This is an automated email from the ASF dual-hosted git repository.
claudevdm pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git
The following commit(s) were added to refs/heads/master by this push:
new 4e5ae91554e Support lakehouse PCNT format in BigQueryIO storage read.
(#39597)
4e5ae91554e is described below
commit 4e5ae91554e6d59d9fa23506c8a4448e7bd494e3
Author: claudevdm <[email protected]>
AuthorDate: Fri Aug 21 12:41:01 2026 -0400
Support lakehouse PCNT format in BigQueryIO storage read. (#39597)
* initial
* imt tests
* let read api decide streams for lakehouse
* fixes
* postcommit
* trigger IT
---------
Co-authored-by: Claude <[email protected]>
---
.../IO_Iceberg_Integration_Tests.json | 2 +-
CHANGES.md | 1 +
runners/google-cloud-dataflow-java/build.gradle | 2 +
sdks/java/io/google-cloud-platform/build.gradle | 33 ++
.../beam/sdk/io/gcp/bigquery/BigQueryHelpers.java | 100 ++++-
.../beam/sdk/io/gcp/bigquery/BigQueryIO.java | 13 +
.../io/gcp/bigquery/BigQueryStorageSourceBase.java | 7 +-
.../gcp/bigquery/BigQueryStorageTableSource.java | 3 +-
.../sdk/io/gcp/bigquery/BigQueryTableSource.java | 5 +
.../sdk/io/gcp/bigquery/BigQueryHelpersTest.java | 348 +++++++++++++++++-
.../bigquery/BigQueryIOIcebergManagedTableIT.java | 408 +++++++++++++++++++++
.../io/gcp/bigquery/BigQueryIOStorageReadTest.java | 63 ++++
sdks/java/io/iceberg/build.gradle | 11 +
.../org/apache/beam/sdk/io/iceberg/AddFilesIT.java | 71 +++-
.../iceberg/BigQueryManagedTableCrossEngineIT.java | 183 +++++++++
.../catalog/BigQueryMetastoreCatalogIT.java | 10 +
.../io/iceberg/catalog/IcebergCatalogBaseIT.java | 259 +++++++++++++
.../sdk/io/iceberg/catalog/RESTCatalogBLMSIT.java | 21 +-
18 files changed, 1514 insertions(+), 26 deletions(-)
diff --git a/.github/trigger_files/IO_Iceberg_Integration_Tests.json
b/.github/trigger_files/IO_Iceberg_Integration_Tests.json
index 7ab7bcd9a9c..37dd25bf902 100644
--- a/.github/trigger_files/IO_Iceberg_Integration_Tests.json
+++ b/.github/trigger_files/IO_Iceberg_Integration_Tests.json
@@ -1,4 +1,4 @@
{
"comment": "Modify this file in a trivial way to cause this test suite to
run.",
- "modification": 2
+ "modification": 3
}
diff --git a/CHANGES.md b/CHANGES.md
index 56c062790e2..3b349b3bc5d 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -65,6 +65,7 @@
## I/Os
* Support for X source added (Java/Python)
([#X](https://github.com/apache/beam/issues/X)).
+* BigQueryIO now supports reading BigQuery Lakehouse runtime catalog (BigLake
metastore) Iceberg tables with the Storage Read API, using 4-part
`project.catalog.namespace.table` identifiers (or a `TableReference` with a
composite `catalog.namespace` dataset id). Previously such references were
silently mis-parsed (Java)
([#39597](https://github.com/apache/beam/issues/39597)) .
## New Features / Improvements
diff --git a/runners/google-cloud-dataflow-java/build.gradle
b/runners/google-cloud-dataflow-java/build.gradle
index ec075a4faf7..9b32c482afa 100644
--- a/runners/google-cloud-dataflow-java/build.gradle
+++ b/runners/google-cloud-dataflow-java/build.gradle
@@ -683,6 +683,7 @@ task googleCloudPlatformLegacyWorkerIntegrationTest(type:
Test, dependsOn: copyG
exclude '**/BigQueryIODynamicReadIT.class'
exclude '**/BigQueryIODynamicReadTableRowIT.class'
exclude '**/BigQueryTimestampPicosIT.java'
+ exclude '**/BigQueryIOIcebergManagedTableIT.class'
exclude '**/PubsubReadIT.class'
exclude '**/FhirIOReadIT.class'
exclude '**/DicomIOReadIT.class'
@@ -740,6 +741,7 @@ task googleCloudPlatformRunnerV2IntegrationTest(type: Test)
{
exclude '**/BigQueryIODynamicReadIT.class'
exclude '**/BigQueryIODynamicReadTableRowIT.class'
exclude '**/BigQueryTimestampPicosIT.java'
+ exclude '**/BigQueryIOIcebergManagedTableIT.class'
exclude '**/SpannerWriteIT.class'
exclude '**/*KmsKeyIT.class'
exclude '**/FhirIOReadIT.class'
diff --git a/sdks/java/io/google-cloud-platform/build.gradle
b/sdks/java/io/google-cloud-platform/build.gradle
index 23859ded50e..ff648b4f903 100644
--- a/sdks/java/io/google-cloud-platform/build.gradle
+++ b/sdks/java/io/google-cloud-platform/build.gradle
@@ -203,6 +203,7 @@ task integrationTest(type: Test, dependsOn:
processTestResources) {
outputs.upToDateWhen { false }
include '**/*IT.class'
+ exclude '**/BigQueryIOIcebergManagedTableIT.class'
exclude '**/BigQueryIOReadIT.class'
exclude '**/BigQueryIOStorageQueryIT.class'
exclude '**/BigQueryIOStorageReadIT.class'
@@ -252,6 +253,37 @@ task integrationTestKms(type: Test) {
}
}
+/*
+ Integration tests for BigQueryIO against managed Apache Iceberg tables. Kept
out of the
+ generic integrationTest task because they need a provisioned CLOUD_RESOURCE
connection;
+ override the apache-beam-testing defaults with -PgcpProject,
-PbqImtConnection and
+ -PbqImtStorageUri.
+ */
+task bigQueryManagedIcebergIntegrationTest(type: Test, dependsOn:
processTestResources) {
+ group = "Verification"
+ def gcpProject = project.findProperty('gcpProject') ?: 'apache-beam-testing'
+ def gcpTempRoot = project.findProperty('gcpTempRoot') ?:
'gs://temp-storage-for-end-to-end-tests'
+ def bqImtConnection = project.findProperty('bqImtConnection') ?:
+
'projects/apache-beam-testing/locations/us/connections/apache-beam-testing-storageapi-biglake-nodelete'
+ def bqImtStorageUri = project.findProperty('bqImtStorageUri') ?:
'gs://apache-beam-testing-bq-biglake'
+ systemProperty "beamTestPipelineOptions", JsonOutput.toJson([
+ "--runner=DirectRunner",
+ "--project=${gcpProject}",
+ "--tempRoot=${gcpTempRoot}",
+ "--tempLocation=${gcpTempRoot}",
+ ])
+ systemProperty "beam.bq.imt.connection", bqImtConnection
+ systemProperty "beam.bq.imt.storageUri", bqImtStorageUri
+
+ outputs.upToDateWhen { false }
+
+ include '**/BigQueryIOIcebergManagedTableIT.class'
+
+ maxParallelForks 4
+ classpath = sourceSets.test.runtimeClasspath
+ testClassesDirs = sourceSets.test.output.classesDirs
+}
+
/*
Integration tests for BigQueryIO that run on BigQuery's early rollout region
(us-east7)
with the intended purpose of catching breaking changes from new BigQuery
releases.
@@ -358,4 +390,5 @@ task postCommit {
description = "Integration tests of GCP connectors using the DirectRunner."
dependsOn integrationTest
dependsOn integrationTestKms
+ dependsOn bigQueryManagedIcebergIntegrationTest
}
diff --git
a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryHelpers.java
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryHelpers.java
index 55c703438f0..ef15cd80522 100644
---
a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryHelpers.java
+++
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryHelpers.java
@@ -45,6 +45,7 @@ import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.regex.Matcher;
+import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.apache.beam.sdk.extensions.gcp.util.BackOffAdapter;
import org.apache.beam.sdk.io.FileSystems;
@@ -79,6 +80,9 @@ public class BigQueryHelpers {
private static final Logger LOG =
LoggerFactory.getLogger(BigQueryHelpers.class);
+ /** Matches valid Project ID patterns. */
+ private static final Pattern PROJECT_NAME_SEGMENT_PATTERN =
Pattern.compile("[-a-z0-9]*[a-z0-9]");
+
// Given a potential failure and a current job-id, return the next job-id to
be used on retry.
// Algorithm is as follows (given input of job_id_prefix-N)
// If BigQuery has no status for job_id_prefix-n, we should retry with the
same id.
@@ -458,6 +462,13 @@ public class BigQueryHelpers {
* Parse a table specification in the form {@code
"[project_id]:[dataset_id].[table_id]"} or
* {@code "[project_id].[dataset_id].[table_id]"} or {@code
"[dataset_id].[table_id]"}.
*
+ * <p>Lakehouse runtime catalog (BigLake metastore) tables are referenced
with four parts, {@code
+ * "[project_id].[catalog_id].[namespace_id].[table_id]"} (or {@code
+ * "[project_id]:[catalog_id].[namespace_id].[table_id]"}); these parse to a
composite {@code
+ * "[catalog_id].[namespace_id]"} dataset id, which is the form the BigQuery
APIs accept for such
+ * tables. More generally, when a specification contains more than three
segments, everything
+ * between the project id and the final (table) segment becomes the dataset
id.
+ *
* <p>If the project id is omitted, the default project id is used.
*/
@SuppressWarnings({
@@ -466,19 +477,88 @@ public class BigQueryHelpers {
public static TableReference parseTableSpec(String tableSpec) {
Matcher match = BigQueryIO.TABLE_SPEC.matcher(tableSpec);
if (!match.matches()) {
- throw new IllegalArgumentException(
- String.format(
- "Table specification [%s] is not in one of the expected formats
("
- + " [project_id]:[dataset_id].[table_id],"
- + " [project_id].[dataset_id].[table_id],"
- + " [dataset_id].[table_id])",
- tableSpec));
+ throw invalidTableSpec(tableSpec);
+ }
+
+ // Table ids cannot contain '.', so the table is always the segment after
+ // the last dot.
+ int lastDot = tableSpec.lastIndexOf('.');
+ String table = tableSpec.substring(lastDot + 1);
+ String prefix = tableSpec.substring(0, lastDot);
+
+ String project = null;
+ String dataset;
+ long colonCount = prefix.chars().filter(c -> c == ':').count();
+ if (colonCount == 0) {
+ // No colon means the purely dotted form ("p.d.t", "d.t",
"p.catalog.ns.t"): the
+ // leading segment is the project id when it is a plausible project id.
+ // (Dataset ids may contain characters such as '_' that project ids may
+ // not, in which case the whole prefix is the dataset id.)
+ // The firstDot < length-1 guard keeps degenerate trailing-dot specs
+ // ("pp..t", accepted by the character-set gate with dataset "pp.")
+ // instead of producing an empty dataset id.
+ int firstDot = prefix.indexOf('.');
+ if (firstDot >= 0
+ && firstDot < prefix.length() - 1
+ && BigQueryIO.PROJECT_ID_PATTERN.matcher(prefix.substring(0,
firstDot)).matches()) {
+ project = prefix.substring(0, firstDot);
+ dataset = prefix.substring(firstDot + 1);
+ } else {
+ dataset = prefix;
+ }
+ } else if (colonCount == 1) {
+ // One colon ("p:d.t", "p:catalog.ns.t", "example.com:proj.ds.t"). If the
+ // part before the colon is dotted, it is the domain half of a legacy
+ // domain-scoped project id ("example.com:proj") spelled with '.',
+ // the first segment after the colon completes the project id, and any
+ // remaining middle segments bind as a (possibly composite) dataset.
+ // (Only the domain half of a domain-scoped id may contain dots, so
+ // exactly one segment can complete it.)
+ int colon = prefix.indexOf(':');
+ project = prefix.substring(0, colon);
+ dataset = prefix.substring(colon + 1);
+ int firstDot = dataset.indexOf('.');
+ // Absorb the first dataset segment into a dotted (domain-scoped) project
+ // only when the split leaves a non-empty dataset.
+ if (firstDot >= 0
+ && firstDot < dataset.length() - 1
+ && project.indexOf('.') >= 0
+ && PROJECT_NAME_SEGMENT_PATTERN.matcher(dataset.substring(0,
firstDot)).matches()) {
+ project = project + ":" + dataset.substring(0, firstDot);
+ dataset = dataset.substring(firstDot + 1);
+ }
+ } else if (colonCount == 2) {
+ // Two colons - the last colon is an explicit project terminator. This is
+ // the canonical spelling for a domain-scoped project, whose id itself
+ // contains a colon ("example.com:proj:ds.t"), including with a composite
+ // Lakehouse catalog dataset ("example.com:proj:catalog.ns.t"). Both
+ // domain-scoped spellings keep toTableSpec/parseTableSpec a round trip
+ // for composite dataset ids.
+ int lastColon = prefix.lastIndexOf(':');
+ project = prefix.substring(0, lastColon);
+ dataset = prefix.substring(lastColon + 1);
+ } else {
+ // Project ids contain at most one colon (domain-scoped), so no valid
+ // reference has more than two. Fail early rather than binding an
+ // invalid project id.
+ throw invalidTableSpec(tableSpec);
}
TableReference ref = new TableReference();
- ref.setProjectId(match.group("PROJECT"));
-
- return
ref.setDatasetId(match.group("DATASET")).setTableId(match.group("TABLE"));
+ ref.setProjectId(project);
+ return ref.setDatasetId(dataset).setTableId(table);
+ }
+
+ private static IllegalArgumentException invalidTableSpec(String tableSpec) {
+ return new IllegalArgumentException(
+ String.format(
+ "Table specification [%s] is not in one of the expected formats ("
+ + " [project_id]:[dataset_id].[table_id],"
+ + " [project_id].[dataset_id].[table_id],"
+ + " [dataset_id].[table_id],"
+ + " [project_id]:[catalog_id].[namespace_id].[table_id],"
+ + " [project_id].[catalog_id].[namespace_id].[table_id])",
+ tableSpec));
}
@SuppressWarnings({
diff --git
a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryIO.java
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryIO.java
index c2ac4efb5b5..56b0200e4ba 100644
---
a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryIO.java
+++
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryIO.java
@@ -589,6 +589,13 @@ public class BigQueryIO {
*/
private static final String PROJECT_ID_REGEXP =
"[a-z][-a-z0-9:.]{0,61}[a-z0-9]";
+ /**
+ * Matches a whole string against {@link #PROJECT_ID_REGEXP}. Used by {@link
+ * BigQueryHelpers#parseTableSpec} to decide whether the leading segment of
a dotted table
+ * specification is a project id.
+ */
+ static final Pattern PROJECT_ID_PATTERN = Pattern.compile(PROJECT_ID_REGEXP);
+
/** Regular expression that matches Dataset IDs. */
private static final String DATASET_REGEXP = "[-\\w.]{1,1024}";
@@ -604,6 +611,12 @@ public class BigQueryIO {
/**
* Matches table specifications in the form {@code
"[project_id]:[dataset_id].[table_id]"}, {@code
* "[project_id].[dataset_id].[table_id]"}, or {@code
"[dataset_id].[table_id]"}.
+ *
+ * <p>This pattern is used for syntactic validation only; the assignment of
the matched string's
+ * segments to the project/dataset/table fields is done by {@link
BigQueryHelpers#parseTableSpec},
+ * which additionally understands 4-part Lakehouse runtime catalog (BigLake
metastore) references
+ * {@code "[project_id].[catalog_id].[namespace_id].[table_id]"}, mapping
them to a composite
+ * {@code "[catalog_id].[namespace_id]"} dataset id.
*/
private static final String DATASET_TABLE_REGEXP =
String.format(
diff --git
a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryStorageSourceBase.java
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryStorageSourceBase.java
index 402de619464..327c6304351 100644
---
a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryStorageSourceBase.java
+++
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryStorageSourceBase.java
@@ -150,12 +150,15 @@ abstract class BigQueryStorageSourceBase<T> extends
BoundedSource<T> {
// an appropriate number of streams for the Session to produce reasonable
throughput.
// This is required when using the Read API Source V2.
int streamCount = 0;
- if (!bqOptions.getEnableStorageReadApiV2()) {
+ // numBytes is null for tables that don't report storage statistics, e.g.
Lakehouse
+ // runtime catalog (BigLake metastore) tables. Let BQ read API decide
number of streams
+ // by specifying streamCount == 0.
+ boolean isNumBytesMissing = targetTable != null &&
targetTable.getNumBytes() == null;
+ if (!bqOptions.getEnableStorageReadApiV2() && !isNumBytesMissing) {
if (desiredBundleSizeBytes > 0) {
long tableSizeBytes = (targetTable != null) ?
targetTable.getNumBytes() : 0;
streamCount = (int) Math.min(tableSizeBytes / desiredBundleSizeBytes,
MAX_SPLIT_COUNT);
}
-
streamCount = Math.max(streamCount, MIN_SPLIT_COUNT);
}
diff --git
a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryStorageTableSource.java
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryStorageTableSource.java
index 8b7240158dc..6887e663b39 100644
---
a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryStorageTableSource.java
+++
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryStorageTableSource.java
@@ -163,12 +163,13 @@ public class BigQueryStorageTableSource<T> extends
BigQueryStorageSourceBase<T>
@Override
public long getEstimatedSizeBytes(PipelineOptions options) throws Exception {
Table table = getTargetTable(options.as(BigQueryOptions.class));
- if (table != null) {
+ if (table != null && table.getNumBytes() != null) {
return table.getNumBytes();
}
// If the table does not exist, then it will be null.
// Avoid the NullPointerException here, allow a more meaningful table
"not_found"
// error to be shown to the user, upon table read.
+ // Lakehouse runtime catalog (BigLake metastore) tables exist but report
no numBytes.
return 0;
}
diff --git
a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryTableSource.java
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryTableSource.java
index 1b6aedf8cb1..ba9a8a15610 100644
---
a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryTableSource.java
+++
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryTableSource.java
@@ -82,6 +82,11 @@ class BigQueryTableSource<T> extends BigQuerySourceBase<T> {
}
Long numBytes = table.getNumBytes();
+ if (numBytes == null) {
+ // Tables that don't report storage statistics, e.g. Lakehouse
runtime catalog
+ // (BigLake metastore) tables.
+ numBytes = 0L;
+ }
if (table.getStreamingBuffer() != null
&& table.getStreamingBuffer().getEstimatedBytes() != null) {
numBytes +=
table.getStreamingBuffer().getEstimatedBytes().longValue();
diff --git
a/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryHelpersTest.java
b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryHelpersTest.java
index 0d36d7bb46d..aa2108d6271 100644
---
a/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryHelpersTest.java
+++
b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryHelpersTest.java
@@ -18,6 +18,7 @@
package org.apache.beam.sdk.io.gcp.bigquery;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
import com.google.api.client.util.Data;
import com.google.api.services.bigquery.model.Clustering;
@@ -28,6 +29,7 @@ import com.google.api.services.bigquery.model.JobStatus;
import com.google.api.services.bigquery.model.TableReference;
import com.google.api.services.bigquery.model.TableRow;
import java.util.Arrays;
+import java.util.List;
import java.util.Optional;
import java.util.Random;
import java.util.Set;
@@ -101,13 +103,357 @@ public class BigQueryHelpersTest {
assertEquals("table_name", ref.getTableId());
}
+ @Test
+ public void testTableParsing_lakehouseCatalogDotted() {
+ // 4-part Lakehouse runtime catalog reference:
project.catalog.namespace.table. The
+ // catalog+namespace form a composite dataset id, including when the
catalog name uses the
+ // GCS-bucket charset (lowercase, digits, dashes).
+ TableReference ref =
BigQueryHelpers.parseTableSpec("my-project.my-bucket-catalog.my_ns.tbl");
+ assertEquals("my-project", ref.getProjectId());
+ assertEquals("my-bucket-catalog.my_ns", ref.getDatasetId());
+ assertEquals("tbl", ref.getTableId());
+ }
+
+ @Test
+ public void testTableParsing_lakehouseCatalogColon() {
+ TableReference ref =
BigQueryHelpers.parseTableSpec("my-project:my-catalog.my_ns.tbl");
+ assertEquals("my-project", ref.getProjectId());
+ assertEquals("my-catalog.my_ns", ref.getDatasetId());
+ assertEquals("tbl", ref.getTableId());
+ }
+
+ @Test
+ public void testTableParsing_lakehouseCatalogNoProject() {
+ // Dataset ids may contain characters that project ids may not (e.g. '_'),
in which case the
+ // whole prefix is the (composite) dataset id.
+ TableReference ref =
BigQueryHelpers.parseTableSpec("my_catalog.my_ns.tbl");
+ assertEquals(null, ref.getProjectId());
+ assertEquals("my_catalog.my_ns", ref.getDatasetId());
+ assertEquals("tbl", ref.getTableId());
+ }
+
+ @Test
+ public void testTableParsing_multiLevelNamespace() {
+ // More than four segments: everything between the project and the table
becomes the dataset.
+ TableReference ref =
BigQueryHelpers.parseTableSpec("my-project.cat.ns1.ns2.tbl");
+ assertEquals("my-project", ref.getProjectId());
+ assertEquals("cat.ns1.ns2", ref.getDatasetId());
+ assertEquals("tbl", ref.getTableId());
+ }
+
+ @Test
+ public void testTableParsing_lakehouseWithPartitionDecorator() {
+ TableReference ref =
BigQueryHelpers.parseTableSpec("my-project.my-catalog.ns.tbl$20260101");
+ assertEquals("my-project", ref.getProjectId());
+ assertEquals("my-catalog.ns", ref.getDatasetId());
+ assertEquals("tbl$20260101", ref.getTableId());
+ }
+
+ @Test
+ public void testTableParsing_domainScopedProjectPreserved() {
+ // Legacy domain-scoped projects keep their historical binding.
+ TableReference ref =
BigQueryHelpers.parseTableSpec("example.com:project:data_set.tbl");
+ assertEquals("example.com:project", ref.getProjectId());
+ assertEquals("data_set", ref.getDatasetId());
+ assertEquals("tbl", ref.getTableId());
+
+ ref = BigQueryHelpers.parseTableSpec("example.com:project.data_set.tbl");
+ assertEquals("example.com:project", ref.getProjectId());
+ assertEquals("data_set", ref.getDatasetId());
+ assertEquals("tbl", ref.getTableId());
+ }
+
+ @Test
+ public void testTableParsing_domainScopedProjectWithCompositeDataset() {
+ // With two colons, the last colon is an explicit project terminator, so a
domain-scoped
+ // project can address a Lakehouse catalog table: the remainder binds as a
composite
+ // dataset.
+ TableReference ref =
BigQueryHelpers.parseTableSpec("example.com:project:cat.ns.tbl");
+ assertEquals("example.com:project", ref.getProjectId());
+ assertEquals("cat.ns", ref.getDatasetId());
+ assertEquals("tbl", ref.getTableId());
+
+ // The dotted spelling binds consistently: the first segment after the
colon completes the
+ // domain-scoped project id; further middle segments form the composite
dataset. (Project
+ // names cannot contain dots, so the pre-fix greedy binding of this string
was invalid.)
+ ref = BigQueryHelpers.parseTableSpec("example.com:project.cat.ns.tbl");
+ assertEquals("example.com:project", ref.getProjectId());
+ assertEquals("cat.ns", ref.getDatasetId());
+ assertEquals("tbl", ref.getTableId());
+ }
+
+ @Test
+ public void testTableParsing_partitionDecoratorColonForm() {
+ TableReference ref =
BigQueryHelpers.parseTableSpec("my-project:my-catalog.ns.tbl$20260101");
+ assertEquals("my-project", ref.getProjectId());
+ assertEquals("my-catalog.ns", ref.getDatasetId());
+ assertEquals("tbl$20260101", ref.getTableId());
+ }
+
+ @Test
+ public void testTableParsing_tableIdSpecialCharacters() {
+ // Table ids may contain spaces, '@', '$', dashes, and unicode letters,
none of which
+ // affect segment binding (only '.' and ':' are structural).
+ TableReference ref =
BigQueryHelpers.parseTableSpec("my-project.data_set.my table@x-1");
+ assertEquals("my-project", ref.getProjectId());
+ assertEquals("data_set", ref.getDatasetId());
+ assertEquals("my table@x-1", ref.getTableId());
+
+ ref = BigQueryHelpers.parseTableSpec("my-project.my-catalog.ns.ग्राहक");
+ assertEquals("my-project", ref.getProjectId());
+ assertEquals("my-catalog.ns", ref.getDatasetId());
+ assertEquals("ग्राहक", ref.getTableId());
+ }
+
+ @Test
+ public void testTableParsing_colonFormMultiLevelNamespace() {
+ TableReference ref =
BigQueryHelpers.parseTableSpec("my-project:cat.ns1.ns2.tbl");
+ assertEquals("my-project", ref.getProjectId());
+ assertEquals("cat.ns1.ns2", ref.getDatasetId());
+ assertEquals("tbl", ref.getTableId());
+ }
+
+ @Test
+ public void testTableParsing_domainScopedMultiLevelNamespace() {
+ // Single-colon domain-scoped spelling with a multi-level composite
dataset: the first
+ // segment after the colon completes the project id; everything else up to
the table binds
+ // as the dataset.
+ TableReference ref =
BigQueryHelpers.parseTableSpec("example.com:proj.cat.ns1.ns2.tbl");
+ assertEquals("example.com:proj", ref.getProjectId());
+ assertEquals("cat.ns1.ns2", ref.getDatasetId());
+ assertEquals("tbl", ref.getTableId());
+ }
+
+ @Test
+ public void testTableParsing_consecutiveDotsPreservedInDataset() {
+ // Dataset content is preserved verbatim; degenerate consecutive dots are
not collapsed
+ // (the service rejects the invalid dataset id; the parser must not mangle
it).
+ TableReference ref = BigQueryHelpers.parseTableSpec("my-project:a..b.tbl");
+ assertEquals("my-project", ref.getProjectId());
+ assertEquals("a..b", ref.getDatasetId());
+ assertEquals("tbl", ref.getTableId());
+ }
+
+ @Test
+ public void testTableParsing_trailingDotStaysOnLegacyBinding() {
+ // Degenerate trailing-dot specs pass the character-set gate with the dot
inside the dataset
+ // id. They must keep their historical binding; the parser must never
produce an empty
+ // dataset id.
+ TableReference ref = BigQueryHelpers.parseTableSpec("pp..t");
+ assertEquals(null, ref.getProjectId());
+ assertEquals("pp.", ref.getDatasetId());
+ assertEquals("t", ref.getTableId());
+
+ ref = BigQueryHelpers.parseTableSpec("google.com:proj..t");
+ assertEquals("google.com", ref.getProjectId());
+ assertEquals("proj.", ref.getDatasetId());
+ assertEquals("t", ref.getTableId());
+ }
+
+ @Test
+ public void testTableParsing_absorptionRequiresProjectCharset() {
+ // The segment absorbed into a domain-scoped project must be legal in the
project charset,
+ // exactly what the historical greedy regex could absorb.
+ // '_' and uppercase were never absorbable:
+ TableReference ref =
BigQueryHelpers.parseTableSpec("google.com:My_Cat.n.x.t");
+ assertEquals("google.com", ref.getProjectId());
+ assertEquals("My_Cat.n.x", ref.getDatasetId());
+ assertEquals("t", ref.getTableId());
+
+ // ...but a single-character segment was:
+ ref = BigQueryHelpers.parseTableSpec("example.com:a.b.t");
+ assertEquals("example.com:a", ref.getProjectId());
+ assertEquals("b", ref.getDatasetId());
+ assertEquals("t", ref.getTableId());
+ }
+
+ @Test
+ public void testTableParsing_nonProjectLeadingSegmentsFoldIntoDataset() {
+ // Leading segments that cannot be project ids (uppercase, '_', too short,
leading '_')
+ // fold into the (composite) dataset with a null project, regardless of
depth.
+ String[][] cases = {
+ {"My-Ds.ns.t", "My-Ds.ns"},
+ {"my_cat.a.b.t", "my_cat.a.b"},
+ {"a.c.n.t", "a.c.n"},
+ {"_x.a.b.c.t", "_x.a.b.c"},
+ };
+ for (String[] c : cases) {
+ TableReference ref = BigQueryHelpers.parseTableSpec(c[0]);
+ assertEquals(c[0], null, ref.getProjectId());
+ assertEquals(c[0], c[1], ref.getDatasetId());
+ assertEquals(c[0], "t", ref.getTableId());
+ }
+ }
+
+ @Test
+ public void testTableParsing_domainScopedNoAbsorptionWithoutDottedDataset() {
+ // Dot-less post-colon remainder: nothing to absorb; the pre-colon text
alone is the project.
+ TableReference ref =
BigQueryHelpers.parseTableSpec("google.com:data_set.tbl");
+ assertEquals("google.com", ref.getProjectId());
+ assertEquals("data_set", ref.getDatasetId());
+ assertEquals("tbl", ref.getTableId());
+ }
+
+ @Test
+ public void testTableParsing_twoColonsDotlessDomain() {
+ // The explicit-terminator rule does not require a dotted (domain-like)
project.
+ TableReference ref = BigQueryHelpers.parseTableSpec("p1:x2:data_set.tbl");
+ assertEquals("p1:x2", ref.getProjectId());
+ assertEquals("data_set", ref.getDatasetId());
+ assertEquals("tbl", ref.getTableId());
+
+ ref = BigQueryHelpers.parseTableSpec("p1:x2:cat.ns.tbl");
+ assertEquals("p1:x2", ref.getProjectId());
+ assertEquals("cat.ns", ref.getDatasetId());
+ assertEquals("tbl", ref.getTableId());
+ }
+
+ @Test
+ public void testTableParsing_uppercaseCatalogUnderNormalProject() {
+ TableReference ref =
BigQueryHelpers.parseTableSpec("my-project:My_Cat.ns.tbl");
+ assertEquals("my-project", ref.getProjectId());
+ assertEquals("My_Cat.ns", ref.getDatasetId());
+ assertEquals("tbl", ref.getTableId());
+ }
+
+ @Test
+ public void testTableParsingError_rejectedForms() {
+ String[] rejected = {
+ "p1:t2", // colon form requires dataset.table
+ "ds.", // empty table
+ ".t", // empty dataset
+ "p1:d2.t3.", // trailing separator
+ "p1::ds.t", // colon cannot follow the separator colon
+ "MyProj:ds.t", // uppercase cannot precede a colon
+ "d1:d2:d3:data_set.tbl", // project ids contain at most one colon, so
two is the maximum
+ };
+ for (String spec : rejected) {
+ try {
+ BigQueryHelpers.parseTableSpec(spec);
+ fail("Expected IllegalArgumentException for spec: " + spec);
+ } catch (IllegalArgumentException expected) {
+ // expected
+ }
+ }
+ }
+
+ @Test
+ public void testParseTableSpecIdempotentThroughToTableSpec() {
+ // parse(toTableSpec(parse(s))) == parse(s) for every accepted spec, the
string-level
+ // consequence of the round-trip fixed point, covering all rebinding
families.
+ String[] specs = {
+ "my-project:data_set.tbl",
+ "my-project.cat.ns.tbl",
+ "my-project:cat.ns.tbl",
+ "my-project:cat.ns1.ns2.tbl",
+ "example.com:proj:cat.ns.tbl",
+ "example.com:proj.cat.ns.tbl",
+ "my_cat.ns.tbl",
+ "my-project:My_Cat.ns.tbl",
+ "my-project:a..b.tbl",
+ "pp..t",
+ "google.com:proj..t",
+ "my-project:data_set.tbl$20260101",
+ };
+ for (String spec : specs) {
+ TableReference once = BigQueryHelpers.parseTableSpec(spec);
+ TableReference twice =
BigQueryHelpers.parseTableSpec(BigQueryHelpers.toTableSpec(once));
+ assertEquals(spec, once.getProjectId(), twice.getProjectId());
+ assertEquals(spec, once.getDatasetId(), twice.getDatasetId());
+ assertEquals(spec, once.getTableId(), twice.getTableId());
+ }
+ }
+
+ @Test
+ public void testTableParsingError_colonIsNotATableSeparator() {
+ // The dataset/table separator must be a dot; a spec with no dot at all is
rejected even
+ // if it contains colons.
+ thrown.expect(IllegalArgumentException.class);
+ BigQueryHelpers.parseTableSpec("my-project:data_set:tbl");
+ }
+
+ @Test
+ public void testTableParsing_projectlessCatalogSpecIsAmbiguous() {
+ // A project-less catalog reference is indistinguishable from
project.dataset.table when the
+ // catalog name fits the project-id charset: the project interpretation
wins. Users must
+ // write the full 4-part name (or use a TableReference) for such catalogs;
only catalog
+ // names that are illegal as project ids (e.g. containing '_') parse as a
composite dataset
+ // with the project left to be defaulted.
+ TableReference ref =
BigQueryHelpers.parseTableSpec("my-bucket-catalog.my_ns.tbl");
+ assertEquals("my-bucket-catalog", ref.getProjectId());
+ assertEquals("my_ns", ref.getDatasetId());
+ assertEquals("tbl", ref.getTableId());
+ }
+
+ @Test
+ public void testTableParsing_informationSchema() {
+ // INFORMATION_SCHEMA views ride along with the multi-segment rule: the
pseudo-schema folds
+ // into the dataset id. (Neither the Storage Read API nor extract jobs
support reading
+ // INFORMATION_SCHEMA views, so this reference form never reaches a table
API; pinning the
+ // binding here documents that it at least keeps a valid, undotted project
id.)
+ TableReference ref =
+
BigQueryHelpers.parseTableSpec("my-project.data_set.INFORMATION_SCHEMA.TABLES");
+ assertEquals("my-project", ref.getProjectId());
+ assertEquals("data_set.INFORMATION_SCHEMA", ref.getDatasetId());
+ assertEquals("TABLES", ref.getTableId());
+ }
+
+ @Test
+ public void testTableParsing_shortFirstSegmentIsNotAProject() {
+ // A single character cannot be a project id, so the prefix folds into the
dataset id
+ // (historical behavior).
+ TableReference ref = BigQueryHelpers.parseTableSpec("a.b.c");
+ assertEquals(null, ref.getProjectId());
+ assertEquals("a.b", ref.getDatasetId());
+ assertEquals("c", ref.getTableId());
+ }
+
+ @Test
+ public void testToTableSpecParseTableSpecRoundTrip() {
+ List<TableReference> refs =
+ Arrays.asList(
+ new TableReference()
+ .setProjectId("my-project")
+ .setDatasetId("data_set")
+ .setTableId("tbl"),
+ new TableReference()
+ .setProjectId("my-project")
+ .setDatasetId("my-catalog.my_ns")
+ .setTableId("tbl"),
+ new TableReference()
+ .setProjectId("example.com:project")
+ .setDatasetId("data_set")
+ .setTableId("tbl"),
+ new TableReference()
+ .setProjectId("example.com:project")
+ .setDatasetId("my-catalog.my_ns")
+ .setTableId("tbl"),
+ new TableReference()
+ .setProjectId("my-project")
+ .setDatasetId("cat.ns1.ns2")
+ .setTableId("tbl"),
+ new TableReference()
+ .setProjectId("my-project")
+ .setDatasetId("my-catalog.ns")
+ .setTableId("tbl$20260101"),
+ new TableReference().setDatasetId("data_set").setTableId("tbl"));
+ for (TableReference ref : refs) {
+ TableReference reparsed =
BigQueryHelpers.parseTableSpec(BigQueryHelpers.toTableSpec(ref));
+ assertEquals(BigQueryHelpers.toTableSpec(ref), ref.getProjectId(),
reparsed.getProjectId());
+ assertEquals(BigQueryHelpers.toTableSpec(ref), ref.getDatasetId(),
reparsed.getDatasetId());
+ assertEquals(BigQueryHelpers.toTableSpec(ref), ref.getTableId(),
reparsed.getTableId());
+ }
+ }
+
@Test
public void testTableParsingError0() {
String expectedMessage =
"Table specification [foo_bar_baz] is not in one of the expected
formats ("
+ " [project_id]:[dataset_id].[table_id],"
+ " [project_id].[dataset_id].[table_id],"
- + " [dataset_id].[table_id])";
+ + " [dataset_id].[table_id],"
+ + " [project_id]:[catalog_id].[namespace_id].[table_id],"
+ + " [project_id].[catalog_id].[namespace_id].[table_id])";
thrown.expect(IllegalArgumentException.class);
thrown.expectMessage(expectedMessage);
diff --git
a/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryIOIcebergManagedTableIT.java
b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryIOIcebergManagedTableIT.java
new file mode 100644
index 00000000000..83d47ab57e0
--- /dev/null
+++
b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryIOIcebergManagedTableIT.java
@@ -0,0 +1,408 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.apache.beam.sdk.io.gcp.bigquery;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+
+import com.google.api.services.bigquery.Bigquery;
+import com.google.api.services.bigquery.model.QueryRequest;
+import com.google.api.services.bigquery.model.QueryResponse;
+import com.google.api.services.bigquery.model.Table;
+import com.google.api.services.bigquery.model.TableCell;
+import com.google.api.services.bigquery.model.TableFieldSchema;
+import com.google.api.services.bigquery.model.TableRow;
+import com.google.api.services.bigquery.model.TableSchema;
+import java.io.IOException;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.LongStream;
+import org.apache.beam.sdk.Pipeline;
+import org.apache.beam.sdk.extensions.gcp.options.GcpOptions;
+import org.apache.beam.sdk.io.gcp.bigquery.BigQueryIO.TypedRead.Method;
+import org.apache.beam.sdk.io.gcp.testing.BigqueryClient;
+import org.apache.beam.sdk.testing.PAssert;
+import org.apache.beam.sdk.testing.TestPipeline;
+import org.apache.beam.sdk.transforms.Create;
+import org.apache.beam.sdk.transforms.MapElements;
+import org.apache.beam.sdk.values.PCollection;
+import org.apache.beam.sdk.values.TypeDescriptors;
+import
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Splitter;
+import
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableList;
+import
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableMap;
+import
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Iterables;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.hamcrest.Matchers;
+import org.joda.time.Duration;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Integration tests for BigQueryIO against managed Apache Iceberg tables in
BigQuery.
+ *
+ * <p>Requires a CLOUD_RESOURCE connection whose service account can
administer the storage bucket.
+ * Defaults target standing apache-beam-testing resources (shared with {@link
+ * StorageApiSinkCreateIfNeededIT}); override with the {@code
beam.bq.imt.connection} and {@code
+ * beam.bq.imt.storageUri} system properties.
+ */
+@RunWith(JUnit4.class)
+public class BigQueryIOIcebergManagedTableIT {
+
+ private static final BigqueryClient BQ_CLIENT =
+ new BigqueryClient("BigQueryIOIcebergManagedTableIT");
+ // BigqueryClient's query helpers stage a destination table (DDL rejects
that) and run in the
+ // default location, so SQL goes through a raw client instead.
+ private static final Bigquery RAW_BQ =
+ BigqueryClient.getNewBigqueryClient("BigQueryIOIcebergManagedTableIT");
+ private static final String PROJECT =
+ TestPipeline.testingPipelineOptions().as(GcpOptions.class).getProject();
+
+ // Connection in
"projects/{project}/locations/{location}/connections/{connection}" form.
+ private static final String CONNECTION =
+ System.getProperty(
+ "beam.bq.imt.connection",
+
"projects/apache-beam-testing/locations/us/connections/apache-beam-testing-storageapi-biglake-nodelete");
+ private static final String STORAGE_URI_ROOT =
+ System.getProperty("beam.bq.imt.storageUri",
"gs://apache-beam-testing-bq-biglake")
+ + "/BigQueryIOIcebergManagedTableIT";
+
+ private static final String DATASET_ID = "bq_imt_it_" + System.nanoTime();
+
+ private static final TableSchema BASE_SCHEMA =
+ new TableSchema()
+ .setFields(
+ ImmutableList.of(
+ new
TableFieldSchema().setName("id").setType("INT64").setMode("REQUIRED"),
+ new TableFieldSchema().setName("name").setType("STRING")));
+
+ @BeforeClass
+ public static void setup() throws IOException, InterruptedException {
+ // The dataset must be colocated with the connection.
+ BQ_CLIENT.createNewDataset(
+ PROJECT, DATASET_ID, /* defaultTableExpirationMs= */ null,
connectionLocation());
+ }
+
+ @AfterClass
+ public static void cleanup() {
+ BQ_CLIENT.deleteDataset(PROJECT, DATASET_ID);
+ }
+
+ private static String connectionLocation() {
+ return Splitter.on('/').splitToList(CONNECTION).get(3);
+ }
+
+ /** Connection reference in the dotted form BigQuery DDL accepts. */
+ private static String connectionDotted() {
+ List<String> parts = Splitter.on('/').splitToList(CONNECTION);
+ return String.format("%s.%s.%s", parts.get(1), parts.get(3), parts.get(5));
+ }
+
+ private static String tableSpec(String table) {
+ return String.format("%s.%s.%s", PROJECT, DATASET_ID, table);
+ }
+
+ private static List<TableRow> rows(long from, long to) {
+ return LongStream.range(from, to)
+ .mapToObj(i -> new TableRow().set("id", i).set("name", "row_" + i))
+ .collect(Collectors.toList());
+ }
+
+ private static String canonical(TableRow row) {
+ return row.get("id") + "|" + row.get("name");
+ }
+
+ private static List<String> canonical(List<TableRow> rows) {
+ return rows.stream()
+ .map(BigQueryIOIcebergManagedTableIT::canonical)
+ .collect(Collectors.toList());
+ }
+
+ private ImmutableMap<String, String> bigLakeConfig() {
+ return ImmutableMap.of(
+ BigQueryIO.CONNECTION_ID, CONNECTION,
+ BigQueryIO.STORAGE_URI, STORAGE_URI_ROOT);
+ }
+
+ /** Runs SQL in the connection's location and returns the result rows. */
+ private static List<TableRow> runSql(String sql) throws IOException {
+ QueryResponse response =
+ RAW_BQ
+ .jobs()
+ .query(
+ PROJECT,
+ new QueryRequest()
+ .setQuery(sql)
+ .setUseLegacySql(false)
+ .setLocation(connectionLocation())
+ .setTimeoutMs(180_000L))
+ .execute();
+ if (!Boolean.TRUE.equals(response.getJobComplete())) {
+ throw new IOException("Query did not complete in time: " + sql);
+ }
+ return response.getRows();
+ }
+
+ private static String firstCell(List<TableRow> rows) {
+ TableCell cell = (TableCell) Iterables.getOnlyElement(rows).getF().get(0);
+ return (String) cell.getV();
+ }
+
+ private void createManagedTableViaDdl(String table, String columns) throws
IOException {
+ runSql(
+ String.format(
+ "CREATE TABLE `%s` (%s) WITH CONNECTION `%s` "
+ + "OPTIONS (file_format='PARQUET', table_format='ICEBERG',
storage_uri='%s/%s/%s')",
+ tableSpec(table), columns, connectionDotted(), STORAGE_URI_ROOT,
DATASET_ID, table));
+ }
+
+ private void runWrite(
+ BigQueryIO.Write.Method method,
+ String table,
+ List<TableRow> input,
+ BigQueryIO.Write.CreateDisposition createDisposition,
+ @Nullable TableSchema schema,
+ boolean withBigLakeConfiguration) {
+ Pipeline p = Pipeline.create(TestPipeline.testingPipelineOptions());
+ BigQueryIO.Write<TableRow> write =
+ BigQueryIO.writeTableRows()
+ .to(tableSpec(table))
+ .withMethod(method)
+ .withCreateDisposition(createDisposition)
+
.withWriteDisposition(BigQueryIO.Write.WriteDisposition.WRITE_APPEND);
+ if (schema != null) {
+ write = write.withSchema(schema);
+ }
+ if (withBigLakeConfiguration) {
+ write = write.withBigLakeConfiguration(bigLakeConfig());
+ }
+ p.apply(Create.of(input).withCoder(TableRowJsonCoder.of())).apply(write);
+ p.run().waitUntilFinish();
+ }
+
+ private long countRows(String table) throws IOException {
+ return Long.parseLong(
+ firstCell(runSql(String.format("SELECT COUNT(*) FROM `%s`",
tableSpec(table)))));
+ }
+
+ @Test
+ public void testAtLeastOnceWriteThenDirectRead() throws IOException,
InterruptedException {
+ String table = "alo_create_" + System.nanoTime();
+ List<TableRow> input = rows(0, 20);
+ runWrite(
+ BigQueryIO.Write.Method.STORAGE_API_AT_LEAST_ONCE,
+ table,
+ input,
+ BigQueryIO.Write.CreateDisposition.CREATE_IF_NEEDED,
+ BASE_SCHEMA,
+ /* withBigLakeConfiguration= */ true);
+
+ Table created = BQ_CLIENT.getTableResource(PROJECT, DATASET_ID, table);
+ assertNotNull(created.getBiglakeConfiguration());
+ assertEquals("ICEBERG",
created.getBiglakeConfiguration().getTableFormat());
+ // Stats can lag behind recent writes and report zero, but are never null
for these tables.
+ assertNotNull(created.getNumBytes());
+
+ Pipeline p = Pipeline.create(TestPipeline.testingPipelineOptions());
+ PCollection<String> full =
+ p.apply(
+ "ReadFull",
+
BigQueryIO.readTableRows().from(tableSpec(table)).withMethod(Method.DIRECT_READ))
+ .apply(
+ "CanonicalFull",
+ MapElements.into(TypeDescriptors.strings())
+ .via(BigQueryIOIcebergManagedTableIT::canonical));
+ PAssert.that(full).containsInAnyOrder(canonical(input));
+
+ PCollection<String> pushdown =
+ p.apply(
+ "ReadPushdown",
+ BigQueryIO.readTableRows()
+ .from(tableSpec(table))
+ .withMethod(Method.DIRECT_READ)
+ .withSelectedFields(ImmutableList.of("id"))
+ .withRowRestriction("id < 5"))
+ .apply(
+ "CanonicalPushdown",
+ MapElements.into(TypeDescriptors.strings()).via(r ->
String.valueOf(r.get("id"))));
+ PAssert.that(pushdown).containsInAnyOrder("0", "1", "2", "3", "4");
+ p.run().waitUntilFinish();
+ }
+
+ /** Exactly-once batch write: bounded STORAGE_WRITE_API (PENDING streams +
batch commit). */
+ @Test
+ public void testExactlyOnceBatchWrite() throws IOException,
InterruptedException {
+ String table = "eo_batch_" + System.nanoTime();
+ runWrite(
+ BigQueryIO.Write.Method.STORAGE_WRITE_API,
+ table,
+ rows(0, 50),
+ BigQueryIO.Write.CreateDisposition.CREATE_IF_NEEDED,
+ BASE_SCHEMA,
+ /* withBigLakeConfiguration= */ true);
+ assertEquals(50L, countRows(table));
+ }
+
+ /** Exactly-once streaming write: unbounded STORAGE_WRITE_API with
triggering frequency. */
+ @Test
+ public void testExactlyOnceStreamingWrite() throws IOException,
InterruptedException {
+ String table = "eo_streaming_" + System.nanoTime();
+ Pipeline p = Pipeline.create(TestPipeline.testingPipelineOptions());
+ // The streaming exactly-once path is chosen by input boundedness; flip
the bounded Create.
+ p.apply(Create.of(rows(0, 30)).withCoder(TableRowJsonCoder.of()))
+ .setIsBoundedInternal(PCollection.IsBounded.UNBOUNDED)
+ .apply(
+ BigQueryIO.writeTableRows()
+ .to(tableSpec(table))
+ .withMethod(BigQueryIO.Write.Method.STORAGE_WRITE_API)
+
.withCreateDisposition(BigQueryIO.Write.CreateDisposition.CREATE_IF_NEEDED)
+
.withWriteDisposition(BigQueryIO.Write.WriteDisposition.WRITE_APPEND)
+ .withSchema(BASE_SCHEMA)
+ .withBigLakeConfiguration(bigLakeConfig())
+ .withNumStorageWriteApiStreams(1)
+ .withTriggeringFrequency(Duration.standardSeconds(1)));
+ p.run().waitUntilFinish();
+ assertEquals(30L, countRows(table));
+ }
+
+ /** The common production case: CREATE_NEVER write into a pre-existing
managed Iceberg table. */
+ @Test
+ public void testCreateNeverToPreExistingTable() throws IOException,
InterruptedException {
+ String table = "create_never_" + System.nanoTime();
+ createManagedTableViaDdl(table, "id INT64 NOT NULL, name STRING");
+ runWrite(
+ BigQueryIO.Write.Method.STORAGE_API_AT_LEAST_ONCE,
+ table,
+ rows(0, 10),
+ BigQueryIO.Write.CreateDisposition.CREATE_NEVER,
+ null,
+ /* withBigLakeConfiguration= */ false);
+ assertThat(countRows(table), Matchers.greaterThanOrEqualTo(10L));
+ }
+
+ /**
+ * Schema widening on a managed Iceberg table: tables.patch accepts a new
nullable column (unlike
+ * Iceberg tables in catalogs, which refuse patch), and subsequent writes
can use it.
+ */
+ @Test
+ public void testSchemaFieldAdditionThenWrite() throws IOException,
InterruptedException {
+ String table = "schema_update_" + System.nanoTime();
+ createManagedTableViaDdl(table, "id INT64 NOT NULL, name STRING");
+
+ TableSchema widened =
+ new TableSchema()
+ .setFields(
+ ImmutableList.of(
+ new
TableFieldSchema().setName("id").setType("INT64").setMode("REQUIRED"),
+ new TableFieldSchema().setName("name").setType("STRING"),
+ new
TableFieldSchema().setName("extra").setType("STRING")));
+ BQ_CLIENT.updateTableSchema(PROJECT, DATASET_ID, table, widened);
+
+ List<TableRow> input =
+ ImmutableList.of(
+ new TableRow().set("id", 1L).set("name", "a").set("extra", "x"),
+ new TableRow().set("id", 2L).set("name", "b").set("extra", "y"));
+ runWrite(
+ BigQueryIO.Write.Method.STORAGE_API_AT_LEAST_ONCE,
+ table,
+ input,
+ BigQueryIO.Write.CreateDisposition.CREATE_NEVER,
+ null,
+ /* withBigLakeConfiguration= */ false);
+
+ long withExtra =
+ Long.parseLong(
+ firstCell(
+ runSql(
+ String.format(
+ "SELECT COUNT(*) FROM `%s` WHERE extra IS NOT NULL",
tableSpec(table)))));
+ assertThat(withExtra, Matchers.greaterThanOrEqualTo(2L));
+ }
+
+ /**
+ * FILE_LOADS append into a pre-existing table. withBigLakeConfiguration is
rejected outside the
+ * Storage Write API, so FILE_LOADS cannot create these tables itself.
+ */
+ @Test
+ public void testFileLoadsSinglePartition() throws IOException,
InterruptedException {
+ String table = "file_loads_" + System.nanoTime();
+ createManagedTableViaDdl(table, "id INT64 NOT NULL, name STRING");
+ runWrite(
+ BigQueryIO.Write.Method.FILE_LOADS,
+ table,
+ rows(0, 25),
+ BigQueryIO.Write.CreateDisposition.CREATE_NEVER,
+ BASE_SCHEMA,
+ /* withBigLakeConfiguration= */ false);
+ assertEquals(25L, countRows(table));
+ }
+
+ /** Legacy streaming (insertAll) is accepted; rows arrive through the
streaming buffer. */
+ @Test
+ public void testStreamingInsertsWrite() throws IOException,
InterruptedException {
+ String table = "streaming_inserts_" + System.nanoTime();
+ createManagedTableViaDdl(table, "id INT64 NOT NULL, name STRING");
+ runWrite(
+ BigQueryIO.Write.Method.STREAMING_INSERTS,
+ table,
+ rows(0, 5),
+ BigQueryIO.Write.CreateDisposition.CREATE_NEVER,
+ BASE_SCHEMA,
+ /* withBigLakeConfiguration= */ false);
+ assertThat(countRows(table), Matchers.greaterThanOrEqualTo(5L));
+ }
+
+ /** Query read with time travel: managed Iceberg tables support FOR
SYSTEM_TIME AS OF. */
+ @Test
+ public void testQueryReadWithTimeTravel() throws IOException,
InterruptedException {
+ String table = "time_travel_" + System.nanoTime();
+ runWrite(
+ BigQueryIO.Write.Method.STORAGE_WRITE_API,
+ table,
+ rows(0, 10),
+ BigQueryIO.Write.CreateDisposition.CREATE_IF_NEEDED,
+ BASE_SCHEMA,
+ /* withBigLakeConfiguration= */ true);
+ // Server-side timestamp between the two writes.
+ String asOf = firstCell(runSql("SELECT STRING(CURRENT_TIMESTAMP())"));
+ runWrite(
+ BigQueryIO.Write.Method.STORAGE_WRITE_API,
+ table,
+ rows(10, 20),
+ BigQueryIO.Write.CreateDisposition.CREATE_NEVER,
+ null,
+ /* withBigLakeConfiguration= */ false);
+
+ Pipeline p = Pipeline.create(TestPipeline.testingPipelineOptions());
+ PCollection<String> counted =
+ p.apply(
+ BigQueryIO.readTableRows()
+ .fromQuery(
+ String.format(
+ "SELECT COUNT(*) AS c FROM `%s` FOR SYSTEM_TIME AS
OF TIMESTAMP '%s'",
+ tableSpec(table), asOf))
+ .usingStandardSql())
+ .apply(
+ MapElements.into(TypeDescriptors.strings()).via(r ->
String.valueOf(r.get("c"))));
+ PAssert.that(counted).containsInAnyOrder("10");
+ p.run().waitUntilFinish();
+ }
+}
diff --git
a/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryIOStorageReadTest.java
b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryIOStorageReadTest.java
index 783114cbacc..32161193d6a 100644
---
a/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryIOStorageReadTest.java
+++
b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryIOStorageReadTest.java
@@ -401,6 +401,25 @@ public class BigQueryIOStorageReadTest {
assertEquals(100, tableSource.getEstimatedSizeBytes(options));
}
+ @Test
+ public void testTableSourceEstimatedSize_WhenNumBytesNull() throws Exception
{
+ fakeDatasetService.createDataset("foo.com:project", "dataset", "", "",
null);
+ TableReference tableRef =
BigQueryHelpers.parseTableSpec("foo.com:project:dataset.table");
+ Table table = new Table().setTableReference(tableRef).setNumBytes(null);
+ fakeDatasetService.createTable(table);
+
+ BigQueryStorageTableSource<TableRow> tableSource =
+ BigQueryStorageTableSource.create(
+ ValueProvider.StaticValueProvider.of(tableRef),
+ null,
+ null,
+ new TableRowParser(),
+ TableRowJsonCoder.of(),
+ new FakeBigQueryServices().withDatasetService(fakeDatasetService));
+
+ assertEquals(0L, tableSource.getEstimatedSizeBytes(options));
+ }
+
@Test
public void testTableSourceInitialSplit() throws Exception {
doTableSourceInitialSplitTest(1024L, 1024);
@@ -416,6 +435,50 @@ public class BigQueryIOStorageReadTest {
doTableSourceInitialSplitTest(10L, 10_000);
}
+ @Test
+ public void testTableSourceInitialSplit_WhenNumBytesNull() throws Exception {
+ fakeDatasetService.createDataset("foo.com:project", "dataset", "", "",
null);
+ TableReference tableRef =
BigQueryHelpers.parseTableSpec("foo.com:project:dataset.table");
+
+ Table table = new
Table().setTableReference(tableRef).setNumBytes(null).setSchema(TABLE_SCHEMA);
+ fakeDatasetService.createTable(table);
+
+ CreateReadSessionRequest expectedRequest =
+ CreateReadSessionRequest.newBuilder()
+ .setParent("projects/project-id")
+ .setReadSession(
+ ReadSession.newBuilder()
+
.setTable("projects/foo.com:project/datasets/dataset/tables/table")
+ .setReadOptions(ReadSession.TableReadOptions.newBuilder()))
+ .setMaxStreamCount(0)
+ .build();
+
+ ReadSession.Builder builder =
+ ReadSession.newBuilder()
+
.setAvroSchema(AvroSchema.newBuilder().setSchema(AVRO_SCHEMA_STRING))
+ .setDataFormat(DataFormat.AVRO);
+ for (int i = 0; i < 5; i++) {
+ builder.addStreams(ReadStream.newBuilder().setName("stream-" + i));
+ }
+
+ StorageClient fakeStorageClient = mock(StorageClient.class);
+
when(fakeStorageClient.createReadSession(expectedRequest)).thenReturn(builder.build());
+
+ BigQueryStorageTableSource<TableRow> tableSource =
+ BigQueryStorageTableSource.create(
+ ValueProvider.StaticValueProvider.of(tableRef),
+ null,
+ null,
+ new TableRowParser(),
+ TableRowJsonCoder.of(),
+ new FakeBigQueryServices()
+ .withDatasetService(fakeDatasetService)
+ .withStorageClient(fakeStorageClient));
+
+ List<? extends BoundedSource<TableRow>> sources = tableSource.split(1024L,
options);
+ assertEquals(5, sources.size());
+ }
+
private static final String AVRO_SCHEMA_STRING =
"{\"namespace\": \"example.avro\",\n"
+ " \"type\": \"record\",\n"
diff --git a/sdks/java/io/iceberg/build.gradle
b/sdks/java/io/iceberg/build.gradle
index 983ebd07fef..e2e8a12d01e 100644
--- a/sdks/java/io/iceberg/build.gradle
+++ b/sdks/java/io/iceberg/build.gradle
@@ -169,6 +169,17 @@ task integrationTest(type: Test) {
"--project=${gcpProject}",
"--tempLocation=${gcpTempLocation}",
])
+ // Warehouse (= catalog) used by the BigLake REST catalog tests;
overridable for runs
+ // against a non-default project's catalog.
+ systemProperty "beam.iceberg.biglake.warehouse",
+ project.findProperty('biglakeWarehouse') ?:
'gs://managed-iceberg-biglake-its'
+ // Connection + storage root for BigQueryManagedTableCrossEngineIT.
+ if (project.findProperty('bqImtConnection') != null) {
+ systemProperty "beam.bq.imt.connection",
project.findProperty('bqImtConnection')
+ }
+ if (project.findProperty('bqImtStorageUri') != null) {
+ systemProperty "beam.bq.imt.storageUri",
project.findProperty('bqImtStorageUri')
+ }
// Disable Gradle cache: these ITs interact with live service that should
always be considered "out of date"
outputs.upToDateWhen { false }
diff --git
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesIT.java
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesIT.java
index 528d6453eac..39a1ab4d427 100644
---
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesIT.java
+++
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesIT.java
@@ -102,7 +102,12 @@ import org.slf4j.LoggerFactory;
public class AddFilesIT {
private static final Logger LOG = LoggerFactory.getLogger(AddFilesIT.class);
- private static final String WAREHOUSE = "gs://managed-iceberg-biglake-its";
+ // Bucket-backed BigLake catalogs are named after their bucket. Overridable
for local runs
+ // against a different project's catalog:
-Dbeam.iceberg.biglake.warehouse=gs://my-bucket
+ private static final String CATALOG_NAME =
+ System.getProperty("beam.iceberg.biglake.warehouse",
"gs://managed-iceberg-biglake-its")
+ .replace("gs://", "");
+ private static final String WAREHOUSE = "gs://" + CATALOG_NAME;
private static final String PROJECT =
TestPipeline.testingPipelineOptions().as(GcpOptions.class).getProject();
@Rule public TestName testName = new TestName();
@@ -130,7 +135,7 @@ public class AddFilesIT {
private Storage storage;
private PubsubClient pubsub;
private Notification notification;
- private final String namespace = getClass().getSimpleName();
+ private final String namespace = getClass().getSimpleName() + "_" +
System.currentTimeMillis();
private String srcTableName;
private String destTableName;
private TableIdentifier srcTableId;
@@ -302,7 +307,7 @@ public class AddFilesIT {
addFilesPipeline.cancel();
// check all records are there
- checkRecordsInDestinationTable();
+ checkRecordsInDestinationTable(/* alsoCheckWithBigQueryIO= */ false);
}
/**
@@ -403,11 +408,20 @@ public class AddFilesIT {
addFilesPipeline.cancel();
// check all records are there
- checkRecordsInDestinationTable();
+ checkRecordsInDestinationTable(/* alsoCheckWithBigQueryIO= */ false);
}
@Test
public void testBatchParquetImport() throws IOException {
+ testBatchParquetImport(false);
+ }
+
+ @Test
+ public void testBatchParquetImportToUIT() throws IOException {
+ testBatchParquetImport(true);
+ }
+
+ private void testBatchParquetImport(boolean isUIT) throws IOException {
// start with a table that does not exist
String parquetDir = format("%s/%s/", WAREHOUSE, dirName);
@@ -448,6 +462,11 @@ public class AddFilesIT {
// before adding, confirm the destination table still does not exist
assertFalse(catalog.tableExists(destTableId));
+ Map<String, String> tableProps = new HashMap<>(TABLE_PROPS);
+ if (isUIT) {
+ tableProps.put("gcp.biglake.bigquery-dml.enabled", "true");
+ }
+
// run batch AddFiles
Pipeline p = Pipeline.create();
PCollectionRowTuple tuple =
@@ -457,9 +476,9 @@ public class AddFilesIT {
IcebergCatalogConfig.builder().setCatalogProperties(BIGLAKE_PROPS).build(),
namespace + "." + destTableName,
null,
- PARTITION_FIELDS,
+ isUIT ? null : PARTITION_FIELDS,
null,
- TABLE_PROPS,
+ tableProps,
null,
null));
PAssert.that(tuple.get("errors")).empty();
@@ -474,11 +493,11 @@ public class AddFilesIT {
LOG.info(
"Destination table has registered all source files ({} files).",
writtenFilePaths.size());
- // check all records are there
- checkRecordsInDestinationTable();
+ // check all records are there.
+ checkRecordsInDestinationTable(/* alsoCheckWithBigQueryIO= */ true);
}
- private void checkRecordsInDestinationTable() {
+ private void checkRecordsInDestinationTable(boolean alsoCheckWithBigQueryIO)
{
Pipeline s = Pipeline.create();
PCollection<Row> destRows =
s.apply(
@@ -488,9 +507,43 @@ public class AddFilesIT {
"table", destTableId.toString(),
"catalog_properties", BIGLAKE_PROPS)))
.getSinglePCollection();
PAssert.that(destRows).containsInAnyOrder(TEST_ROWS);
+
+ if (alsoCheckWithBigQueryIO) {
+ // Cross-engine check: the same rows must be readable with BigQueryIO
via the 4-part
+ // project.catalog.namespace.table reference. Rows are compared on a
canonical string
+ // because BigQuery widens int32 (age) to INT64, so whole-row equality
does not hold.
+ PCollection<String> bqRows =
+ s.apply(
+ "read with BigQueryIO",
+ Managed.read(Managed.BIGQUERY)
+ .withConfig(
+ ImmutableMap.of(
+ "table",
+ format(
+ "%s.%s.%s.%s",
+ PROJECT,
+ CATALOG_NAME,
+ destTableId.namespace(),
+ destTableId.name()))))
+ .getSinglePCollection()
+ .apply(
+ "canonicalize bq rows",
+
MapElements.into(strings()).via(AddFilesIT::canonicalRecord));
+ PAssert.that(bqRows)
+ .containsInAnyOrder(
+
TEST_ROWS.stream().map(AddFilesIT::canonicalRecord).collect(Collectors.toList()));
+ }
s.run().waitUntilFinish();
}
+ private static String canonicalRecord(Row row) {
+ return String.valueOf((Object) row.getValue("id"))
+ + "|"
+ + row.getValue("name")
+ + "|"
+ + String.valueOf((Object) row.getValue("age"));
+ }
+
private boolean checkTableHasRegisteredParquetFiles(List<String>
parquetFiles) {
Table destTable = catalog.loadTable(destTableId);
diff --git
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/BigQueryManagedTableCrossEngineIT.java
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/BigQueryManagedTableCrossEngineIT.java
new file mode 100644
index 00000000000..72e99d8348c
--- /dev/null
+++
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/BigQueryManagedTableCrossEngineIT.java
@@ -0,0 +1,183 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.apache.beam.sdk.io.iceberg;
+
+import com.google.api.services.bigquery.Bigquery;
+import com.google.api.services.bigquery.model.QueryRequest;
+import com.google.api.services.bigquery.model.QueryResponse;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import java.util.stream.LongStream;
+import org.apache.beam.sdk.Pipeline;
+import org.apache.beam.sdk.extensions.gcp.options.GcpOptions;
+import org.apache.beam.sdk.io.gcp.testing.BigqueryClient;
+import org.apache.beam.sdk.managed.Managed;
+import org.apache.beam.sdk.testing.PAssert;
+import org.apache.beam.sdk.testing.TestPipeline;
+import org.apache.beam.sdk.transforms.MapElements;
+import org.apache.beam.sdk.values.PCollection;
+import org.apache.beam.sdk.values.TypeDescriptors;
+import
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Splitter;
+import
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableMap;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Reads a managed Apache Iceberg table in BigQuery through {@code
Managed.ICEBERG} with the
+ * BigQuery Lakehouse Iceberg REST catalog, using the {@code bq://} warehouse
that surfaces BigQuery
+ * datasets as namespaces.
+ *
+ * <p>Shares the {@code beam.bq.imt.*} system properties with
BigQueryIOIcebergManagedTableIT in the
+ * google-cloud-platform module.
+ */
+@RunWith(JUnit4.class)
+public class BigQueryManagedTableCrossEngineIT {
+
+ private static final BigqueryClient BQ_CLIENT =
+ new BigqueryClient("BigQueryManagedTableCrossEngineIT");
+ // BigqueryClient's query helpers stage a destination table (DDL rejects
that) and run in the
+ // default location, so SQL goes through a raw client instead.
+ private static final Bigquery RAW_BQ =
+ BigqueryClient.getNewBigqueryClient("BigQueryManagedTableCrossEngineIT");
+ private static final String PROJECT =
+ TestPipeline.testingPipelineOptions().as(GcpOptions.class).getProject();
+
+ // Connection in
"projects/{project}/locations/{location}/connections/{connection}" form.
+ private static final String CONNECTION =
+ System.getProperty(
+ "beam.bq.imt.connection",
+
"projects/apache-beam-testing/locations/us/connections/apache-beam-testing-storageapi-biglake-nodelete");
+ private static final String STORAGE_URI_ROOT =
+ System.getProperty("beam.bq.imt.storageUri",
"gs://apache-beam-testing-bq-biglake")
+ + "/BigQueryManagedTableCrossEngineIT";
+
+ private static final String DATASET_ID = "bq_imt_xengine_" +
System.nanoTime();
+
+ @BeforeClass
+ public static void setup() throws IOException, InterruptedException {
+ // The dataset must be colocated with the connection.
+ BQ_CLIENT.createNewDataset(
+ PROJECT, DATASET_ID, /* defaultTableExpirationMs= */ null,
connectionLocation());
+ }
+
+ @AfterClass
+ public static void cleanup() {
+ BQ_CLIENT.deleteDataset(PROJECT, DATASET_ID);
+ }
+
+ private static String connectionLocation() {
+ return Splitter.on('/').splitToList(CONNECTION).get(3);
+ }
+
+ /** Connection reference in the dotted form BigQuery DDL accepts. */
+ private static String connectionDotted() {
+ List<String> parts = Splitter.on('/').splitToList(CONNECTION);
+ return String.format("%s.%s.%s", parts.get(1), parts.get(3), parts.get(5));
+ }
+
+ /** Runs SQL in the connection's location. */
+ private static void runSql(String sql) throws IOException {
+ QueryResponse response =
+ RAW_BQ
+ .jobs()
+ .query(
+ PROJECT,
+ new QueryRequest()
+ .setQuery(sql)
+ .setUseLegacySql(false)
+ .setLocation(connectionLocation())
+ .setTimeoutMs(180_000L))
+ .execute();
+ if (!Boolean.TRUE.equals(response.getJobComplete())) {
+ throw new IOException("Query did not complete in time: " + sql);
+ }
+ }
+
+ @Test
+ public void testManagedIcebergReadByThreePartName() throws IOException {
+ String table = "managed_read_" + System.nanoTime();
+ runSql(
+ String.format(
+ "CREATE TABLE `%s.%s.%s` (id INT64, name STRING) WITH CONNECTION
`%s` "
+ + "OPTIONS (file_format='PARQUET', table_format='ICEBERG',
storage_uri='%s/%s/%s')",
+ PROJECT, DATASET_ID, table, connectionDotted(), STORAGE_URI_ROOT,
DATASET_ID, table));
+ runSql(
+ String.format(
+ "INSERT INTO `%s.%s.%s` "
+ + "SELECT id, CONCAT('row_', CAST(id AS STRING)) "
+ + "FROM UNNEST(GENERATE_ARRAY(0, 9)) id",
+ PROJECT, DATASET_ID, table));
+ // The catalog resolves the table through its exported Iceberg metadata,
and automatic exports
+ // can lag far behind recent writes; exporting makes the test
deterministic.
+ runSql(String.format("EXPORT TABLE METADATA FROM `%s.%s.%s`", PROJECT,
DATASET_ID, table));
+
+ Map<String, Object> config =
+ ImmutableMap.<String, Object>builder()
+ .put("table", DATASET_ID + "." + table)
+ .put(
+ "catalog_properties",
+ ImmutableMap.<String, String>builder()
+ .put("type", "rest")
+ .put("uri",
"https://biglake.googleapis.com/iceberg/v1/restcatalog")
+ .put(
+ "warehouse",
+ String.format(
+ "bq://projects/%s/locations/%s", PROJECT,
connectionLocation()))
+ .put("header.x-goog-user-project", PROJECT)
+ .put("rest-metrics-reporting-enabled", "false")
+ .put("io-impl", "org.apache.iceberg.gcp.gcs.GCSFileIO")
+ .put("rest.auth.type",
"org.apache.iceberg.gcp.auth.GoogleAuthManager")
+ .build())
+ .build();
+
+ List<String> expected =
+ LongStream.range(0, 10).mapToObj(i -> i + "|row_" +
i).collect(Collectors.toList());
+
+ Pipeline p = Pipeline.create(TestPipeline.testingPipelineOptions());
+ PCollection<String> icebergRead =
+ p.apply("Iceberg read",
Managed.read(Managed.ICEBERG).withConfig(config))
+ .getSinglePCollection()
+ .apply(
+ "canonicalize iceberg",
+ MapElements.into(TypeDescriptors.strings())
+ .via(row -> row.getInt64("id") + "|" +
row.getString("name")));
+ PAssert.that(icebergRead).containsInAnyOrder(expected);
+
+ // The same table read through the BigQuery lens, addressed
project.dataset.table.
+ PCollection<String> bigQueryRead =
+ p.apply(
+ "BigQuery read",
+ Managed.read(Managed.BIGQUERY)
+ .withConfig(
+ ImmutableMap.of(
+ "table", String.format("%s.%s.%s", PROJECT,
DATASET_ID, table))))
+ .getSinglePCollection()
+ .apply(
+ "canonicalize bigquery",
+ MapElements.into(TypeDescriptors.strings())
+ .via(row -> row.getInt64("id") + "|" +
row.getString("name")));
+ PAssert.that(bigQueryRead).containsInAnyOrder(expected);
+
+ p.run().waitUntilFinish();
+ }
+}
diff --git
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/catalog/BigQueryMetastoreCatalogIT.java
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/catalog/BigQueryMetastoreCatalogIT.java
index a34e580d29b..a0c41259601 100644
---
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/catalog/BigQueryMetastoreCatalogIT.java
+++
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/catalog/BigQueryMetastoreCatalogIT.java
@@ -49,6 +49,16 @@ public class BigQueryMetastoreCatalogIT extends
IcebergCatalogBaseIT {
return "bqms";
}
+ @Override
+ public String bigQueryTableSpec(String tableId) {
+ // The BigQuery metastore federation surfaces Iceberg namespaces as
ordinary BigQuery
+ // datasets, so tables use plain 3-part project.dataset.table references,
with the namespace
+ // as the dataset.
+ TableIdentifier identifier = TableIdentifier.parse(tableId);
+ return String.format(
+ "%s.%s.%s", OPTIONS.getProject(), identifier.namespace(),
identifier.name());
+ }
+
@Override
public Catalog createCatalog() {
return CatalogUtil.loadCatalog(
diff --git
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/catalog/IcebergCatalogBaseIT.java
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/catalog/IcebergCatalogBaseIT.java
index 2435cdb231d..9eca20189e5 100644
---
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/catalog/IcebergCatalogBaseIT.java
+++
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/catalog/IcebergCatalogBaseIT.java
@@ -55,6 +55,8 @@ import org.apache.beam.sdk.extensions.gcp.options.GcpOptions;
import org.apache.beam.sdk.extensions.gcp.options.GcsOptions;
import org.apache.beam.sdk.extensions.gcp.util.GcsUtil;
import org.apache.beam.sdk.extensions.gcp.util.gcsfs.GcsPath;
+import org.apache.beam.sdk.io.gcp.bigquery.BigQueryIO;
+import org.apache.beam.sdk.io.gcp.bigquery.BigQueryIO.TypedRead.Method;
import org.apache.beam.sdk.io.iceberg.IcebergUtils;
import org.apache.beam.sdk.io.iceberg.cdc.IcebergCdcMetadataColumns;
import org.apache.beam.sdk.managed.Managed;
@@ -127,6 +129,7 @@ import org.joda.time.Duration;
import org.joda.time.Instant;
import org.joda.time.LocalDate;
import org.joda.time.LocalTime;
+import org.joda.time.ReadableInstant;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
@@ -169,6 +172,18 @@ public abstract class IcebergCatalogBaseIT implements
Serializable {
public abstract String type();
+ /**
+ * Catalogs whose tables are also queryable with BigQuery return the
BigQuery table reference for
+ * the given Iceberg table id: either the 4-part {@code
project.catalog.namespace.table} form for
+ * Lakehouse runtime catalog (BigLake metastore REST) tables, or the 3-part
{@code
+ * project.dataset.table} form for the BigQuery metastore federation, where
namespaces surface as
+ * datasets. Returning null (the default) disables the cross-engine read
checks in {@link
+ * #testReadWithBigQueryIO()}.
+ */
+ public @Nullable String bigQueryTableSpec(String tableId) {
+ return null;
+ }
+
public void catalogSetup() {
((SupportsNamespaces) catalog).createNamespace(Namespace.of(namespace()));
}
@@ -738,6 +753,250 @@ public abstract class IcebergCatalogBaseIT implements
Serializable {
containsInAnyOrder(expectedRows.stream().map(RECORD_FUNC::apply).toArray()));
}
+ /**
+ * Cross-engine consistency: rows written through the Iceberg catalog must
be readable with
+ * BigQueryIO's Storage Read API using the catalog's BigQuery table
reference (see {@link
+ * #bigQueryTableSpec(String)}). Exercises the full read, server-side
projection + filtering
+ * push-down, and a query read with the reference embedded in SQL.
+ *
+ * <p>Rows are compared on a projection of fields whose types survive the
Iceberg-to-BigQuery
+ * mapping losslessly; BigQuery widens e.g. {@code int32} to {@code INT64},
so whole-row equality
+ * against the Iceberg schema does not hold by design.
+ */
+ @Test
+ public void testReadWithBigQueryIO() throws Exception {
+ String tableSpec = bigQueryTableSpec(tableId());
+ assumeTrue("Catalog does not surface its tables in BigQuery", tableSpec !=
null);
+ Table table = catalog.createTable(TableIdentifier.parse(tableId()),
ICEBERG_SCHEMA);
+ List<Row> expectedRows = populateTable(table);
+ int intCutoff = numRecords() / 2;
+
+ List<String> expectedFull =
+ expectedRows.stream()
+ .map(
+ r ->
+ canonical(
+ r.getString("str"),
+ r.getInt64("modulo_5"),
+ r.getBoolean("bool_field"),
+ r.getInt32("int_field")))
+ .collect(Collectors.toList());
+ List<String> expectedFiltered =
+ expectedRows.stream()
+ .filter(r -> checkStateNotNull(r.getInt32("int_field")) <
intCutoff)
+ .map(r -> canonical(r.getString("str"), r.getInt32("int_field")))
+ .collect(Collectors.toList());
+
+ PCollection<String> fullRead =
+ pipeline
+ .apply(
+ "BQ direct read",
+
BigQueryIO.readTableRows().from(tableSpec).withMethod(Method.DIRECT_READ))
+ .apply(
+ "canonicalize full",
+ MapElements.into(TypeDescriptors.strings())
+ .via(
+ tr ->
+ canonical(
+ tr.get("str"),
+ tr.get("modulo_5"),
+ tr.get("bool_field"),
+ tr.get("int_field"))));
+ PAssert.that(fullRead).containsInAnyOrder(expectedFull);
+
+ PCollection<String> pushdownRead =
+ pipeline
+ .apply(
+ "BQ pushdown read",
+ BigQueryIO.readTableRows()
+ .from(tableSpec)
+ .withMethod(Method.DIRECT_READ)
+ .withSelectedFields(Arrays.asList("str", "int_field"))
+ .withRowRestriction("int_field < " + intCutoff))
+ .apply(
+ "canonicalize pushdown",
+ MapElements.into(TypeDescriptors.strings())
+ .via(tr -> canonical(tr.get("str"), tr.get("int_field"))));
+ PAssert.that(pushdownRead).containsInAnyOrder(expectedFiltered);
+
+ PCollection<String> queryRead =
+ pipeline
+ .apply(
+ "BQ query read",
+ BigQueryIO.readTableRows()
+ .fromQuery(
+ String.format(
+ "SELECT str, int_field FROM `%s` WHERE int_field <
%d",
+ tableSpec, intCutoff))
+ .usingStandardSql()
+ .withMethod(Method.DIRECT_READ))
+ .apply(
+ "canonicalize query",
+ MapElements.into(TypeDescriptors.strings())
+ .via(tr -> canonical(tr.get("str"), tr.get("int_field"))));
+ PAssert.that(queryRead).containsInAnyOrder(expectedFiltered);
+
+ pipeline.run().waitUntilFinish();
+ }
+
+ /**
+ * String canonicalization for cross-engine row comparison: {@code
String.valueOf} normalizes
+ * representation differences (e.g. {@code Long} vs {@code Integer} vs
numeric strings in {@code
+ * TableRow}).
+ */
+ private static String canonical(Object... values) {
+ StringBuilder sb = new StringBuilder();
+ for (Object value : values) {
+ sb.append(String.valueOf(normalize(value))).append('|');
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Normalizes representation differences that are inherent to the
Iceberg-to-BigQuery type mapping
+ * so that values compare equal across engines: BigQuery widens {@code
float32} to {@code
+ * FLOAT64}, and the schema'd Beam Row conversion used by the Managed
BigQuery read maps TIMESTAMP
+ * to Beam's DATETIME field type, a millisecond Joda instant, while the
Iceberg-side rows carry
+ * microsecond {@code java.time.Instant}s. (Plain {@code readTableRows()} is
not affected: its
+ * {@code TableRow} values are strings with full microsecond precision.)
+ */
+ private static Object normalize(Object value) {
+ if (value instanceof Float) {
+ return ((Float) value).doubleValue();
+ }
+ if (value instanceof java.time.Instant) {
+ return ((java.time.Instant) value).toEpochMilli();
+ }
+ if (value instanceof ReadableInstant) {
+ return ((ReadableInstant) value).getMillis();
+ }
+ return value;
+ }
+
+ /**
+ * Full-schema canonicalization for the type-fidelity check. Works on both
the Iceberg-side
+ * expected rows and the BigQuery-lens rows returned by the Managed BigQuery
read; {@link
+ * #normalize(Object)} absorbs the documented type widenings.
+ */
+ private static String fidelityCanonical(Row row) {
+ Row nested = checkStateNotNull(row.getRow("row"));
+ @Nullable Row nullableNested = row.getRow("nullable_row");
+ return canonical(
+ row.getValue("str"),
+ row.getValue("char"),
+ row.getValue("modulo_5"),
+ row.getValue("bool_field"),
+ row.getValue("int_field"),
+ nestedCanonical(nested),
+ (Object) row.getValue("arr_long"),
+ nullableNested == null ? "null" : nestedCanonical(nullableNested),
+ row.getValue("nullable_long"),
+ row.getValue("datetime_tz"),
+ row.getValue("datetime"),
+ row.getValue("date"),
+ row.getValue("time"));
+ }
+
+ private static String nestedCanonical(Row nested) {
+ Row doubly = checkStateNotNull(nested.getRow("nested_row"));
+ return canonical(
+ nested.getValue("nested_str"),
+ doubly.getValue("doubly_nested_str"),
+ doubly.getValue("doubly_nested_float"),
+ nested.getValue("nested_int"),
+ nested.getValue("nested_float"));
+ }
+
+ /**
+ * Type fidelity across the Iceberg-to-BigQuery read: every field of {@link
#BEAM_SCHEMA}
+ * (including timestamps, datetime/date/time, arrays, and nullable nested
rows) must survive the
+ * round trip with its value intact, modulo the documented widenings handled
by {@link
+ * #normalize(Object)}.
+ */
+ @Test
+ public void testReadWithBigQueryIOTypeFidelity() throws Exception {
+ String tableSpec = bigQueryTableSpec(tableId());
+ assumeTrue("Catalog does not surface its tables in BigQuery", tableSpec !=
null);
+ Table table = catalog.createTable(TableIdentifier.parse(tableId()),
ICEBERG_SCHEMA);
+ List<Row> expectedRows = populateTable(table);
+ List<String> expected =
+ expectedRows.stream()
+ .map(IcebergCatalogBaseIT::fidelityCanonical)
+ .collect(Collectors.toList());
+
+ PCollection<String> actual =
+ pipeline
+ .apply(
+ "BQ managed read",
+
Managed.read(Managed.BIGQUERY).withConfig(ImmutableMap.of("table", tableSpec)))
+ .getSinglePCollection()
+ .apply(
+ "fidelity canonicalize",
+ MapElements.into(TypeDescriptors.strings())
+ .via(IcebergCatalogBaseIT::fidelityCanonical));
+ PAssert.that(actual).containsInAnyOrder(expected);
+ pipeline.run().waitUntilFinish();
+ }
+
+ /**
+ * Merge-on-read: BigQuery reads must apply Iceberg v2 row-level delete
files. Writes an
+ * equality-delete file with the Iceberg API (as an external engine would),
commits it as a row
+ * delta, and asserts the deleted rows are absent from the BigQueryIO read.
+ */
+ @Test
+ public void testReadWithBigQueryIOAfterRowLevelDeletes() throws Exception {
+ String tableSpec = bigQueryTableSpec(tableId());
+ assumeTrue("Catalog does not surface its tables in BigQuery", tableSpec !=
null);
+ Table table =
+ catalog.createTable(
+ TableIdentifier.parse(tableId()),
+ ICEBERG_SCHEMA,
+ PartitionSpec.unpartitioned(),
+ ImmutableMap.of("format-version", "2"));
+ List<Row> expectedRows = populateTable(table);
+ int deleteBelow = numRecords() / 2;
+
+ org.apache.iceberg.Schema deleteRowSchema =
ICEBERG_SCHEMA.select("int_field");
+ OutputFile deleteOutputFile;
+ try (FileIO io = table.io()) {
+ deleteOutputFile = io.newOutputFile(table.location() + "/deletes-" +
UUID.randomUUID());
+ }
+ EqualityDeleteWriter<Record> deleteWriter =
+ Parquet.writeDeletes(deleteOutputFile)
+ .createWriterFunc(GenericParquetWriter::create)
+ .overwrite()
+ .rowSchema(deleteRowSchema)
+ .withSpec(PartitionSpec.unpartitioned())
+ .equalityFieldIds(ICEBERG_SCHEMA.findField("int_field").fieldId())
+ .buildEqualityWriter();
+ try (EqualityDeleteWriter<Record> closingWriter = deleteWriter) {
+ for (int i = 0; i < deleteBelow; i++) {
+ GenericRecord deleteRecord = GenericRecord.create(deleteRowSchema);
+ deleteRecord.setField("int_field", i);
+ closingWriter.write(deleteRecord);
+ }
+ }
+ table.newRowDelta().addDeletes(deleteWriter.toDeleteFile()).commit();
+
+ List<String> expectedRemaining =
+ expectedRows.stream()
+ .filter(r -> checkStateNotNull(r.getInt32("int_field")) >=
deleteBelow)
+ .map(r -> canonical(r.getString("str"), r.getInt32("int_field")))
+ .collect(Collectors.toList());
+
+ PCollection<String> afterDeletes =
+ pipeline
+ .apply(
+ "BQ read after deletes",
+
BigQueryIO.readTableRows().from(tableSpec).withMethod(Method.DIRECT_READ))
+ .apply(
+ "canonicalize after deletes",
+ MapElements.into(TypeDescriptors.strings())
+ .via(tr -> canonical(tr.get("str"), tr.get("int_field"))));
+ PAssert.that(afterDeletes).containsInAnyOrder(expectedRemaining);
+ pipeline.run().waitUntilFinish();
+ }
+
@Test
public void testWriteReadWithFilter() throws IOException {
Table table = catalog.createTable(TableIdentifier.parse(tableId()),
ICEBERG_SCHEMA);
diff --git
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/catalog/RESTCatalogBLMSIT.java
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/catalog/RESTCatalogBLMSIT.java
index 6934bf13f21..aa83d2b7db2 100644
---
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/catalog/RESTCatalogBLMSIT.java
+++
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/catalog/RESTCatalogBLMSIT.java
@@ -20,6 +20,7 @@ package org.apache.beam.sdk.io.iceberg.catalog;
import java.util.Map;
import
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableMap;
import org.apache.iceberg.catalog.Catalog;
+import org.apache.iceberg.catalog.TableIdentifier;
import org.apache.iceberg.rest.RESTCatalog;
import org.junit.After;
import org.junit.BeforeClass;
@@ -29,8 +30,12 @@ public class RESTCatalogBLMSIT extends IcebergCatalogBaseIT {
private static Map<String, String> catalogProps;
// Using a special bucket for this test class because
- // BigLake does not support using subfolders as a warehouse (yet)
- private static final String BIGLAKE_WAREHOUSE =
"gs://managed-iceberg-biglake-its";
+ // BigLake does not support using subfolders as a warehouse (yet).
+ // Overridable for local runs against a different project's catalog, e.g.
+ // -Dbeam.iceberg.biglake.warehouse=gs://my-bucket (bucket-backed catalogs
are named after
+ // their bucket).
+ private static final String BIGLAKE_WAREHOUSE =
+ System.getProperty("beam.iceberg.biglake.warehouse",
"gs://managed-iceberg-biglake-its");
@BeforeClass
public static void setup() {
@@ -57,6 +62,18 @@ public class RESTCatalogBLMSIT extends IcebergCatalogBaseIT {
return "biglake";
}
+ @Override
+ public String bigQueryTableSpec(String tableId) {
+ // BigQuery surfaces Lakehouse runtime catalog (BigLake metastore REST)
tables via 4-part
+ // project.catalog.namespace.table identifiers; the catalog id of a
bucket-backed catalog is
+ // the bucket name. Requires the caller to hold biglake.* read permissions
(e.g.
+ // roles/biglake.viewer) in addition to the usual BigQuery roles.
+ TableIdentifier identifier = TableIdentifier.parse(tableId);
+ String catalogId = BIGLAKE_WAREHOUSE.replace("gs://", "");
+ return String.format(
+ "%s.%s.%s.%s", OPTIONS.getProject(), catalogId,
identifier.namespace(), identifier.name());
+ }
+
@Override
public Catalog createCatalog() {
RESTCatalog restCatalog = new RESTCatalog();