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 87b563ed54cd..2731733d7c0f 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 @@ -106,6 +106,10 @@ class BigQueryDatabaseMetaData implements DatabaseMetaData { private static String exportedKeysSqlContent; private static final String GET_GRAPH_TABLES_SQL = "DatabaseMetaData_GetGraphTables.sql"; private static String graphTablesSqlContent; + private static final String GET_GRAPH_COLUMNS_SQL = "DatabaseMetaData_GetGraphColumns.sql"; + private static String graphColumnsSqlContent; + static final String MEASURE_EXPRESSION_KIND = "MEASURE"; + static final String MEASURE_REMARKS_PREFIX = "MEASURE: "; // Declared package-private for testing. static final String GOOGLE_SQL_QUOTED_IDENTIFIER = "`"; // Does not include SQL:2003 Keywords as per JDBC spec. @@ -1223,25 +1227,16 @@ ColumnTypeInfo determineTypeInfoFromDataType( String procedureName, String columnName, int ordinalPosition) { - - ColumnTypeInfo defaultStringTypeInfo = - new ColumnTypeInfo(Types.NVARCHAR, "STRING", null, null, null); try { - String typeKind = argumentDataType.getTypeKind(); - if (typeKind != null && !typeKind.isEmpty()) { - if ("ARRAY".equalsIgnoreCase(typeKind)) { - return new ColumnTypeInfo(Types.ARRAY, "ARRAY", null, null, null); - } - StandardSQLTypeName determinedType = StandardSQLTypeName.valueOf(typeKind.toUpperCase()); - return getColumnTypeInfoForSqlType(determinedType); - } + return getColumnTypeInfoForTypeKind( + argumentDataType != null ? argumentDataType.getTypeKind() : null); } catch (Exception e) { LOG.warning( "Proc: %s, Arg: %s (Pos %d) - Caught an unexpected Exception during type" + " determination. Defaulting type to STRING. Error: %s", procedureName, columnName, ordinalPosition, e.getMessage()); + return getColumnTypeInfoForSqlType(null); } - return defaultStringTypeInfo; } Comparator defineGetProcedureColumnsComparator(FieldList resultSchemaFields) { @@ -1471,10 +1466,14 @@ Comparator defineGetTablesComparator(FieldList resultSchemaField } boolean shouldSkipGraphTables(String tableNamePattern, Set requestedTypes) { - if (this.connection == null || !this.connection.isEnableGraphMetadata()) { + if (requestedTypes != null && !requestedTypes.contains(GRAPH_TABLE_TYPE)) { return true; } - if (requestedTypes != null && !requestedTypes.contains(GRAPH_TABLE_TYPE)) { + return shouldSkipGraphColumns(tableNamePattern); + } + + boolean shouldSkipGraphColumns(String tableNamePattern) { + if (this.connection == null || !this.connection.isEnableGraphMetadata()) { return true; } if (tableNamePattern != null @@ -1684,19 +1683,32 @@ private ResultSet getColumnsImpl( () -> { final List collectedResults = Collections.synchronizedList(new ArrayList<>()); - processTargetTablesConcurrently( - targetDatasets, + boolean isExactGraphTableName = + tableNamePattern != null + && !tableNamePattern.contains("%") + && tableNamePattern.contains("."); + if (!isExactGraphTableName) { + processTargetTablesConcurrently( + targetDatasets, + tableNamePattern, + true, + false, + true, + collectedResults, + resultSchemaFields, + (bqTable, results, fields) -> { + processTableColumns(bqTable, columnNameRegex, results, fields); + }, + TableOption.fields( + TableField.TABLE_REFERENCE, TableField.TYPE, TableField.SCHEMA)); + } + appendGraphColumnRows( + effectiveCatalog, + effectiveSchemaPattern, tableNamePattern, - true, - false, - true, + columnNamePattern, collectedResults, - resultSchemaFields, - (bqTable, results, fields) -> { - processTableColumns(bqTable, columnNameRegex, results, fields); - }, - TableOption.fields( - TableField.TABLE_REFERENCE, TableField.TYPE, TableField.SCHEMA)); + resultSchemaFields); Comparator comparator = defineGetColumnsComparator(resultSchemaFields); @@ -1775,7 +1787,133 @@ private void processTableColumns( } } - private Schema defineGetColumnsSchema() { + void appendGraphColumnRows( + String effectiveCatalog, + String effectiveSchemaPattern, + String tableNamePattern, + String columnNamePattern, + List collectedResults, + FieldList resultSchemaFields) + throws SQLException { + if (shouldSkipGraphColumns(tableNamePattern)) { + return; + } + + LOG.info("Appending Graph column rows"); + + List targetCatalogs = + effectiveCatalog != null + ? Collections.singletonList(effectiveCatalog) + : getAccessibleCatalogNames(); + boolean isExactDataset = + effectiveSchemaPattern != null && !effectiveSchemaPattern.contains("%"); + String effectiveSchemaLike = (effectiveSchemaPattern == null) ? "%" : effectiveSchemaPattern; + String effectiveTableLike = (tableNamePattern == null) ? "%" : tableNamePattern; + String effectiveColumnLike = (columnNamePattern == null) ? "%" : columnNamePattern; + for (String project : targetCatalogs) { + String scope = isExactDataset ? effectiveSchemaPattern : resolveGraphRegion(); + String formattedSql = replaceSqlParameters(getGraphColumnsSqlContent(), project, scope); + try (PreparedStatement statement = this.connection.prepareStatement(formattedSql)) { + statement.setString(1, effectiveSchemaLike); + statement.setString(2, effectiveTableLike); + statement.setString(3, effectiveColumnLike); + 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 columnName = rs.getString("COLUMN_NAME"); + String typeKind = + rs.getString("TYPE_KIND"); // DATA_TYPE, TYPE_NAME, COLUMN_SIZE, DECIMAL_DIGITS, + // NUM_PREC_RADIX + String expression = + rs.getString("EXPRESSION"); // REMARKS (for measures) & IS_GENERATEDCOLUMN + String expressionKind = + rs.getString( + "EXPRESSION_KIND"); // REMARKS ("MEASURE: " prefix) & IS_GENERATEDCOLUMN + String description = rs.getString("DESCRIPTION"); // REMARKS (property description) + int ordinalPosition = rs.getInt("ORDINAL_POSITION"); + List values = + createGraphColumnRow( + tableCat, + tableSchem, + tableName, + columnName, + typeKind, + expression, + expressionKind, + description, + ordinalPosition); + collectedResults.add(FieldValueList.of(values, resultSchemaFields)); + } + } + } catch (SQLException e) { + if (e.getMessage() != null && e.getMessage().contains("Not found")) { + LOG.info( + "Dataset or project not found while querying PROPERTY_GRAPHS for columns in project" + + " '%s'. Skipping.", + project); + } else { + throw e; + } + } + } + } + + List createGraphColumnRow( + String catalog, + String schemaName, + String tableName, + String columnName, + String typeKind, + @Nullable String expression, + @Nullable String expressionKind, + @Nullable String description, + int ordinalPosition) { + ColumnTypeInfo typeInfo = getColumnTypeInfoForTypeKind(typeKind); + + boolean isMeasure = MEASURE_EXPRESSION_KIND.equals(expressionKind); + boolean isComputedExpression = + expression != null && !expression.isEmpty() && !expression.equalsIgnoreCase(columnName); + String isGeneratedColumn = (isMeasure || isComputedExpression) ? "YES" : "NO"; + + String remarks; + if (isMeasure) { + String measurePrefix = MEASURE_REMARKS_PREFIX + (expression != null ? expression : ""); + remarks = + (description == null || description.isEmpty()) + ? measurePrefix + : measurePrefix + " - " + description; + } else { + remarks = description; + } + + return buildColumnRowValues( + catalog, + schemaName, + tableName, + columnName, + typeInfo, + DatabaseMetaData.columnNullableUnknown, + remarks, + ordinalPosition, + "", + isGeneratedColumn); + } + + ColumnTypeInfo getColumnTypeInfoForTypeKind(@Nullable String typeKind) { + if (typeKind == null || typeKind.isEmpty()) { + return getColumnTypeInfoForSqlType(null); + } + try { + return getColumnTypeInfoForSqlType(StandardSQLTypeName.valueOf(typeKind)); + } catch (IllegalArgumentException e) { + LOG.warning("Unknown BigQuery typeKind '%s' in graph metadata. Mapping to STRING.", typeKind); + return getColumnTypeInfoForSqlType(null); + } + } + + Schema defineGetColumnsSchema() { List fields = new ArrayList<>(24); fields.add( Field.newBuilder("TABLE_CAT", StandardSQLTypeName.STRING) @@ -1878,14 +2016,51 @@ private Schema defineGetColumnsSchema() { List createColumnRow( String catalog, String schemaName, String tableName, Field field, int ordinalPosition) { - List values = new ArrayList<>(24); Field.Mode mode = (field.getMode() == null) ? Field.Mode.NULLABLE : field.getMode(); ColumnTypeInfo typeInfo = mapBigQueryTypeToJdbc(field); - + int nullable = + (mode == Field.Mode.REQUIRED) + ? DatabaseMetaData.columnNoNulls + : DatabaseMetaData.columnNullable; + String isNullable = ""; + switch (mode) { + case REQUIRED: + isNullable = "NO"; + break; + case NULLABLE: + case REPEATED: + isNullable = "YES"; + break; + } + return buildColumnRowValues( + catalog, + schemaName, + tableName, + field.getName(), + typeInfo, + nullable, + field.getDescription(), + ordinalPosition, + isNullable, + "NO"); + } + + private List buildColumnRowValues( + String catalog, + String schemaName, + String tableName, + String columnName, + ColumnTypeInfo typeInfo, + int nullable, + @Nullable String remarks, + int ordinalPosition, + String isNullable, + String isGeneratedColumn) { + List values = new ArrayList<>(24); values.add(createStringFieldValue(catalog)); // 1. TABLE_CAT values.add(createStringFieldValue(schemaName)); // 2. TABLE_SCHEM values.add(createStringFieldValue(tableName)); // 3. TABLE_NAME - values.add(createStringFieldValue(field.getName())); // 4. COLUMN_NAME + values.add(createStringFieldValue(columnName)); // 4. COLUMN_NAME values.add(createLongFieldValue((long) typeInfo.jdbcType)); // 5. DATA_TYPE values.add(createStringFieldValue(typeInfo.typeName)); // 6. TYPE_NAME values.add( @@ -1904,35 +2079,20 @@ List createColumnRow( typeInfo.numPrecRadix == null ? null : typeInfo.numPrecRadix.longValue())); // 10. NUM_PREC_RADIX - int nullable = - (mode == Field.Mode.REQUIRED) - ? DatabaseMetaData.columnNoNulls - : DatabaseMetaData.columnNullable; values.add(createLongFieldValue((long) nullable)); // 11. NULLABLE - values.add(createStringFieldValue(field.getDescription())); // 12. REMARKS + values.add(createStringFieldValue(remarks)); // 12. REMARKS values.add(createNullFieldValue()); // 13. COLUMN_DEF values.add(createNullFieldValue()); // 14. SQL_DATA_TYPE values.add(createNullFieldValue()); // 15. SQL_DATETIME_SUB values.add(createNullFieldValue()); // 16. CHAR_OCTET_LENGTH values.add(createLongFieldValue((long) ordinalPosition)); // 17. ORDINAL_POSITION - String isNullable = ""; - switch (mode) { - case REQUIRED: - isNullable = "NO"; - break; - case NULLABLE: - case REPEATED: - isNullable = "YES"; - break; - } values.add(createStringFieldValue(isNullable)); // 18. IS_NULLABLE values.add(createNullFieldValue()); // 19. SCOPE_CATALOG values.add(createNullFieldValue()); // 20. SCOPE_SCHEMA values.add(createNullFieldValue()); // 21. SCOPE_TABLE values.add(createNullFieldValue()); // 22. SOURCE_DATA_TYPE values.add(createStringFieldValue("NO")); // 23. IS_AUTOINCREMENT - values.add(createStringFieldValue("NO")); // 24. IS_GENERATEDCOLUMN - + values.add(createStringFieldValue(isGeneratedColumn)); // 24. IS_GENERATEDCOLUMN return values; } @@ -4989,6 +5149,13 @@ static synchronized String getGraphTablesSqlContent() { return graphTablesSqlContent; } + private static synchronized String getGraphColumnsSqlContent() { + if (graphColumnsSqlContent == null) { + graphColumnsSqlContent = readSqlFromFile(GET_GRAPH_COLUMNS_SQL); + } + return graphColumnsSqlContent; + } + 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_GetGraphColumns.sql b/java-bigquery-jdbc/src/main/resources/com/google/cloud/bigquery/jdbc/DatabaseMetaData_GetGraphColumns.sql new file mode 100644 index 000000000000..168238f6c717 --- /dev/null +++ b/java-bigquery-jdbc/src/main/resources/com/google/cloud/bigquery/jdbc/DatabaseMetaData_GetGraphColumns.sql @@ -0,0 +1,51 @@ +/* + * 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. + */ + +WITH props AS ( + SELECT + property_graph_catalog AS TABLE_CAT, + property_graph_schema AS TABLE_SCHEM, + CONCAT(property_graph_name, '.', JSON_VALUE(t, '$.name')) AS TABLE_NAME, + JSON_VALUE(p, '$.name') AS COLUMN_NAME, + JSON_VALUE(p, '$.dataType.typeKind') AS TYPE_KIND, + TO_JSON_STRING(JSON_QUERY(p, '$.dataType')) AS DATA_TYPE_JSON, + JSON_VALUE(p, '$.expression') AS EXPRESSION, + JSON_VALUE(p, '$.expressionKind') AS EXPRESSION_KIND, + JSON_VALUE(p, '$.info.description') AS DESCRIPTION, + (lp_off * 100000) + p_off AS first_seen + FROM `%1$s.%2$s.INFORMATION_SCHEMA.PROPERTY_GRAPHS`, + UNNEST([STRUCT(JSON_QUERY_ARRAY(property_graph_metadata_json, '$.nodeTables') AS arr), + STRUCT(JSON_QUERY_ARRAY(property_graph_metadata_json, '$.edgeTables') AS arr)]) k, + UNNEST(k.arr) t, + UNNEST(JSON_QUERY_ARRAY(t, '$.labelAndProperties')) lp WITH OFFSET lp_off, + UNNEST(JSON_QUERY_ARRAY(lp, '$.properties')) p WITH OFFSET p_off + WHERE LOWER(property_graph_schema) LIKE LOWER(?) + AND LOWER(CONCAT(property_graph_name, '.', JSON_VALUE(t, '$.name'))) LIKE LOWER(?) + AND LOWER(JSON_VALUE(p, '$.name')) LIKE LOWER(?) +) +SELECT + TABLE_CAT, + TABLE_SCHEM, + TABLE_NAME, + COLUMN_NAME, + ANY_VALUE(TYPE_KIND HAVING MIN first_seen) AS TYPE_KIND, + ANY_VALUE(DATA_TYPE_JSON HAVING MIN first_seen) AS DATA_TYPE_JSON, + ANY_VALUE(EXPRESSION HAVING MIN first_seen) AS EXPRESSION, + ANY_VALUE(EXPRESSION_KIND HAVING MIN first_seen) AS EXPRESSION_KIND, + ANY_VALUE(DESCRIPTION HAVING MIN first_seen) AS DESCRIPTION, + ROW_NUMBER() OVER (PARTITION BY TABLE_CAT, TABLE_SCHEM, TABLE_NAME ORDER BY MIN(first_seen)) AS ORDINAL_POSITION +FROM props +GROUP BY TABLE_CAT, TABLE_SCHEM, TABLE_NAME, COLUMN_NAME \ 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 d24a763ab03b..03fc2ddd3015 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 @@ -3352,11 +3352,14 @@ public void testMetadataAndResultSetMetadataTypeMappingConsistency(StandardSQLTy } ColumnTypeInfo metadataTypeInfo = dbMetadata.mapBigQueryTypeToJdbc(field); + ColumnTypeInfo graphTypeInfo = dbMetadata.getColumnTypeInfoForTypeKind(type.name()); Integer resultSetType = BigQueryTypeRegistry.toJdbcType(type); assertNotNull(resultSetType, "ResultSet mapping should exist for " + type); assertEquals( metadataTypeInfo.jdbcType, (int) resultSetType, "Type mapping mismatch for " + type); + assertEquals(metadataTypeInfo.jdbcType, graphTypeInfo.jdbcType); + assertEquals(metadataTypeInfo.typeName, graphTypeInfo.typeName); } @Test @@ -3844,6 +3847,125 @@ public void testGetTableTypes_withGraphTablesEnabled() throws SQLException { } } + @Test + public void testShouldSkipGraphColumns() { + when(bigQueryConnection.isEnableGraphMetadata()).thenReturn(false); + assertTrue(dbMetadata.shouldSkipGraphColumns("%")); + assertTrue(dbMetadata.shouldSkipGraphColumns("RetailGraph.Product")); + + when(bigQueryConnection.isEnableGraphMetadata()).thenReturn(true); + assertTrue(dbMetadata.shouldSkipGraphColumns("plainTableWithoutDot")); + assertFalse(dbMetadata.shouldSkipGraphColumns("RetailGraph.Product")); + assertFalse(dbMetadata.shouldSkipGraphColumns("Retail_Graph.Product")); + assertFalse(dbMetadata.shouldSkipGraphColumns("Retail_Graph.Product_Node")); + assertFalse(dbMetadata.shouldSkipGraphColumns("RetailGraph.Product_Node")); + assertFalse(dbMetadata.shouldSkipGraphColumns("Retail%.Product_Node")); + assertFalse(dbMetadata.shouldSkipGraphColumns("RetailGraph.Product%")); + assertFalse(dbMetadata.shouldSkipGraphColumns("%")); + assertFalse(dbMetadata.shouldSkipGraphColumns("Retail%")); + assertTrue(dbMetadata.shouldSkipGraphColumns("Graph_Node")); + assertFalse(dbMetadata.shouldSkipGraphColumns(null)); + } + + @Test + public void testCreateGraphColumnRow_StandardProperty() { + FieldList schemaFields = dbMetadata.defineGetColumnsSchema().getFields(); + + FieldValueList propRow = + FieldValueList.of( + dbMetadata.createGraphColumnRow( + "test-project", + "my_dataset", + "RetailGraph.Product", + "list_price", + "NUMERIC", + "list_price", + "COLUMN", + "List price in USD", + 1), + schemaFields); + + assertEquals("test-project", propRow.get("TABLE_CAT").getStringValue()); + assertEquals("my_dataset", propRow.get("TABLE_SCHEM").getStringValue()); + assertEquals("RetailGraph.Product", propRow.get("TABLE_NAME").getStringValue()); + assertEquals("list_price", propRow.get("COLUMN_NAME").getStringValue()); + assertEquals(Types.NUMERIC, propRow.get("DATA_TYPE").getLongValue()); + assertEquals("NUMERIC", propRow.get("TYPE_NAME").getStringValue()); + assertEquals(38L, propRow.get("COLUMN_SIZE").getLongValue()); + assertTrue(propRow.get("BUFFER_LENGTH").isNull()); + assertEquals(9L, propRow.get("DECIMAL_DIGITS").getLongValue()); + assertEquals(10L, propRow.get("NUM_PREC_RADIX").getLongValue()); + assertEquals(DatabaseMetaData.columnNullableUnknown, propRow.get("NULLABLE").getLongValue()); + assertEquals("List price in USD", propRow.get("REMARKS").getStringValue()); + assertTrue(propRow.get("COLUMN_DEF").isNull()); + assertEquals(1L, propRow.get("ORDINAL_POSITION").getLongValue()); + assertEquals("", propRow.get("IS_NULLABLE").getStringValue()); + assertEquals("NO", propRow.get("IS_AUTOINCREMENT").getStringValue()); + assertEquals("NO", propRow.get("IS_GENERATEDCOLUMN").getStringValue()); + } + + @Test + public void testCreateGraphColumnRow_MeasureAndComputedProperty() { + FieldList schemaFields = dbMetadata.defineGetColumnsSchema().getFields(); + + // Measure without description + FieldValueList measureRow = + FieldValueList.of( + dbMetadata.createGraphColumnRow( + "test-project", + "my_dataset", + "RetailGraph.Product", + "avg_list_price", + "NUMERIC", + "AVG(list_price)", + "MEASURE", + null, + 2), + schemaFields); + assertEquals(Types.NUMERIC, measureRow.get("DATA_TYPE").getLongValue()); + assertEquals("NUMERIC", measureRow.get("TYPE_NAME").getStringValue()); + assertEquals(38L, measureRow.get("COLUMN_SIZE").getLongValue()); + assertEquals(9L, measureRow.get("DECIMAL_DIGITS").getLongValue()); + assertEquals("MEASURE: AVG(list_price)", measureRow.get("REMARKS").getStringValue()); + assertEquals("YES", measureRow.get("IS_GENERATEDCOLUMN").getStringValue()); + + // Measure with description + FieldValueList measureWithDescRow = + FieldValueList.of( + dbMetadata.createGraphColumnRow( + "test-project", + "my_dataset", + "RetailGraph.Product", + "avg_list_price", + "NUMERIC", + "AVG(list_price)", + "MEASURE", + "Average list price", + 3), + schemaFields); + assertEquals( + "MEASURE: AVG(list_price) - Average list price", + measureWithDescRow.get("REMARKS").getStringValue()); + assertEquals("YES", measureWithDescRow.get("IS_GENERATEDCOLUMN").getStringValue()); + + // Computed property (non-measure, expression differs from column name) + FieldValueList computedRow = + FieldValueList.of( + dbMetadata.createGraphColumnRow( + "test-project", + "my_dataset", + "RetailGraph.Sale", + "line_total", + "NUMERIC", + "quantity * unit_price", + "EXPRESSION", + "Total line amount", + 4), + schemaFields); + assertEquals("Total line amount", computedRow.get("REMARKS").getStringValue()); + assertEquals("YES", computedRow.get("IS_GENERATEDCOLUMN").getStringValue()); + } + @Test public void testShouldSkipGraphTables() { Set tableOnly = Collections.singleton("TABLE"); 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 c52fa73dfcb2..3f5181d249c6 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 @@ -880,6 +880,120 @@ public void testDatabaseMetadataGetTables_PropertyGraph() throws SQLException { } } + @Test + @Tag("disable_tpc") + @Tag("advanced") + public void testDatabaseMetadataGetColumns_PropertyGraph() throws SQLException { + String nodeTable = "GRAPH_COL_NODE_TABLE_" + randomNumber; + String edgeTable = "GRAPH_COL_EDGE_TABLE_" + randomNumber; + String graphName = "TEST_GRAPH_GET_COLUMNS_" + 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, " + + "list_price NUMERIC OPTIONS(description='List price in USD'))", + PROJECT_ID, DATASET, nodeTable)); + stmt.execute( + String.format( + "CREATE OR REPLACE TABLE `%s.%s.%s` (" + + "id INT64 NOT NULL, dst_id INT64 NOT NULL, weight INT64)", + 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) " + + " LABEL Person PROPERTIES (" + + " id, name, list_price, MEASURE(AVG(list_price)) AS avg_list_price) " + + " LABEL Customer PROPERTIES (id, name), " + + " `%s.%s.%s` AS Tag KEY (id) " + + " LABEL Tag NO PROPERTIES" + + ") " + + "EDGE TABLES (" + + " `%s.%s.%s` AS Knows KEY (id, dst_id) " + + " SOURCE KEY (id) REFERENCES Person (id) " + + " DESTINATION KEY (dst_id) REFERENCES Person (id) " + + " LABEL Knows PROPERTIES (id, dst_id, weight * 2 AS double_weight)" + + ")", + PROJECT_ID, + DATASET, + graphName, + PROJECT_ID, + DATASET, + nodeTable, + PROJECT_ID, + DATASET, + nodeTable, + PROJECT_ID, + DATASET, + edgeTable)); + + DatabaseMetaData metaData = conn.getMetaData(); + + // 1. Node element with deduplicated multi-label properties (id, name) and a measure + try (ResultSet rs = metaData.getColumns(PROJECT_ID, DATASET, graphName + ".Person", "%")) { + Assertions.assertTrue(rs.next()); + Assertions.assertEquals(PROJECT_ID, rs.getString("TABLE_CAT")); + Assertions.assertEquals(DATASET, rs.getString("TABLE_SCHEM")); + Assertions.assertEquals(graphName + ".Person", rs.getString("TABLE_NAME")); + Assertions.assertEquals("id", rs.getString("COLUMN_NAME")); + Assertions.assertEquals(Types.BIGINT, rs.getInt("DATA_TYPE")); + Assertions.assertEquals("INT64", rs.getString("TYPE_NAME")); + Assertions.assertEquals(DatabaseMetaData.columnNullableUnknown, rs.getInt("NULLABLE")); + Assertions.assertEquals("", rs.getString("IS_NULLABLE")); + Assertions.assertEquals(1, rs.getInt("ORDINAL_POSITION")); + Assertions.assertEquals("NO", rs.getString("IS_AUTOINCREMENT")); + Assertions.assertEquals("NO", rs.getString("IS_GENERATEDCOLUMN")); + + Assertions.assertTrue(rs.next()); + Assertions.assertEquals("name", rs.getString("COLUMN_NAME")); + Assertions.assertEquals(Types.NVARCHAR, rs.getInt("DATA_TYPE")); + Assertions.assertEquals("STRING", rs.getString("TYPE_NAME")); + Assertions.assertEquals(2, rs.getInt("ORDINAL_POSITION")); + Assertions.assertEquals("NO", rs.getString("IS_GENERATEDCOLUMN")); + + Assertions.assertTrue(rs.next()); + Assertions.assertEquals("list_price", rs.getString("COLUMN_NAME")); + Assertions.assertEquals(Types.NUMERIC, rs.getInt("DATA_TYPE")); + Assertions.assertEquals("NUMERIC", rs.getString("TYPE_NAME")); + Assertions.assertEquals(3, rs.getInt("ORDINAL_POSITION")); + Assertions.assertEquals("NO", rs.getString("IS_GENERATEDCOLUMN")); + + Assertions.assertTrue(rs.next()); + Assertions.assertEquals("avg_list_price", rs.getString("COLUMN_NAME")); + Assertions.assertEquals(Types.NUMERIC, rs.getInt("DATA_TYPE")); + Assertions.assertEquals("NUMERIC", rs.getString("TYPE_NAME")); + Assertions.assertEquals(4, rs.getInt("ORDINAL_POSITION")); + Assertions.assertTrue(rs.getString("REMARKS").startsWith("MEASURE: ")); + Assertions.assertEquals("YES", rs.getString("IS_GENERATEDCOLUMN")); + + Assertions.assertFalse(rs.next()); + } + + // 2. Edge element with a calculated property filtered by columnNamePattern + try (ResultSet rs = + metaData.getColumns(PROJECT_ID, DATASET, graphName + ".Knows", "double_weight")) { + Assertions.assertTrue(rs.next()); + Assertions.assertEquals(graphName + ".Knows", rs.getString("TABLE_NAME")); + Assertions.assertEquals("double_weight", rs.getString("COLUMN_NAME")); + Assertions.assertEquals(Types.BIGINT, rs.getInt("DATA_TYPE")); + Assertions.assertEquals("INT64", rs.getString("TYPE_NAME")); + Assertions.assertEquals("YES", rs.getString("IS_GENERATEDCOLUMN")); + Assertions.assertFalse(rs.next()); + } + + // 3. Element declared with NO PROPERTIES returns 0 rows in getColumns + try (ResultSet rs = metaData.getColumns(PROJECT_ID, DATASET, graphName + ".Tag", "%")) { + Assertions.assertFalse(rs.next()); + } + } + } + @Test @Tag("disable_tpc") public void testDatabaseMetadataGetSchemas() throws SQLException {