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..075fe4f2a373 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,8 @@ 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_SQL = "DatabaseMetaData_GetGraphTables.sql"; + private static String graphTablesSqlContent; // Declared package-private for testing. static final String GOOGLE_SQL_QUOTED_IDENTIFIER = "`"; // Does not include SQL:2003 Keywords as per JDBC spec. @@ -138,6 +141,24 @@ 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"; + // Google Cloud project IDs: letters, digits, hyphens, and optional domain prefix (e.g. + // domain.com:proj-1) + private static final Pattern VALID_CATALOG_PATTERN = + Pattern.compile("^[A-Za-z0-9][A-Za-z0-9:._-]*$"); + // BigQuery dataset IDs: letters, digits, and underscores only + private static final Pattern VALID_SCHEMA_PATTERN = Pattern.compile("^[A-Za-z0-9_]+$"); + // BigQuery region qualifier for regional INFORMATION_SCHEMA queries (e.g. region-us, + // region-us-central1) + private static final Pattern VALID_REGION_SCOPE_PATTERN = + Pattern.compile("^region-[A-Za-z0-9-]+$"); String URL; BigQueryConnection connection; @@ -1308,19 +1329,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 +1480,123 @@ Comparator defineGetTablesComparator(FieldList resultSchemaField Comparator.nullsFirst(String::compareTo)); } + static void validateCatalogIdentifier(String catalog) throws SQLException { + if (catalog == null || !VALID_CATALOG_PATTERN.matcher(catalog).matches()) { + throw new BigQueryJdbcException( + String.format("Invalid catalog (project ID) identifier: '%s'", catalog)); + } + } + + static void validateSchemaIdentifier(String schema) throws SQLException { + if (schema == null || !VALID_SCHEMA_PATTERN.matcher(schema).matches()) { + throw new BigQueryJdbcException( + String.format("Invalid schema (dataset ID) identifier: '%s'", schema)); + } + } + + static void validateRegionScopeIdentifier(String regionScope) throws SQLException { + if (regionScope == null || !VALID_REGION_SCOPE_PATTERN.matcher(regionScope).matches()) { + throw new BigQueryJdbcException( + String.format("Invalid region scope identifier: '%s'", regionScope)); + } + } + + 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("%") + // If table name has a dot it is expected to be a graph table name. + && !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 && !effectiveSchemaPattern.contains("%"); + String effectiveTablePattern = (tableNamePattern == null) ? "%" : tableNamePattern; + String scope = isExactDataset ? effectiveSchemaPattern : resolveGraphRegion(); + if (isExactDataset) { + validateSchemaIdentifier(scope); + } else { + validateRegionScopeIdentifier(scope); + } + for (String project : targetCatalogs) { + validateCatalogIdentifier(project); + String formattedSql = replaceSqlParameters(getGraphTablesSqlContent(), project, scope); + + try (PreparedStatement statement = this.connection.prepareStatement(formattedSql)) { + 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)); // 1. TABLE_CAT + values.add(createStringFieldValue(tableSchem)); // 2. TABLE_SCHEM + values.add(createStringFieldValue(tableName)); // 3. TABLE_NAME + values.add(createStringFieldValue(GRAPH_TABLE_TYPE)); // 4. TABLE_TYPE + values.add(createStringFieldValue(remarks)); // 5. REMARKS + values.add(createNullFieldValue()); // 6. TYPE_CAT (always null) + values.add(createNullFieldValue()); // 7. TYPE_SCHEM (always null) + values.add(createNullFieldValue()); // 8. TYPE_NAME (always null) + values.add(createNullFieldValue()); // 9. SELF_REFERENCING_COL_NAME (always null) + values.add(createNullFieldValue()); // 10. REF_GENERATION (always null) + 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 +1644,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 +1661,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); @@ -2195,6 +2356,8 @@ public ResultSet getExportedKeys(String catalog, String schema, String table) return BigQueryJsonResultSet.of(resultSchema, -1, queue, null); } + validateCatalogIdentifier(catalog); + validateSchemaIdentifier(schema); String sql = getExportedKeysSqlContent(); String formattedSql = replaceSqlParameters(sql, catalog, schema, table); PreparedStatement stmt = this.connection.prepareStatement(formattedSql); @@ -4853,6 +5016,13 @@ private static synchronized String getExportedKeysSqlContent() { return exportedKeysSqlContent; } + static synchronized String getGraphTablesSqlContent() { + if (graphTablesSqlContent == null) { + graphTablesSqlContent = readSqlFromFile(GET_GRAPH_TABLES_SQL); + } + return graphTablesSqlContent; + } + 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.sql b/java-bigquery-jdbc/src/main/resources/com/google/cloud/bigquery/jdbc/DatabaseMetaData_GetGraphTables.sql new file mode 100644 index 000000000000..3bfdd4ea528c --- /dev/null +++ b/java-bigquery-jdbc/src/main/resources/com/google/cloud/bigquery/jdbc/DatabaseMetaData_GetGraphTables.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..d24a763ab03b 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.*; @@ -729,48 +730,57 @@ public void testSortResults_Tables() { results.add(createTableRow(null, "sch_b", "table_0", "TABLE", schemaFields)); results.add(createTableRow("cat_a", "sch_z", "table_0", "TABLE", schemaFields)); results.add(createTableRow("cat_a", null, "view_0", "VIEW", schemaFields)); + results.add( + createTableRow("cat_a", "sch_a", "RetailGraph.Product", "GRAPH TABLE", schemaFields)); 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,94 @@ 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 testGraphTablesSqlContent() throws SQLException { + String datasetSql = + dbMetadata.replaceSqlParameters( + BigQueryDatabaseMetaData.getGraphTablesSqlContent(), "my-project", "my_dataset"); + assertThat(datasetSql).contains("`my-project.my_dataset.INFORMATION_SCHEMA.PROPERTY_GRAPHS`"); + + String regionSql = + dbMetadata.replaceSqlParameters( + BigQueryDatabaseMetaData.getGraphTablesSqlContent(), "my-project", "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..c52fa73dfcb2 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,65 @@ 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()); + } + } + @Test @Tag("disable_tpc") public void testDatabaseMetadataGetSchemas() throws SQLException {