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..b0fa063ac674 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 + && !tableNamePattern.contains("%") + && 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,120 @@ Comparator defineGetTablesComparator(FieldList resultSchemaField Comparator.nullsFirst(String::compareTo)); } + 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 + && !tableNamePattern.contains("%") + && !tableNamePattern.contains(".")) { + return true; + } + return false; + } + + String resolveGraphRegion() throws SQLException { + String location = this.connection != null ? this.connection.getLocation() : null; + if (location == null || location.trim().isEmpty()) { + location = DEFAULT_GRAPH_REGION; + LOG.info(String.format("Deafulting to Graph Location %s", 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; + } + + LOG.info("Appending Graph table rows"); + + 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 +1636,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 +1653,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 +5006,20 @@ private static synchronized String getExportedKeysSqlContent() { return exportedKeysSqlContent; } + static synchronized String getGraphTablesDatasetSqlContent() { + if (graphTablesDatasetSqlContent == null) { + graphTablesDatasetSqlContent = readSqlFromFile(GET_GRAPH_TABLES_DATASET_SQL); + } + return graphTablesDatasetSqlContent; + } + + 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..0f316af47cd7 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.*; @@ -725,6 +726,8 @@ public void testSortResults_Tables() { // Add rows in unsorted order (based on TYPE, CAT, SCHEM, NAME) results.add(createTableRow("cat_a", "sch_z", "table_1", "TABLE", schemaFields)); results.add(createTableRow("cat_b", "sch_a", "view_1", "VIEW", schemaFields)); + results.add( + createTableRow("cat_a", "sch_a", "RetailGraph.Product", "GRAPH TABLE", schemaFields)); results.add(createTableRow("cat_a", "sch_c", "table_2", "TABLE", schemaFields)); results.add(createTableRow(null, "sch_b", "table_0", "TABLE", schemaFields)); results.add(createTableRow("cat_a", "sch_z", "table_0", "TABLE", schemaFields)); @@ -733,44 +736,51 @@ public void testSortResults_Tables() { Comparator comparator = dbMetadata.defineGetTablesComparator(schemaFields); dbMetadata.sortResults(results, comparator, "getTables", dbMetadata.LOG); - // Expected order: TABLEs first, then VIEWs. Within type, sort by CAT, SCHEM, NAME - assertEquals(6, results.size()); + // Expected order: GRAPH TABLE first, then TABLEs, then VIEWs. Within type, sort by CAT, SCHEM, + // NAME + assertEquals(7, results.size()); - // 1. Null cat, sch_b, table_0, TABLE - assertTrue(results.get(0).get("TABLE_CAT").isNull(), "Row 0 TABLE_CAT should be null"); - assertEquals("sch_b", results.get(0).get("TABLE_SCHEM").getStringValue()); - assertEquals("table_0", results.get(0).get("TABLE_NAME").getStringValue()); - assertEquals("TABLE", results.get(0).get("TABLE_TYPE").getStringValue()); + // 1. cat_a, sch_a, RetailGraph.Product, GRAPH TABLE + assertEquals("cat_a", results.get(0).get("TABLE_CAT").getStringValue()); + assertEquals("sch_a", results.get(0).get("TABLE_SCHEM").getStringValue()); + assertEquals("RetailGraph.Product", results.get(0).get("TABLE_NAME").getStringValue()); + assertEquals("GRAPH TABLE", results.get(0).get("TABLE_TYPE").getStringValue()); - // 2. cat_a, sch_c, table_2, TABLE - assertEquals("cat_a", results.get(1).get("TABLE_CAT").getStringValue()); - assertEquals("sch_c", results.get(1).get("TABLE_SCHEM").getStringValue()); - assertEquals("table_2", results.get(1).get("TABLE_NAME").getStringValue()); + // 2. Null cat, sch_b, table_0, TABLE + assertTrue(results.get(1).get("TABLE_CAT").isNull(), "Row 1 TABLE_CAT should be null"); + assertEquals("sch_b", results.get(1).get("TABLE_SCHEM").getStringValue()); + assertEquals("table_0", results.get(1).get("TABLE_NAME").getStringValue()); assertEquals("TABLE", results.get(1).get("TABLE_TYPE").getStringValue()); - // 3. cat_a, sch_z, table_0, TABLE + // 3. cat_a, sch_c, table_2, TABLE assertEquals("cat_a", results.get(2).get("TABLE_CAT").getStringValue()); - assertEquals("sch_z", results.get(2).get("TABLE_SCHEM").getStringValue()); - assertEquals("table_0", results.get(2).get("TABLE_NAME").getStringValue()); + assertEquals("sch_c", results.get(2).get("TABLE_SCHEM").getStringValue()); + assertEquals("table_2", results.get(2).get("TABLE_NAME").getStringValue()); assertEquals("TABLE", results.get(2).get("TABLE_TYPE").getStringValue()); - // 4. cat_a, sch_z, table_1, TABLE + // 4. cat_a, sch_z, table_0, TABLE assertEquals("cat_a", results.get(3).get("TABLE_CAT").getStringValue()); assertEquals("sch_z", results.get(3).get("TABLE_SCHEM").getStringValue()); - assertEquals("table_1", results.get(3).get("TABLE_NAME").getStringValue()); + assertEquals("table_0", results.get(3).get("TABLE_NAME").getStringValue()); assertEquals("TABLE", results.get(3).get("TABLE_TYPE").getStringValue()); - // 5. cat_a, null, view_0, VIEW + // 5. cat_a, sch_z, table_1, TABLE assertEquals("cat_a", results.get(4).get("TABLE_CAT").getStringValue()); - assertTrue(results.get(4).get("TABLE_SCHEM").isNull(), "Row 4 TABLE_SCHEM should be null"); - assertEquals("view_0", results.get(4).get("TABLE_NAME").getStringValue()); - assertEquals("VIEW", results.get(4).get("TABLE_TYPE").getStringValue()); - - // 6. cat_b, sch_a, view_1, VIEW - assertEquals("cat_b", results.get(5).get("TABLE_CAT").getStringValue()); - assertEquals("sch_a", results.get(5).get("TABLE_SCHEM").getStringValue()); - assertEquals("view_1", results.get(5).get("TABLE_NAME").getStringValue()); + assertEquals("sch_z", results.get(4).get("TABLE_SCHEM").getStringValue()); + assertEquals("table_1", results.get(4).get("TABLE_NAME").getStringValue()); + assertEquals("TABLE", results.get(4).get("TABLE_TYPE").getStringValue()); + + // 6. cat_a, null, view_0, VIEW + assertEquals("cat_a", results.get(5).get("TABLE_CAT").getStringValue()); + assertTrue(results.get(5).get("TABLE_SCHEM").isNull(), "Row 5 TABLE_SCHEM should be null"); + assertEquals("view_0", results.get(5).get("TABLE_NAME").getStringValue()); assertEquals("VIEW", results.get(5).get("TABLE_TYPE").getStringValue()); + + // 7. cat_b, sch_a, view_1, VIEW + assertEquals("cat_b", results.get(6).get("TABLE_CAT").getStringValue()); + assertEquals("sch_a", results.get(6).get("TABLE_SCHEM").getStringValue()); + assertEquals("view_1", results.get(6).get("TABLE_NAME").getStringValue()); + assertEquals("VIEW", results.get(6).get("TABLE_TYPE").getStringValue()); } @Test @@ -3804,6 +3814,112 @@ 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(); + } + } + + @Test + public void testShouldSkipGraphTables() { + Set tableOnly = Collections.singleton("TABLE"); + Set graphIncluded = new HashSet<>(Arrays.asList("TABLE", "GRAPH TABLE")); + + // Disabled connection property -> always skip + when(bigQueryConnection.isEnableGraphMetadata()).thenReturn(false); + assertTrue(dbMetadata.shouldSkipGraphTables(null, null)); + assertTrue(dbMetadata.shouldSkipGraphTables("%", graphIncluded)); + assertTrue(dbMetadata.shouldSkipGraphTables("RetailGraph.Product", graphIncluded)); + + // Enabled connection property + when(bigQueryConnection.isEnableGraphMetadata()).thenReturn(true); + // Requested types exclude GRAPH TABLE -> skip + assertTrue(dbMetadata.shouldSkipGraphTables("%", tableOnly)); + // Plain table names without '%' or '.' (including snake_case with '_') -> skip + assertTrue(dbMetadata.shouldSkipGraphTables("my_normal_table", null)); + assertTrue(dbMetadata.shouldSkipGraphTables("plain_table", graphIncluded)); + + // Wildcard '%', null pattern, or dot-qualified graph table names -> do not skip + assertFalse(dbMetadata.shouldSkipGraphTables(null, null)); + assertFalse(dbMetadata.shouldSkipGraphTables("%", null)); + assertFalse(dbMetadata.shouldSkipGraphTables("RetailGraph.Product", null)); + assertFalse(dbMetadata.shouldSkipGraphTables("Graph_.Person", graphIncluded)); + assertFalse(dbMetadata.shouldSkipGraphTables("my_graph.%", graphIncluded)); + } + + @Test + public void testResolveGraphRegion() throws SQLException { + when(bigQueryConnection.getLocation()).thenReturn(null); + assertEquals("region-us", dbMetadata.resolveGraphRegion()); + + when(bigQueryConnection.getLocation()).thenReturn(" "); + assertEquals("region-us", dbMetadata.resolveGraphRegion()); + + when(bigQueryConnection.getLocation()).thenReturn("US"); + assertEquals("region-us", dbMetadata.resolveGraphRegion()); + + when(bigQueryConnection.getLocation()).thenReturn("us-central1"); + assertEquals("region-us-central1", dbMetadata.resolveGraphRegion()); + + when(bigQueryConnection.getLocation()).thenReturn("region-eu"); + assertEquals("region-eu", dbMetadata.resolveGraphRegion()); + } + + @Test + public void testQuoteIdentifier() throws SQLException { + assertEquals("`my-project_1`", BigQueryDatabaseMetaData.quoteIdentifier("my-project_1")); + assertEquals("`my_dataset`", BigQueryDatabaseMetaData.quoteIdentifier("my_dataset")); + assertEquals("`region-us`", BigQueryDatabaseMetaData.quoteIdentifier("region-us")); + + assertThrows(BigQueryJdbcException.class, () -> BigQueryDatabaseMetaData.quoteIdentifier(null)); + assertThrows(BigQueryJdbcException.class, () -> BigQueryDatabaseMetaData.quoteIdentifier("")); + assertThrows( + BigQueryJdbcException.class, () -> BigQueryDatabaseMetaData.quoteIdentifier("bad`id")); + assertThrows( + BigQueryJdbcException.class, () -> BigQueryDatabaseMetaData.quoteIdentifier("drop;table")); + } + + @Test + public void testGraphTablesSqlContent() throws SQLException { + String datasetSql = + dbMetadata.replaceSqlParameters( + BigQueryDatabaseMetaData.getGraphTablesDatasetSqlContent(), + BigQueryDatabaseMetaData.quoteIdentifier("my-project"), + BigQueryDatabaseMetaData.quoteIdentifier("my_dataset")); + assertThat(datasetSql).contains("`my-project`.`my_dataset`.INFORMATION_SCHEMA.PROPERTY_GRAPHS"); + + String regionSql = + dbMetadata.replaceSqlParameters( + BigQueryDatabaseMetaData.getGraphTablesRegionSqlContent(), + BigQueryDatabaseMetaData.quoteIdentifier("my-project"), + BigQueryDatabaseMetaData.quoteIdentifier("region-us")); + assertThat(regionSql).contains("`my-project`.`region-us`.INFORMATION_SCHEMA.PROPERTY_GRAPHS"); + } + @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..94822b4e09bb 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,76 @@ public void testDatabaseMetadataGetTables() throws SQLException { connection.close(); } + @Test + @Tag("disable_tpc") + @Tag("advanced") + 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" + verifyTableTypes(metaData, Arrays.asList("EXTERNAL", "GRAPH TABLE", "TABLE", "VIEW")); + + // 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 {