diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryConnection.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryConnection.java index 7124cc4c4ff7..02f518a16ff4 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryConnection.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryConnection.java @@ -164,7 +164,9 @@ public class BigQueryConnection extends BigQueryNoOpsConnection { BigQueryJdbcUrlUtility.OAUTH_SA_IMPERSONATION_TOKEN_LIFETIME_PROPERTY_NAME, BigQueryJdbcUrlUtility.OAUTH_SA_EMAIL_PROPERTY_NAME, BigQueryJdbcUrlUtility.OAUTH_PVT_KEY_PATH_PROPERTY_NAME, - BigQueryJdbcUrlUtility.OAUTH_CLIENT_ID_PROPERTY_NAME) + BigQueryJdbcUrlUtility.OAUTH_CLIENT_ID_PROPERTY_NAME, + BigQueryJdbcUrlUtility.ENABLE_DIAGNOSTIC_TELEMETRY_PROPERTY_NAME, + BigQueryJdbcUrlUtility.ENABLE_GRAPH_METADATA_PROPERTY_NAME) .build(); private HeaderProvider headerProvider; BigQueryReadClient bigQueryReadClient = null; diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryDatabaseMetaData.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryDatabaseMetaData.java index 3e12ca300294..54d9991997e8 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryDatabaseMetaData.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryDatabaseMetaData.java @@ -70,6 +70,7 @@ import java.util.Comparator; import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Set; import java.util.concurrent.BlockingQueue; import java.util.concurrent.Callable; @@ -103,6 +104,12 @@ class BigQueryDatabaseMetaData implements DatabaseMetaData { private static final int DEFAULT_QUEUE_CAPACITY = 5000; private static final String GET_EXPORTED_KEYS_SQL = "DatabaseMetaData_GetExportedKeys.sql"; private static String exportedKeysSqlContent; + private static final String GET_GRAPH_TABLES_REGION_SQL = + "DatabaseMetaData_GetGraphTables_Region.sql"; + private static String graphTablesRegionSqlContent; + private static final String GET_GRAPH_TABLES_DATASET_SQL = + "DatabaseMetaData_GetGraphTables_Dataset.sql"; + private static String graphTablesDatasetSqlContent; // Declared package-private for testing. static final String GOOGLE_SQL_QUOTED_IDENTIFIER = "`"; // Does not include SQL:2003 Keywords as per JDBC spec. @@ -138,6 +145,15 @@ class BigQueryDatabaseMetaData implements DatabaseMetaData { static final String GOOGLE_SQL_CATALOG_SEPARATOR = "."; static final int GOOGLE_SQL_MAX_COL_NAME_LEN = 300; static final int GOOGLE_SQL_MAX_COLS_PER_TABLE = 10000; + private static final String[] DEFAULT_TABLE_TYPES = { + "EXTERNAL", "MATERIALIZED VIEW", "SNAPSHOT", "TABLE", "VIEW" + }; + private static final String[] TABLE_TYPES_WITH_GRAPH = { + "EXTERNAL", "GRAPH TABLE", "MATERIALIZED VIEW", "SNAPSHOT", "TABLE", "VIEW" + }; + static final String GRAPH_TABLE_TYPE = "GRAPH TABLE"; + private static final String DEFAULT_GRAPH_REGION = "us"; + private static final Pattern VALID_IDENTIFIER_PATTERN = Pattern.compile("^[A-Za-z0-9_\\-:.]+$"); String URL; BigQueryConnection connection; @@ -1308,19 +1324,37 @@ private ResultSet getTablesImpl( () -> { final List collectedResults = Collections.synchronizedList(new ArrayList<>()); - processTargetTablesConcurrently( - targetDatasets, + boolean onlyGraphTablesRequested = + requestedTypes != null + && requestedTypes.size() == 1 + && requestedTypes.contains(GRAPH_TABLE_TYPE); + boolean isExactGraphTableName = + tableNamePattern != null + && !needsListing(tableNamePattern) + && tableNamePattern.contains("."); + if (!isExactGraphTableName && !onlyGraphTablesRequested) { + processTargetTablesConcurrently( + targetDatasets, + tableNamePattern, + true, + false, + false, + collectedResults, + resultSchemaFields, + (bqTable, results, fields) -> { + processTableInfo(bqTable, requestedTypes, results, fields); + }, + TableOption.fields( + TableField.TABLE_REFERENCE, TableField.TYPE, TableField.DESCRIPTION)); + } + + appendGraphTableRows( + effectiveCatalog, + effectiveSchemaPattern, tableNamePattern, - true, - false, - false, + requestedTypes, collectedResults, - resultSchemaFields, - (bqTable, results, fields) -> { - processTableInfo(bqTable, requestedTypes, results, fields); - }, - TableOption.fields( - TableField.TABLE_REFERENCE, TableField.TYPE, TableField.DESCRIPTION)); + resultSchemaFields); Comparator comparator = defineGetTablesComparator(resultSchemaFields); sortResults(collectedResults, comparator, "getTables", LOG); @@ -1441,6 +1475,117 @@ Comparator defineGetTablesComparator(FieldList resultSchemaField Comparator.nullsFirst(String::compareTo)); } + private static String quoteIdentifier(String identifier) throws SQLException { + if (identifier == null || !VALID_IDENTIFIER_PATTERN.matcher(identifier).matches()) { + throw new BigQueryJdbcException( + String.format("Invalid BigQuery identifier: '%s'", identifier)); + } + return GOOGLE_SQL_QUOTED_IDENTIFIER + identifier + GOOGLE_SQL_QUOTED_IDENTIFIER; + } + + boolean shouldSkipGraphTables(String tableNamePattern, Set requestedTypes) { + if (this.connection == null || !this.connection.isEnableGraphMetadata()) { + return true; + } + if (requestedTypes != null && !requestedTypes.contains(GRAPH_TABLE_TYPE)) { + return true; + } + if (tableNamePattern != null + && !needsListing(tableNamePattern) + && !tableNamePattern.contains(".")) { + return true; + } + return false; + } + + private String resolveGraphRegion() throws SQLException { + String location = this.connection != null ? this.connection.getLocation() : null; + if (location == null || location.trim().isEmpty()) { + location = DEFAULT_GRAPH_REGION; + } + String normalized = location.trim().toLowerCase(Locale.US); + return normalized.startsWith("region-") ? normalized : "region-" + normalized; + } + + void appendGraphTableRows( + String effectiveCatalog, + String effectiveSchemaPattern, + String tableNamePattern, + Set requestedTypes, + List collectedResults, + FieldList resultSchemaResults) + throws SQLException { + if (shouldSkipGraphTables(tableNamePattern, requestedTypes)) { + return; + } + + List targetCatalogs = + effectiveCatalog != null + ? Collections.singletonList(effectiveCatalog) + : getAccessibleCatalogNames(); + boolean isExactDataset = + effectiveSchemaPattern != null && !needsListing(effectiveSchemaPattern); + String effectiveTablePattern = (tableNamePattern == null) ? "%" : tableNamePattern; + + for (String project : targetCatalogs) { + String formattedSql; + if (isExactDataset) { + formattedSql = + replaceSqlParameters( + getGraphTablesDatasetSqlContent(), + quoteIdentifier(project), + quoteIdentifier(effectiveSchemaPattern)); + } else { + formattedSql = + replaceSqlParameters( + getGraphTablesRegionSqlContent(), + quoteIdentifier(project), + quoteIdentifier(resolveGraphRegion())); + } + + try (PreparedStatement statement = this.connection.prepareStatement(formattedSql)) { + if (isExactDataset) { + statement.setString(1, effectiveTablePattern); + } else { + statement.setString(1, effectiveSchemaPattern == null ? "%" : effectiveSchemaPattern); + statement.setString(2, effectiveTablePattern); + } + + try (ResultSet rs = statement.executeQuery()) { + while (rs.next()) { + String tableCat = rs.getString("TABLE_CAT"); + String tableSchem = rs.getString("TABLE_SCHEM"); + String tableName = rs.getString("TABLE_NAME"); + String sourceTable = rs.getString("SOURCE_TABLE"); + String remarks = sourceTable != null ? "Source: " + sourceTable : null; + + List values = new ArrayList<>(resultSchemaResults.size()); + values.add(createStringFieldValue(tableCat)); + values.add(createStringFieldValue(tableSchem)); + values.add(createStringFieldValue(tableName)); + values.add(createStringFieldValue(GRAPH_TABLE_TYPE)); + values.add(createStringFieldValue(remarks)); + values.add(createNullFieldValue()); + values.add(createNullFieldValue()); + values.add(createNullFieldValue()); + values.add(createNullFieldValue()); + values.add(createNullFieldValue()); + collectedResults.add(FieldValueList.of(values, resultSchemaResults)); + } + } + } catch (SQLException e) { + if (e.getMessage() != null && e.getMessage().contains("Not found")) { + LOG.info( + "Dataset or project not found while querying PROPERTY_GRAPHS in project '%s'." + + " Skipping.", + project); + } else { + throw e; + } + } + } + } + @Override public ResultSet getSchemas() throws SQLException { LOG.info("getSchemas() called"); @@ -1488,7 +1633,8 @@ public ResultSet getTableTypes() throws SQLException { LOG.info("getTableTypes() called"); final Schema tableTypesSchema = defineGetTableTypesSchema(); - final List tableTypeRows = prepareGetTableTypesRows(tableTypesSchema); + final List tableTypeRows = + prepareGetTableTypesRows(tableTypesSchema, this.connection.isEnableGraphMetadata()); BlockingQueue queue = fetchAndPopulateQueue(() -> tableTypeRows, tableTypesSchema.getFields()); @@ -1504,9 +1650,13 @@ static Schema defineGetTableTypesSchema() { } static List prepareGetTableTypesRows(Schema schema) { - final String[] tableTypes = {"EXTERNAL", "MATERIALIZED VIEW", "SNAPSHOT", "TABLE", "VIEW"}; - List rows = new ArrayList<>(tableTypes.length); + return prepareGetTableTypesRows(schema, false); + } + + static List prepareGetTableTypesRows(Schema schema, boolean includeGraphTables) { + final String[] tableTypes = includeGraphTables ? TABLE_TYPES_WITH_GRAPH : DEFAULT_TABLE_TYPES; FieldList schemaFields = schema.getFields(); + List rows = new ArrayList<>(tableTypes.length); for (String typeName : tableTypes) { FieldValue fieldValue = FieldValue.of(FieldValue.Attribute.PRIMITIVE, typeName); @@ -4853,6 +5003,20 @@ private static synchronized String getExportedKeysSqlContent() { return exportedKeysSqlContent; } + private static synchronized String getGraphTablesDatasetSqlContent() { + if (graphTablesDatasetSqlContent == null) { + graphTablesDatasetSqlContent = readSqlFromFile(GET_GRAPH_TABLES_DATASET_SQL); + } + return graphTablesDatasetSqlContent; + } + + private static synchronized String getGraphTablesRegionSqlContent() { + if (graphTablesRegionSqlContent == null) { + graphTablesRegionSqlContent = readSqlFromFile(GET_GRAPH_TABLES_REGION_SQL); + } + return graphTablesRegionSqlContent; + } + static String readSqlFromFile(String filename) { try (InputStream in = BigQueryDatabaseMetaData.class.getResourceAsStream(filename)) { if (in == null) { diff --git a/java-bigquery-jdbc/src/main/resources/com/google/cloud/bigquery/jdbc/DatabaseMetaData_GetGraphTables_Dataset.sql b/java-bigquery-jdbc/src/main/resources/com/google/cloud/bigquery/jdbc/DatabaseMetaData_GetGraphTables_Dataset.sql new file mode 100644 index 000000000000..499a5b9fd608 --- /dev/null +++ b/java-bigquery-jdbc/src/main/resources/com/google/cloud/bigquery/jdbc/DatabaseMetaData_GetGraphTables_Dataset.sql @@ -0,0 +1,29 @@ +/* + * 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 + * + * https://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. + */ + +SELECT + property_graph_catalog AS TABLE_CAT, + property_graph_schema AS TABLE_SCHEM, + CONCAT(property_graph_name, '.', JSON_VALUE(t, '$.name')) AS TABLE_NAME, + CONCAT( + JSON_VALUE(t, '$.dataSourceTable.projectId'), '.', + JSON_VALUE(t, '$.dataSourceTable.datasetId'), '.', + JSON_VALUE(t, '$.dataSourceTable.tableId')) AS SOURCE_TABLE +FROM %1$s.%2$s.INFORMATION_SCHEMA.PROPERTY_GRAPHS, +UNNEST(ARRAY_CONCAT( + IFNULL(JSON_QUERY_ARRAY(property_graph_metadata_json, '$.nodeTables'), []), + IFNULL(JSON_QUERY_ARRAY(property_graph_metadata_json, '$.edgeTables'), []))) AS t +WHERE LOWER(CONCAT(property_graph_name, '.', JSON_VALUE(t, '$.name'))) LIKE LOWER(?) \ No newline at end of file diff --git a/java-bigquery-jdbc/src/main/resources/com/google/cloud/bigquery/jdbc/DatabaseMetaData_GetGraphTables_Region.sql b/java-bigquery-jdbc/src/main/resources/com/google/cloud/bigquery/jdbc/DatabaseMetaData_GetGraphTables_Region.sql new file mode 100644 index 000000000000..a6ec6e197846 --- /dev/null +++ b/java-bigquery-jdbc/src/main/resources/com/google/cloud/bigquery/jdbc/DatabaseMetaData_GetGraphTables_Region.sql @@ -0,0 +1,30 @@ +/* + * 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 + * + * https://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. + */ + +SELECT + property_graph_catalog AS TABLE_CAT, + property_graph_schema AS TABLE_SCHEM, + CONCAT(property_graph_name, '.', JSON_VALUE(t, '$.name')) AS TABLE_NAME, + CONCAT( + JSON_VALUE(t, '$.dataSourceTable.projectId'), '.', + JSON_VALUE(t, '$.dataSourceTable.datasetId'), '.', + JSON_VALUE(t, '$.dataSourceTable.tableId')) AS SOURCE_TABLE +FROM %1$s.%2$s.INFORMATION_SCHEMA.PROPERTY_GRAPHS, +UNNEST(ARRAY_CONCAT( + IFNULL(JSON_QUERY_ARRAY(property_graph_metadata_json, '$.nodeTables'), []), + IFNULL(JSON_QUERY_ARRAY(property_graph_metadata_json, '$.edgeTables'), []))) AS t +WHERE LOWER(property_graph_schema) LIKE LOWER(?) + AND LOWER(CONCAT(property_graph_name, '.', JSON_VALUE(t, '$.name'))) LIKE LOWER(?) \ No newline at end of file diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryDatabaseMetaDataTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryDatabaseMetaDataTest.java index 70dff53103df..9c9e5f10b77e 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryDatabaseMetaDataTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryDatabaseMetaDataTest.java @@ -26,6 +26,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; @@ -3804,6 +3805,36 @@ public void testGetAccessibleCatalogNames_includesDefaultDatasetProject() throws Arrays.asList("extra-proj1", "extra-proj2", "pcnt-warehouse", "primary-project"), catalogs); } + @Test + public void testPrepareGetTableTypesRows_withGraphTablesEnabled() { + Schema schema = BigQueryDatabaseMetaData.defineGetTableTypesSchema(); + List rows = BigQueryDatabaseMetaData.prepareGetTableTypesRows(schema, true); + + List actualTypes = new ArrayList<>(); + for (FieldValueList row : rows) { + actualTypes.add(row.get(0).getStringValue()); + } + assertThat(actualTypes) + .containsExactly( + "EXTERNAL", "GRAPH TABLE", "MATERIALIZED VIEW", "SNAPSHOT", "TABLE", "VIEW") + .inOrder(); + } + + @Test + public void testGetTableTypes_withGraphTablesEnabled() throws SQLException { + when(bigQueryConnection.isEnableGraphMetadata()).thenReturn(true); + try (ResultSet rs = dbMetadata.getTableTypes()) { + List actualTypes = new ArrayList<>(); + while (rs.next()) { + actualTypes.add(rs.getString("TABLE_TYPE")); + } + assertThat(actualTypes) + .containsExactly( + "EXTERNAL", "GRAPH TABLE", "MATERIALIZED VIEW", "SNAPSHOT", "TABLE", "VIEW") + .inOrder(); + } + } + @FunctionalInterface private interface MetadataResultSetSupplier { ResultSet get(BigQueryDatabaseMetaData metadata) throws SQLException; diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITDatabaseMetadataTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITDatabaseMetadataTest.java index 1671bc523ddf..98f675e16e05 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITDatabaseMetadataTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITDatabaseMetadataTest.java @@ -821,6 +821,81 @@ public void testDatabaseMetadataGetTables() throws SQLException { connection.close(); } + @Test + @Tag("disable_tpc") + public void testDatabaseMetadataGetTables_PropertyGraph() throws SQLException { + String nodeTable = "GRAPH_NODE_TABLE_" + randomNumber; + String edgeTable = "GRAPH_EDGE_TABLE_" + randomNumber; + String graphName = "TEST_GRAPH_GET_TABLES" + randomNumber; + String graphUrl = ITBase.connectionUrl + ";EnableGraphMetadata=1"; + + try (Connection conn = DriverManager.getConnection(graphUrl); + Statement stmt = conn.createStatement()) { + stmt.execute( + String.format( + "CREATE OR REPLACE TABLE `%s.%s.%s` (id INT64 NOT NULL, name STRING)", + PROJECT_ID, DATASET, nodeTable)); + stmt.execute( + String.format( + "CREATE OR REPLACE TABLE `%s.%s.%s` (id INT64 NOT NULL, dst_id INT64 NOT NULL)", + PROJECT_ID, DATASET, edgeTable)); + stmt.execute( + String.format( + "CREATE OR REPLACE PROPERTY GRAPH `%s.%s.%s` " + + "NODE TABLES (`%s.%s.%s` AS Person KEY (id)) " + + "EDGE TABLES (`%s.%s.%s` AS Knows KEY (id, dst_id) " + + "SOURCE KEY (id) REFERENCES Person (id) " + + "DESTINATION KEY (dst_id) REFERENCES Person (id))", + PROJECT_ID, + DATASET, + graphName, + PROJECT_ID, + DATASET, + nodeTable, + PROJECT_ID, + DATASET, + edgeTable)); + + DatabaseMetaData metaData = conn.getMetaData(); + + // 1. Verify getTableTypes() includes "GRAPH TABLE" + Set tableTypes = new HashSet<>(); + try (ResultSet rsTypes = metaData.getTableTypes()) { + while (rsTypes.next()) { + tableTypes.add(rsTypes.getString("TABLE_TYPE")); + } + } + Assertions.assertTrue(tableTypes.contains("GRAPH TABLE")); + + // 2. Verify getTables() with wildcard pattern returns both node and edge elements + Set graphTables = new HashSet<>(); + try (ResultSet rs = + metaData.getTables(PROJECT_ID, DATASET, graphName + ".%", new String[] {"GRAPH TABLE"})) { + while (rs.next()) { + Assertions.assertEquals(PROJECT_ID, rs.getString("TABLE_CAT")); + Assertions.assertEquals(DATASET, rs.getString("TABLE_SCHEM")); + Assertions.assertEquals("GRAPH TABLE", rs.getString("TABLE_TYPE")); + Assertions.assertTrue(rs.getString("REMARKS").startsWith("Source: ")); + graphTables.add(rs.getString("TABLE_NAME")); + } + } + Assertions.assertTrue(graphTables.contains(graphName + ".Person")); + Assertions.assertTrue(graphTables.contains(graphName + ".Knows")); + Assertions.assertEquals(2, graphTables.size()); + } finally { + try (Connection cleanupConn = DriverManager.getConnection(ITBase.connectionUrl); + Statement cleanupStmt = cleanupConn.createStatement()) { + cleanupStmt.execute( + String.format( + "DROP PROPERTY GRAPH IF EXISTS `%s.%s.%s`", PROJECT_ID, DATASET, graphName)); + cleanupStmt.execute( + String.format("DROP TABLE IF EXISTS `%s.%s.%s`", PROJECT_ID, DATASET, edgeTable)); + cleanupStmt.execute( + String.format("DROP TABLE IF EXISTS `%s.%s.%s`", PROJECT_ID, DATASET, nodeTable)); + } + } + } + @Test @Tag("disable_tpc") public void testDatabaseMetadataGetSchemas() throws SQLException {