Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d4130a2
Pushdown joins - initial AST
stevelordbq Jul 6, 2026
9934567
Pushdown joins - Add predicate converter
stevelordbq Jul 9, 2026
2cb081e
Pushdown joins - Add predicate converter unit test
stevelordbq Jul 9, 2026
d6b1ba3
Pushdown joins - PR updates
stevelordbq Jul 10, 2026
5439080
Pushdown join - add SupportsPushDownJoin implementation
stevelordbq Jul 13, 2026
a3b39b5
Merge branch 'main' into pushdown/join
stevelordbq Jul 16, 2026
4ade087
Pushdown Join: rebase with new AST
stevelordbq Jul 16, 2026
698a65a
Pushdown Join: check for interleaving
stevelordbq Jul 17, 2026
c6b9870
Pushdown Join: Add join to LogicalQuery
stevelordbq Jul 17, 2026
6191695
Pushdown Join: build JoinRelation
stevelordbq Jul 20, 2026
baadb8d
Pushdown Join: update AST validation
stevelordbq Jul 20, 2026
0b9a824
Pushdown Join: add enablePredicateSql option to generate SQL from AST
stevelordbq Jul 21, 2026
7373463
Pushdown Join: add parameter binding to AST SQL rendering
stevelordbq Jul 21, 2026
c9f064c
Merge branch 'main' into pushdown/join
stevelordbq Jul 21, 2026
506e3e8
Pushdown Join: add switch to create Spark 41 SpannerTable for Spark 4…
stevelordbq Jul 21, 2026
0564aae
Pushdown Join: cater for null values in interleave query.
stevelordbq Jul 21, 2026
26a2b43
Pushdown Join: PR comments
stevelordbq Jul 22, 2026
520d17a
Pushdown Join: Fix stack overflow
stevelordbq Jul 22, 2026
34526f3
Pushdown Join: Add acceptance test
stevelordbq Jul 22, 2026
92da451
Pushdown Join: spark.sql.optimizer.datasourceV2JoinPushdown required …
stevelordbq Jul 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 36 additions & 8 deletions README-template.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,16 +50,17 @@ You can find the released jar file from the Releases tag on right of the github
Note 1: Spark compatibility to be tested.

### Connector to Dataproc Image Compatibility Matrix
| Connector \ Dataproc Image | 1.3 | 1.4 | 1.5 | 2.0 | 2.1 | 2.2 | 3.0 | Serverless<br>Image 1.1 | Serverless<br>Image 1.2 | Serverless<br>Image 2.0 | Serverless<br>Image 2.1 | Serverless<br>Image 2.2 | Serverless<br>Image 3.0 |
|----------------------------|---------|---------|---------|---------|---------|---------|---------|-------------------------|-------------------------|-------------------------|-------------------------|-------------------------|-------------------------|
| spark-3.1-spanner | | | | &check; | &check; | &check; | Note 1 | &check; | Note 1 | &check; | &check; | Note 1 | Note 1 |
| spark-3.2-spanner | | | | &check; | &check; | &check; | Note 1 | &check; | Note 1 | &check; | &check; | Note 1 | Note 1 |
| spark-3.3-spanner | | | | &check; | &check; | &check; | Note 1 | &check; | Note 1 | &check; | &check; | Note 1 | Note 1 |
| spark-3.5-spanner | | | | | | &check; | Note 1 | | &check; | | | &check; | Note 1 |
| spark-4.0-spanner | | | | | | | &check; | | | | | | &check; |
| spark-4.1-spanner | | | | | | | &check; | | | | | | &check; |
| Connector \ Dataproc Image | 1.3 | 1.4 | 1.5 | 2.0 | 2.1 | 2.2 | 3.0 | Serverless<br>Image 1.1 | Serverless<br>Image 1.2 | Serverless<br>Image 2.0 | Serverless<br>Image 2.1 | Serverless<br>Image 2.2 | Serverless<br>Image 3.0 |
|----------------------------|---------|---------|---------|---------|---------|---------|----------------|-------------------------|-------------------------|-------------------------|-------------------------|-------------------------|-------------------------|
| spark-3.1-spanner | | | | &check; | &check; | &check; | Note 1 | &check; | Note 1 | &check; | &check; | Note 1 | Note 1 |
| spark-3.2-spanner | | | | &check; | &check; | &check; | Note 1 | &check; | Note 1 | &check; | &check; | Note 1 | Note 1 |
| spark-3.3-spanner | | | | &check; | &check; | &check; | Note 1 | &check; | Note 1 | &check; | &check; | Note 1 | Note 1 |
| spark-3.5-spanner | | | | | | &check; | Note 1 | | &check; | | | &check; | Note 1 |
| spark-4.0-spanner | | | | | | | &check; | | | | | | &check; |
| spark-4.1-spanner | | | | | | | &check; Note 2 | | | | | | &check; Note 2 |

Note 1: Dataproc compatibility to be tested.
Note 2: SupportsPushDownJoin requires Managed Service for Apache Spark Dataproc image 3.0-debian13. Serverless image supports Spark 4.0.1 not Spark 4.1. SupportsPushDownJoin is not available in serverless.

### Connector to Java Compatibility

Expand Down Expand Up @@ -179,6 +180,7 @@ table|String|The Table of the Cloud Spanner database that you are reading from
enableDataboost|Boolean|Enable the [Data Boost](https://cloud.google.com/spanner/docs/databoost/databoost-overview), which provides independent compute resources to query Spanner with near-zero impact to existing workloads. Note1: the option may trigger [extra charge](https://cloud.google.com/spanner/pricing#spanner-data-boost-pricing). Note2: this feature is not supported in Spanner Omni.
readTimestamp|String|An RFC 3339 timestamp identifying the database snapshot to read. Multiple table reads using the same timestamp observe a consistent snapshot. By default, each table read uses the time when its scan is created.
emulatorHost|String|The host and port of the Spanner emulator (e.g. `localhost:9010`) or a Spanner Omni instance (e.g. `localhost:15000`). When set, the connector connects to the emulator or Spanner Omni instead of Cloud Spanner. Useful for local development and testing.
enablePredicateSql|Boolean|Enable SQL generation for Spark Predicates

### Writing to Spanner Tables

Expand Down Expand Up @@ -610,3 +612,29 @@ timestamptz/timestamp with time zone |TimestampType| Only microseconds will be c
Since jsonb is converted to StringType in Spark, a filter containing jsonb column can only be pushed down as a string filter. For the jsonb column, `IN` filter is not pushdown to Cloud Spanner.

Filters containing array column will not be pushed down.

#### Join Pushdown

Spanner requires that pushdown joins are on interleaved tables. Attempts to pushdown joins on other tables will result in Spark executing the join.
Spark currently supports pushdown joins in the Managed Service for Apache Spark Dataproc image 3.0-debian13 or later. Debian12 does not support this.
Spark requires that the Spark session is configured with `spark.sql.optimizer.datasourceV2JoinPushdown` set to `true` to enable the pushdown join feature.

```
spark = SparkSession.builder.appName('MyApp').getOrCreate()
spark.conf.set(
"spark.sql.optimizer.datasourceV2JoinPushdown",
"true"
)
```

Further this connector requires that `enablePredicateSql` is also set to generate the join SQL for pushdown.

```
spark.read.format("cloud-spanner")
.option("projectId", project_id)
.option("instanceId", instance_id)
.option("databaseId", database_id)
.option("table", table)
.option("enablePredicateSql", True)
.load()
```
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,16 @@ default String dropTableDdl(String tableName) {
return "DROP TABLE " + quoteIdentifier(tableName);
}

static Statement getInterleaving() {
return Statement.of(
"SELECT\n"
+ " table_name,\n"
+ " parent_table_name,\n"
+ " interleave_type\n"
+ "FROM information_schema.tables\n"
+ "WHERE table_schema = ''");
}

static SpannerInformationSchema create(Dialect dialect) {
switch (dialect) {
case POSTGRESQL:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ public Table getTable(
boolean hasTable = options.containsKey("table");
boolean hasGraph = options.containsKey("graph");
if (hasTable && !hasGraph) {
return new SpannerTable(options, schema);
return createSpannerTable(options, schema);
} else if (!hasTable && hasGraph) {
return SpannerGraphBuilder.build(options);
} else {
Expand All @@ -68,6 +68,10 @@ public Table getTable(
}
}

public SpannerTable createSpannerTable(CaseInsensitiveStringMap options, StructType schema) {
return new SpannerTable(options, schema);
}

/*
* Returns true if the source has the ability of
* accepting external table metadata when getting tables.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.cloud.spark.spanner.binding;
Comment thread
stevelordbq marked this conversation as resolved.

public class GoogleSqlParameterRegistry extends ParameterRegistry {
@Override
public ParameterRef nextParameter() {
counter++;
final String name = "p" + String.valueOf(counter);
return new ParameterRef(name, name);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.cloud.spark.spanner.binding;

public class ParameterRef {
private final String bindName;
private final String sqlName;

public ParameterRef(String bindName, String sqlName) {
this.bindName = bindName;
this.sqlName = sqlName;
}

public String getBindName() {
return bindName;
}

public String getSqlName() {
return sqlName;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.cloud.spark.spanner.binding;

import com.google.cloud.spanner.Dialect;

public abstract class ParameterRegistry {
protected int counter = 0;

public abstract ParameterRef nextParameter();

public static ParameterRegistry create(Dialect dialect) {
switch (dialect) {
case POSTGRESQL:
return new PostgresSqlParameterRegistry();
case GOOGLE_STANDARD_SQL:
return new GoogleSqlParameterRegistry();
}
throw new IllegalArgumentException("Unsupported dialect: " + dialect);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.cloud.spark.spanner.binding;

public class PostgresSqlParameterRegistry extends ParameterRegistry {
@Override
public ParameterRef nextParameter() {
counter++;
final String name = String.valueOf(counter);
return new ParameterRef("p" + name, name);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.cloud.spark.spanner.binding;

import com.google.cloud.ByteArray;
import com.google.cloud.spanner.Statement;
import com.google.cloud.spanner.Type;
import com.google.cloud.spark.spanner.planning.expression.LiteralExpr;
import java.math.BigDecimal;
import java.time.Instant;
import org.apache.spark.sql.types.DataType;
import org.apache.spark.sql.types.DataTypes;
import org.apache.spark.sql.types.DecimalType;

public final class SpannerTypeBinder {

public static void bind(Statement.Builder builder, String parameter, LiteralExpr literal) {

Object value = literal.getValue();
DataType type = literal.getSparkType();

if (value == null) {
builder.bind(parameter).to(mapToSpannerType(type), (com.google.cloud.spanner.Struct) null);
return;
}

if (type instanceof DecimalType) {
Comment thread
stevelordbq marked this conversation as resolved.
BigDecimal bd = (BigDecimal) value;
// Spanner NUMERIC validation: precision <= 38, scale <= 9
if (bd.precision() <= 38 && bd.scale() <= 9) {
builder.bind(parameter).to(bd);
} else {
throw new IllegalArgumentException("Decimal out of Spanner NUMERIC range");
}
Comment thread
stevelordbq marked this conversation as resolved.
} else if (type.sameType(DataTypes.StringType)) {
builder.bind(parameter).to((String) value);
} else if (type.sameType(DataTypes.LongType)) {
builder.bind(parameter).to((Long) value);
} else if (type.sameType(DataTypes.BooleanType)) {
builder.bind(parameter).to((Boolean) value);
} else if (type.sameType(DataTypes.IntegerType)) {
builder.bind(parameter).to(((Integer) value).longValue());
} else if (type.sameType(DataTypes.ShortType)) {
builder.bind(parameter).to(((Short) value).longValue());
} else if (type.sameType(DataTypes.ByteType)) {
builder.bind(parameter).to(((Byte) value).longValue());
} else if (type.sameType(DataTypes.DoubleType)) {
builder.bind(parameter).to((Double) value);
} else if (type.sameType(DataTypes.FloatType)) {
builder.bind(parameter).to(((Float) value).doubleValue());
} else if (type.sameType(DataTypes.TimestampType)) {

if (value instanceof Instant) {
Instant instant = (Instant) value;
builder
.bind(parameter)
.to(
com.google.cloud.Timestamp.ofTimeSecondsAndNanos(
instant.getEpochSecond(), instant.getNano()));

} else if (value instanceof java.sql.Timestamp) {
java.sql.Timestamp ts = (java.sql.Timestamp) value;
Instant instant = ts.toInstant();
builder
.bind(parameter)
.to(
com.google.cloud.Timestamp.ofTimeSecondsAndNanos(
instant.getEpochSecond(), instant.getNano()));

} else {
throw new IllegalArgumentException(
"Unexpected timestamp literal type: " + value.getClass());
}
} else if (type.sameType(DataTypes.DateType)) {
java.sql.Date date = (java.sql.Date) value;

builder
.bind(parameter)
.to(
com.google.cloud.Date.fromYearMonthDay(
date.toLocalDate().getYear(),
date.toLocalDate().getMonthValue(),
date.toLocalDate().getDayOfMonth()));
} else if (type.sameType(DataTypes.BinaryType)) {
builder.bind(parameter).to(ByteArray.copyFrom((byte[]) value));
} else {
throw new UnsupportedOperationException("Unsupported type: " + type);
}
}

private static Type mapToSpannerType(DataType type) {
if (type instanceof DecimalType) {
return Type.numeric();
} else if (type.sameType(DataTypes.StringType)) {
return Type.string();
} else if (type.sameType(DataTypes.LongType)
|| type.sameType(DataTypes.IntegerType)
|| type.sameType(DataTypes.ShortType)
|| type.sameType(DataTypes.ByteType)) {
return Type.int64();
} else if (type.sameType(DataTypes.BooleanType)) {
return Type.bool();
} else if (type.sameType(DataTypes.DoubleType) || type.sameType(DataTypes.FloatType)) {
return Type.float64();
} else if (type.sameType(DataTypes.TimestampType)) {
return Type.timestamp();
} else if (type.sameType(DataTypes.DateType)) {
return Type.date();
} else if (type.sameType(DataTypes.BinaryType)) {
return Type.bytes();
} else {
Comment thread
stevelordbq marked this conversation as resolved.
throw new UnsupportedOperationException(
"Unsupported Spark type for Spanner null mapping: " + type);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,28 @@ public enum Operator {
MOD
}

Operator operator;
ValueExpr left;
ValueExpr right;
private Operator operator;
private ValueExpr left;
private ValueExpr right;

public ArithmeticExpr(ValueExpr left, Operator operator, ValueExpr right) {
this.left = Objects.requireNonNull(left, "left cannot be null");
this.operator = Objects.requireNonNull(operator, "operator cannot be null");
this.right = Objects.requireNonNull(right, "right cannot be null");
}

public Operator getOperator() {
return operator;
}

public ValueExpr getLeft() {
return left;
}

public ValueExpr getRight() {
return right;
}

@Override
public <T> T accept(SpannerExprVisitor<T> visitor) {
return visitor.visit(this);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,7 @@ public interface SpannerExprVisitor<T> {

T visit(ArithmeticExpr expr);

T visit(UnaryExpr expr);

T visit(FunctionExpr expr);
}
Loading