From 6b81a98453b23310955e2f04031185f94c64c70e Mon Sep 17 00:00:00 2001 From: guan xin Date: Thu, 17 Sep 2026 19:30:44 +0800 Subject: [PATCH] feat(dameng): show view validity and support recompilation --- .../java/com/dbx/agent/BaseDatabaseAgent.java | 2 +- .../java/com/dbx/agent/DatabaseAgent.java | 2 +- .../main/java/com/dbx/agent/ObjectInfo.java | 22 +- .../java/com/dbx/agent/PostgresLikeAgent.java | 2 +- .../com/dbx/agent/StandardJdbcMetadata.java | 2 +- .../main/java/com/dbx/agent/TableInfo.java | 20 +- .../com/dbx/agent/access/AccessAgent.java | 2 +- .../com/dbx/agent/dameng/DamengAgent.java | 114 ++++- .../agent/dameng/DamengAgentMetadataTest.java | 464 +++++++++++++++++- .../main/java/com/dbx/agent/db2/Db2Agent.java | 2 +- .../com/dbx/agent/goldendb/GoldendbAgent.java | 2 +- .../main/java/com/dbx/agent/h2/H2Agent.java | 2 +- .../com/dbx/agent/informix/InformixAgent.java | 2 +- .../dbx/agent/snowflake/SnowflakeAgent.java | 2 +- .../java/com/dbx/agent/sundb/SundbAgent.java | 2 +- .../src/components/objects/ObjectBrowser.vue | 45 +- .../sidebar/SidebarTreeItemDialogs.vue | 16 + .../sidebar/SidebarTreeRuntimeHost.vue | 30 ++ ...ebarTreeRuntimeHost.compileRefresh.spec.ts | 28 ++ .../sidebar/sidebarTreeDialogState.ts | 6 + .../i18n/__tests__/damengViewMessages.spec.ts | 31 ++ apps/desktop/src/i18n/locales/en.ts | 4 + apps/desktop/src/i18n/locales/es.ts | 4 + apps/desktop/src/i18n/locales/it.ts | 4 + apps/desktop/src/i18n/locales/ja.ts | 4 + apps/desktop/src/i18n/locales/ko.ts | 4 + apps/desktop/src/i18n/locales/pt-BR.ts | 4 + apps/desktop/src/i18n/locales/zh-CN.ts | 4 + apps/desktop/src/i18n/locales/zh-TW.ts | 4 + .../lib/__tests__/damengCompileSql.spec.ts | 20 + .../__tests__/table/objectBrowserRows.spec.ts | 14 + .../src/lib/__tests__/table/tableTree.spec.ts | 34 ++ .../src/lib/database/damengCompileSql.ts | 16 + .../src/lib/table/objectBrowserRows.ts | 1 + apps/desktop/src/lib/table/tableTree.ts | 10 + apps/desktop/src/types/database.ts | 2 + crates/dbx-core/src/database_export.rs | 1 + crates/dbx-core/src/db/clickhouse_driver.rs | 1 + crates/dbx-core/src/db/cloudflare_d1/mod.rs | 1 + crates/dbx-core/src/db/dolt.rs | 3 + crates/dbx-core/src/db/hbase_driver.rs | 1 + crates/dbx-core/src/db/influxdb3_driver.rs | 1 + crates/dbx-core/src/db/influxdb_driver.rs | 2 + crates/dbx-core/src/db/mysql.rs | 7 +- crates/dbx-core/src/db/mysql_compatible.rs | 1 + crates/dbx-core/src/db/ob_oracle.rs | 1 + crates/dbx-core/src/db/oceanbase_mysql.rs | 2 + crates/dbx-core/src/db/postgres.rs | 1 + crates/dbx-core/src/db/questdb.rs | 3 + crates/dbx-core/src/db/rqlite_driver.rs | 1 + crates/dbx-core/src/db/sqlite.rs | 2 + crates/dbx-core/src/db/sqlserver.rs | 2 + crates/dbx-core/src/db/starrocks.rs | 2 + crates/dbx-core/src/db/turso_driver.rs | 1 + .../dbx-core/src/db/victoriametrics_driver.rs | 1 + crates/dbx-core/src/schema.rs | 12 + crates/dbx-core/src/schema/normalization.rs | 2 + .../dbx-core/src/schema/providers/native.rs | 1 + crates/dbx-core/src/schema_diff.rs | 27 + crates/dbx-core/src/script_generator.rs | 1 + crates/dbx-core/src/transfer.rs | 1 + crates/dbx-core/src/types.rs | 37 +- .../tests/api_contract_verification.rs | 27 + .../dbx-core/tests/bidirectional_diff_e2e.rs | 1 + .../tests/cross_dialect_integration.rs | 1 + .../live_mysql_schema_diff_column_order.rs | 1 + .../dbx-core/tests/performance_benchmarks.rs | 2 + .../tests/regression_compatibility.rs | 1 + .../tests/schema_diff_case_options.rs | 1 + .../tests/schema_diff_table_mapping.rs | 1 + crates/dbx-mcp/src/backend.rs | 1 + 71 files changed, 1040 insertions(+), 38 deletions(-) create mode 100644 apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.compileRefresh.spec.ts create mode 100644 apps/desktop/src/i18n/__tests__/damengViewMessages.spec.ts create mode 100644 apps/desktop/src/lib/__tests__/damengCompileSql.spec.ts create mode 100644 apps/desktop/src/lib/database/damengCompileSql.ts diff --git a/agents/common/src/main/java/com/dbx/agent/BaseDatabaseAgent.java b/agents/common/src/main/java/com/dbx/agent/BaseDatabaseAgent.java index 7731bcaec6..77ff4b4687 100644 --- a/agents/common/src/main/java/com/dbx/agent/BaseDatabaseAgent.java +++ b/agents/common/src/main/java/com/dbx/agent/BaseDatabaseAgent.java @@ -10,7 +10,7 @@ public abstract class BaseDatabaseAgent implements DatabaseAgent { public List listObjects(String schema) { List result = new ArrayList<>(); for (TableInfo table : listTables(schema)) { - result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment())); + result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment(), table.getValid())); } return result; } diff --git a/agents/common/src/main/java/com/dbx/agent/DatabaseAgent.java b/agents/common/src/main/java/com/dbx/agent/DatabaseAgent.java index 81eae8898e..4d751e2a24 100644 --- a/agents/common/src/main/java/com/dbx/agent/DatabaseAgent.java +++ b/agents/common/src/main/java/com/dbx/agent/DatabaseAgent.java @@ -56,7 +56,7 @@ public interface DatabaseAgent { default List listObjects(String schema) { List result = new ArrayList<>(); for (TableInfo table : listTables(schema)) { - result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment())); + result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment(), table.getValid())); } return result; } diff --git a/agents/common/src/main/java/com/dbx/agent/ObjectInfo.java b/agents/common/src/main/java/com/dbx/agent/ObjectInfo.java index 00b26392b2..0604ca7dd7 100644 --- a/agents/common/src/main/java/com/dbx/agent/ObjectInfo.java +++ b/agents/common/src/main/java/com/dbx/agent/ObjectInfo.java @@ -7,9 +7,10 @@ public final class ObjectInfo { private String object_type; private String schema; private String comment; + private Boolean valid; public ObjectInfo() { - this("", "", null, null); + this("", "", null, null, null); } public ObjectInfo(String name, String object_type) { @@ -17,10 +18,15 @@ public final class ObjectInfo { } public ObjectInfo(String name, String object_type, String schema, String comment) { + this(name, object_type, schema, comment, null); + } + + public ObjectInfo(String name, String object_type, String schema, String comment, Boolean valid) { this.name = name; this.object_type = object_type; this.schema = schema; this.comment = comment; + this.valid = valid; } public String getName() { @@ -39,6 +45,10 @@ public final class ObjectInfo { return comment; } + public Boolean getValid() { + return valid; + } + public void setName(String name) { this.name = name; } @@ -55,6 +65,10 @@ public final class ObjectInfo { this.comment = comment; } + public void setValid(Boolean valid) { + this.valid = valid; + } + @Override public boolean equals(Object other) { if (this == other) return true; @@ -63,12 +77,13 @@ public final class ObjectInfo { return Objects.equals(name, that.name) && Objects.equals(object_type, that.object_type) && Objects.equals(schema, that.schema) - && Objects.equals(comment, that.comment); + && Objects.equals(comment, that.comment) + && Objects.equals(valid, that.valid); } @Override public int hashCode() { - return Objects.hash(name, object_type, schema, comment); + return Objects.hash(name, object_type, schema, comment, valid); } @Override @@ -77,6 +92,7 @@ public final class ObjectInfo { + ", object_type=" + object_type + ", schema=" + schema + ", comment=" + comment + + ", valid=" + valid + ")"; } } diff --git a/agents/common/src/main/java/com/dbx/agent/PostgresLikeAgent.java b/agents/common/src/main/java/com/dbx/agent/PostgresLikeAgent.java index 6a6b764e8a..e0dbb313f5 100644 --- a/agents/common/src/main/java/com/dbx/agent/PostgresLikeAgent.java +++ b/agents/common/src/main/java/com/dbx/agent/PostgresLikeAgent.java @@ -206,7 +206,7 @@ public abstract class PostgresLikeAgent extends AbstractJdbcAgent { return unchecked(() -> { List result = new ArrayList<>(); for (TableInfo table : listTables(schema)) { - result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment())); + result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment(), table.getValid())); } try (java.sql.PreparedStatement stmt = requireConnection().prepareStatement( "SELECT p.proname AS routine_name, " + diff --git a/agents/common/src/main/java/com/dbx/agent/StandardJdbcMetadata.java b/agents/common/src/main/java/com/dbx/agent/StandardJdbcMetadata.java index 0750ea3cc4..4af893c09e 100644 --- a/agents/common/src/main/java/com/dbx/agent/StandardJdbcMetadata.java +++ b/agents/common/src/main/java/com/dbx/agent/StandardJdbcMetadata.java @@ -115,7 +115,7 @@ public final class StandardJdbcMetadata { public List listObjects(List tables, String schema, MetadataListConstraints constraints) { List result = new ArrayList<>(); for (TableInfo table : tables) { - result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment())); + result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment(), table.getValid())); } return MetadataListConstraints.orNone(constraints).filterObjects(result); } diff --git a/agents/common/src/main/java/com/dbx/agent/TableInfo.java b/agents/common/src/main/java/com/dbx/agent/TableInfo.java index 0b385952bf..3dfa73180d 100644 --- a/agents/common/src/main/java/com/dbx/agent/TableInfo.java +++ b/agents/common/src/main/java/com/dbx/agent/TableInfo.java @@ -8,6 +8,7 @@ public final class TableInfo { private String comment; private String parent_schema; private String parent_name; + private Boolean valid; public TableInfo() { this("", "", null); @@ -22,11 +23,16 @@ public final class TableInfo { } public TableInfo(String name, String table_type, String comment, String parent_schema, String parent_name) { + this(name, table_type, comment, parent_schema, parent_name, null); + } + + public TableInfo(String name, String table_type, String comment, String parent_schema, String parent_name, Boolean valid) { this.name = name; this.table_type = table_type; this.comment = comment; this.parent_schema = parent_schema; this.parent_name = parent_name; + this.valid = valid; } public String getName() { @@ -49,6 +55,10 @@ public final class TableInfo { return parent_name; } + public Boolean getValid() { + return valid; + } + public void setName(String name) { this.name = name; } @@ -69,6 +79,10 @@ public final class TableInfo { this.parent_name = parent_name; } + public void setValid(Boolean valid) { + this.valid = valid; + } + @Override public boolean equals(Object other) { if (this == other) return true; @@ -78,12 +92,13 @@ public final class TableInfo { && Objects.equals(table_type, that.table_type) && Objects.equals(comment, that.comment) && Objects.equals(parent_schema, that.parent_schema) - && Objects.equals(parent_name, that.parent_name); + && Objects.equals(parent_name, that.parent_name) + && Objects.equals(valid, that.valid); } @Override public int hashCode() { - return Objects.hash(name, table_type, comment, parent_schema, parent_name); + return Objects.hash(name, table_type, comment, parent_schema, parent_name, valid); } @Override @@ -93,6 +108,7 @@ public final class TableInfo { + ", comment=" + comment + ", parent_schema=" + parent_schema + ", parent_name=" + parent_name + + ", valid=" + valid + ")"; } } diff --git a/agents/drivers/access/src/main/java/com/dbx/agent/access/AccessAgent.java b/agents/drivers/access/src/main/java/com/dbx/agent/access/AccessAgent.java index c723571ecf..09de7043b0 100644 --- a/agents/drivers/access/src/main/java/com/dbx/agent/access/AccessAgent.java +++ b/agents/drivers/access/src/main/java/com/dbx/agent/access/AccessAgent.java @@ -97,7 +97,7 @@ public final class AccessAgent extends AbstractJdbcAgent { public List listObjects(String schema) { List result = new ArrayList<>(); for (TableInfo table : listTables(schema)) { - result.add(new ObjectInfo(table.getName(), table.getTable_type(), null, table.getComment())); + result.add(new ObjectInfo(table.getName(), table.getTable_type(), null, table.getComment(), table.getValid())); } return result; } diff --git a/agents/drivers/dameng/src/main/java/com/dbx/agent/dameng/DamengAgent.java b/agents/drivers/dameng/src/main/java/com/dbx/agent/dameng/DamengAgent.java index 97f830db4c..ee539280c7 100644 --- a/agents/drivers/dameng/src/main/java/com/dbx/agent/dameng/DamengAgent.java +++ b/agents/drivers/dameng/src/main/java/com/dbx/agent/dameng/DamengAgent.java @@ -67,6 +67,7 @@ public final class DamengAgent extends AbstractJdbcAgent { private static final String AGENT_VERSION = "9999.06.04.1-fix-default"; private static final int DBMS_OUTPUT_ENABLE_TIMEOUT_SECS = 5; private static final int DBMS_OUTPUT_ENABLE_NETWORK_TIMEOUT_MILLIS = 5_000; + private static final int VIEW_VALIDITY_BATCH_SIZE = 500; private static final Pattern DATABASE_VERSION_MAJOR_PATTERN = Pattern.compile("(\\d+)\\."); // Word-boundary match so a type or default merely containing the letters is not mistaken // for the IDENTITY keyword. @@ -570,17 +571,17 @@ public final class DamengAgent extends AbstractJdbcAgent { private List queryConstrainedTables(String schema, MetadataListConstraints constraints) { if (legacyJdbcMetadata) { - return executeJdbcMetadataTables(schema, constraints); + return withViewValidity(executeJdbcMetadataTables(schema, constraints), schema); } if (!constraints.includesTableLikeTypes()) { return List.of(); } RuntimeException permissionError; try { - return executeConstrainedTables(buildConstrainedTablesQuery(schema, constraints), constraints); + return withViewValidity(executeConstrainedTables(buildConstrainedTablesQuery(schema, constraints), constraints), schema); } catch (RuntimeException e) { if (isDamengInvalidDatetimeMetadataError(e)) { - return executeJdbcMetadataTables(schema, constraints); + return withViewValidity(executeJdbcMetadataTables(schema, constraints), schema); } if (!isDamengMetadataPermissionError(e)) { throw e; @@ -589,13 +590,13 @@ public final class DamengAgent extends AbstractJdbcAgent { } if (needsMaterializedViewClassification(constraints)) { try { - return executeConstrainedTables( + return withViewValidity(executeConstrainedTables( buildAccessibleConstrainedTablesQuery(schema, constraints), constraints - ); + ), schema); } catch (RuntimeException e) { if (isDamengInvalidDatetimeMetadataError(e)) { - return executeJdbcMetadataTables(schema, constraints); + return withViewValidity(executeJdbcMetadataTables(schema, constraints), schema); } if (!isDamengMetadataPermissionError(e)) { throw e; @@ -605,13 +606,13 @@ public final class DamengAgent extends AbstractJdbcAgent { } if (needsMaterializedViewClassification(constraints) && schemaMatchesConnectedUser(schema)) { try { - return executeConstrainedTables( + return withViewValidity(executeConstrainedTables( buildConstrainedTablesQuery(schema, constraints, DAMENG_USER_MATERIALIZED_VIEW_JOIN_SQL), constraints - ); + ), schema); } catch (RuntimeException e) { if (isDamengInvalidDatetimeMetadataError(e)) { - return executeJdbcMetadataTables(schema, constraints); + return withViewValidity(executeJdbcMetadataTables(schema, constraints), schema); } if (!isDamengMetadataPermissionError(e)) { throw e; @@ -620,10 +621,10 @@ public final class DamengAgent extends AbstractJdbcAgent { } } try { - return executeRawConstrainedTables(schema, constraints); + return withViewValidity(executeRawConstrainedTables(schema, constraints), schema); } catch (RuntimeException e) { if (isDamengInvalidDatetimeMetadataError(e)) { - return executeJdbcMetadataTables(schema, constraints); + return withViewValidity(executeJdbcMetadataTables(schema, constraints), schema); } if (!isDamengMetadataPermissionError(e)) { throw e; @@ -631,7 +632,7 @@ public final class DamengAgent extends AbstractJdbcAgent { permissionError.addSuppressed(e); } try { - return executeJdbcMetadataTables(schema, constraints); + return withViewValidity(executeJdbcMetadataTables(schema, constraints), schema); } catch (RuntimeException e) { e.addSuppressed(permissionError); throw e; @@ -1095,6 +1096,12 @@ public final class DamengAgent extends AbstractJdbcAgent { } private List queryConstrainedObjects(String schema, MetadataListConstraints constraints) { + List objects = queryConstrainedObjectsWithoutValidity(schema, constraints); + applyViewValidity(objects, schema); + return objects; + } + + private List queryConstrainedObjectsWithoutValidity(String schema, MetadataListConstraints constraints) { if (legacyJdbcMetadata) { return executeJdbcMetadataObjects(schema, constraints); } @@ -1158,9 +1165,11 @@ public final class DamengAgent extends AbstractJdbcAgent { if (!constraints.includesTableLikeTypes()) { return List.of(); } - return executeJdbcMetadataTables(schema, constraints).stream() - .map(table -> new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment())) - .toList(); + List objects = new ArrayList<>(); + for (TableInfo table : executeJdbcMetadataTables(schema, constraints)) { + objects.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment(), table.getValid())); + } + return objects; } private List executeConstrainedObjects( @@ -1187,6 +1196,81 @@ public final class DamengAgent extends AbstractJdbcAgent { }); } + private void applyViewValidity(List objects, String schema) { + List viewNames = objects.stream() + .filter(object -> "VIEW".equals(object.getObject_type())) + .map(ObjectInfo::getName) + .toList(); + Map validityByName = loadViewValidity(schema, viewNames); + for (ObjectInfo object : objects) { + if ("VIEW".equals(object.getObject_type())) { + object.setValid(validityByName.get(object.getName())); + } + } + } + + private List withViewValidity(List tables, String schema) { + applyTableViewValidity(tables, schema); + return tables; + } + + private void applyTableViewValidity(List tables, String schema) { + List viewNames = tables.stream() + .filter(table -> "VIEW".equals(table.getTable_type())) + .map(TableInfo::getName) + .toList(); + Map validityByName = loadViewValidity(schema, viewNames); + for (TableInfo table : tables) { + if ("VIEW".equals(table.getTable_type())) { + table.setValid(validityByName.get(table.getName())); + } + } + } + + private Map loadViewValidity(String schema, List viewNames) { + Map validityByName = new HashMap<>(); + List names = new ArrayList<>(new LinkedHashSet<>(viewNames)); + for (int offset = 0; offset < names.size(); offset += VIEW_VALIDITY_BATCH_SIZE) { + List batch = names.subList(offset, Math.min(offset + VIEW_VALIDITY_BATCH_SIZE, names.size())); + try { + unchecked(() -> { + try (PreparedStatement stmt = requireConnected().prepareStatement( + "SELECT OWNER, OBJECT_NAME, OBJECT_TYPE, STATUS " + + "FROM DBA_OBJECTS " + + "WHERE OBJECT_TYPE = 'VIEW' AND OWNER = ? AND OBJECT_NAME IN (" + + String.join(", ", Collections.nCopies(batch.size(), "?")) + ")" + )) { + stmt.setString(1, schema); + for (int index = 0; index < batch.size(); index++) { + stmt.setString(index + 2, batch.get(index)); + } + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String name = rs.getString("OBJECT_NAME"); + String status = rs.getString("STATUS"); + if (name == null || status == null) { + continue; + } + Boolean valid = switch (status.trim().toUpperCase(Locale.ROOT)) { + case "VALID" -> Boolean.TRUE; + case "INVALID" -> Boolean.FALSE; + default -> null; + }; + if (valid != null) { + validityByName.put(name, valid); + } + } + } + } + return null; + }); + } catch (RuntimeException error) { + LOGGER.log(Level.FINE, "Unable to load Dameng view validity for schema " + schema, error); + } + } + return validityByName; + } + private List executeRawConstrainedObjects(String schema, MetadataListConstraints constraints) { List candidates = executeConstrainedObjects( schema, diff --git a/agents/drivers/dameng/src/test/java/com/dbx/agent/dameng/DamengAgentMetadataTest.java b/agents/drivers/dameng/src/test/java/com/dbx/agent/dameng/DamengAgentMetadataTest.java index 046a88f7a6..4d597f5674 100644 --- a/agents/drivers/dameng/src/test/java/com/dbx/agent/dameng/DamengAgentMetadataTest.java +++ b/agents/drivers/dameng/src/test/java/com/dbx/agent/dameng/DamengAgentMetadataTest.java @@ -24,6 +24,7 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; class DamengAgentMetadataTest { @@ -244,8 +245,10 @@ class DamengAgentMetadataTest { Assertions.assertEquals(List.of("MV_C", "TABLE_A", "VIEW_B"), tables.stream().map(TableInfo::getName).toList()); Assertions.assertEquals(List.of("MATERIALIZED_VIEW", "TABLE", "VIEW"), tables.stream().map(TableInfo::getTable_type).toList()); Assertions.assertEquals(List.of("mv comment", "table comment", "view comment"), tables.stream().map(TableInfo::getComment).toList()); - Assertions.assertEquals(4, sqls.size(), String.join("\n", sqls)); - Assertions.assertTrue(sqls.stream().allMatch(sql -> sql.contains("ALL_OBJECTS")), String.join("\n", sqls)); + Assertions.assertEquals(5, sqls.size(), String.join("\n", sqls)); + Assertions.assertTrue(sqls.stream() + .filter(sql -> !sql.contains("FROM DBA_OBJECTS")) + .allMatch(sql -> sql.contains("ALL_OBJECTS")), String.join("\n", sqls)); Assertions.assertEquals(List.of("catalog=null,schema=APP\\_DATA\\%2026,table=%,types=null"), jdbcMetadataCalls); } @@ -337,6 +340,292 @@ class DamengAgentMetadataTest { Assertions.assertEquals("用户示例表", objects.get(0).getComment()); } + @Test + void mapsDamengViewValidityFromDbaObjectsAndBindsSchema() { + DamengAgent agent = new DamengAgent(); + List sqls = new ArrayList<>(); + List validityParams = new ArrayList<>(); + TestSupport.setPrivateConnection(agent, viewValidityConnection( + sqls, + validityParams, + List.of( + List.of("APP", "VALID_VIEW", "VIEW", "VALID"), + List.of("APP", "INVALID_VIEW", "VIEW", "INVALID") + ), + null + )); + + List objects = agent.listObjects("APP"); + + Assertions.assertEquals(Boolean.TRUE, objects.stream() + .filter(object -> "VALID_VIEW".equals(object.getName())) + .findFirst().orElseThrow().getValid()); + Assertions.assertEquals(Boolean.FALSE, objects.stream() + .filter(object -> "INVALID_VIEW".equals(object.getName())) + .findFirst().orElseThrow().getValid()); + String validitySql = sqls.stream() + .filter(sql -> sql.contains("FROM DBA_OBJECTS")) + .findFirst() + .orElseThrow(); + Assertions.assertTrue(validitySql.contains("SELECT OWNER, OBJECT_NAME, OBJECT_TYPE, STATUS"), validitySql); + Assertions.assertTrue(validitySql.contains("OBJECT_TYPE = 'VIEW'"), validitySql); + Assertions.assertTrue(validitySql.contains("OWNER = ?"), validitySql); + Assertions.assertEquals(List.of("APP", "VALID_VIEW", "INVALID_VIEW"), validityParams); + } + + @Test + void mapsDamengViewValidityThroughPagedListTablesResults() { + DamengAgent agent = new DamengAgent(); + List sqls = new ArrayList<>(); + List validityParams = new ArrayList<>(); + TestSupport.setPrivateConnection(agent, viewValidityConnection( + sqls, + validityParams, + List.of( + List.of("APP", "VALID_VIEW", "VIEW", "VALID"), + List.of("APP", "INVALID_VIEW", "VIEW", "INVALID") + ), + null, + List.of(List.of("INVALID_VIEW", "VIEW", "invalid view")) + )); + + List tables = agent.listTables("APP", new MetadataListConstraints(null, 1, 1, List.of("VIEW"))); + + Assertions.assertEquals(1, tables.size()); + Assertions.assertEquals("INVALID_VIEW", tables.get(0).getName()); + Assertions.assertEquals(Boolean.FALSE, tables.get(0).getValid()); + Assertions.assertEquals(List.of("APP", "INVALID_VIEW"), validityParams); + } + + @Test + void mapsDamengViewValidityThroughJdbcListTablesFallback() { + DamengAgent agent = new DamengAgent(); + List sqls = new ArrayList<>(); + List validityParams = new ArrayList<>(); + TestSupport.setPrivateConnection(agent, viewValidityJdbcFallbackConnection( + sqls, + validityParams, + List.of(List.of("APP", "VIEW_B", "VIEW", "INVALID")) + )); + setLegacyJdbcMetadata(agent, true); + + List tables = agent.listTables("APP"); + + Assertions.assertEquals(List.of("TABLE_A", "VIEW_B"), tables.stream().map(TableInfo::getName).toList()); + Assertions.assertEquals(Boolean.FALSE, tables.stream() + .filter(table -> "VIEW_B".equals(table.getName())) + .findFirst().orElseThrow().getValid()); + Assertions.assertNull(tables.stream() + .filter(table -> "TABLE_A".equals(table.getName())) + .findFirst().orElseThrow().getValid()); + Assertions.assertEquals(List.of("APP", "VIEW_B"), validityParams); + Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("FROM DBA_OBJECTS")), sqls.toString()); + } + + @Test + void doesNotQueryOrAttachValidityToNonViewObjects() { + DamengAgent agent = new DamengAgent(); + List sqls = new ArrayList<>(); + TestSupport.setPrivateConnection(agent, metadataConnection("id comment", null, true, List.of(), sqls)); + + List objects = agent.listObjects("APP"); + + Assertions.assertTrue(objects.stream().allMatch(object -> object.getValid() == null), objects.toString()); + Assertions.assertTrue(sqls.stream().noneMatch(sql -> sql.contains("FROM DBA_OBJECTS")), String.join("\n", sqls)); + } + + @Test + void keepsViewObjectsWithUnknownValidityWhenStatusQueryFails() { + DamengAgent agent = new DamengAgent(); + List sqls = new ArrayList<>(); + TestSupport.setPrivateConnection(agent, viewValidityConnection( + sqls, + new ArrayList<>(), + List.of(), + new SQLException("DBA_OBJECTS permission denied") + )); + + List objects = agent.listObjects("APP"); + + Assertions.assertEquals(2, objects.size()); + Assertions.assertTrue(objects.stream().allMatch(object -> object.getValid() == null), objects.toString()); + List tables = agent.listTables("APP"); + Assertions.assertEquals(2, tables.size()); + Assertions.assertTrue(tables.stream().allMatch(table -> table.getValid() == null)); + Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("FROM DBA_OBJECTS")), String.join("\n", sqls)); + } + + @Test + void scopesTableViewValidityToSuccessivePagesAndSearches() { + assertViewValidityPageAndSearchIsolation(true); + } + + @Test + void scopesObjectViewValidityToSuccessivePagesAndSearches() { + assertViewValidityPageAndSearchIsolation(false); + } + + private void assertViewValidityPageAndSearchIsolation(boolean listTables) { + ScopedViewValidityFixture fixture = new ScopedViewValidityFixture(); + DamengAgent agent = new DamengAgent(); + TestSupport.setPrivateConnection(agent, fixture.connection()); + String owner = "App'Owner"; + List names = List.of("PAGE_A", "PAGE_B", "Mixed'View"); + for (int page = 0; page < names.size(); page++) { + String name = names.get(page); + fixture.rows.clear(); + fixture.rows.add(List.of(name, "VIEW", "view comment")); + fixture.statusRows.add(List.of(owner, name, "VIEW", "INVALID")); + String search = page == 2 ? "Mixed'" : null; + if (search != null) { + fixture.rows.add(List.of("UNMATCHED_VIEW", "VIEW", "not requested")); + fixture.rows.add(List.of("Mixed'Table", "TABLE", "not a view")); + } + MetadataListConstraints constraints = new MetadataListConstraints(search, 1, search == null ? page : 0, List.of("VIEW")); + if (listTables) { + List tables = agent.listTables(owner, constraints); + Assertions.assertEquals(List.of(name), tables.stream().map(TableInfo::getName).toList()); + Assertions.assertEquals(Boolean.FALSE, tables.get(0).getValid()); + } else { + List objects = agent.listObjects(owner, constraints); + Assertions.assertEquals(List.of(name), objects.stream().map(ObjectInfo::getName).toList()); + Assertions.assertEquals(Boolean.FALSE, objects.get(0).getValid()); + } + Assertions.assertEquals(List.of(owner, name), fixture.requests.get(page)); + } + Assertions.assertEquals(3, fixture.requests.size()); + } + + @Test + void scopesRawFallbackViewValidityAfterPaging() { + ScopedViewValidityFixture fixture = new ScopedViewValidityFixture(); + fixture.rawFallback = true; + fixture.rows.addAll(List.of( + List.of("VIEW_A", "VIEW", "first"), + List.of("VIEW_B", "VIEW", "second"), + List.of("VIEW_C", "VIEW", "third") + )); + fixture.statusRows.add(List.of("APP", "VIEW_B", "VIEW", "VALID")); + DamengAgent agent = new DamengAgent(); + TestSupport.setPrivateConnection(agent, fixture.connection()); + + List objects = agent.listObjects("APP", new MetadataListConstraints(null, 1, 1, List.of("VIEW"))); + + Assertions.assertEquals(List.of("VIEW_B"), objects.stream().map(ObjectInfo::getName).toList()); + Assertions.assertEquals(Boolean.TRUE, objects.get(0).getValid()); + Assertions.assertEquals(List.of(List.of("APP", "VIEW_B")), fixture.requests); + List tables = agent.listTables("APP", new MetadataListConstraints(null, 1, 1, List.of("VIEW"))); + Assertions.assertEquals(List.of("VIEW_B"), tables.stream().map(TableInfo::getName).toList()); + Assertions.assertEquals(Boolean.TRUE, tables.get(0).getValid()); + Assertions.assertEquals(List.of(List.of("APP", "VIEW_B"), List.of("APP", "VIEW_B")), fixture.requests); + } + + @Test + void scopesJdbcFallbackObjectValidityAfterFilteringAndPaging() { + DamengAgent agent = new DamengAgent(); + List params = new ArrayList<>(); + TestSupport.setPrivateConnection(agent, viewValidityJdbcFallbackConnection( + new ArrayList<>(), params, List.of(List.of("APP", "VIEW_B", "VIEW", "VALID")) + )); + setLegacyJdbcMetadata(agent, true); + + List objects = agent.listObjects("APP", new MetadataListConstraints("VIEW", 1, 0, List.of("VIEW"))); + + Assertions.assertEquals(List.of("VIEW_B"), objects.stream().map(ObjectInfo::getName).toList()); + Assertions.assertEquals(Boolean.TRUE, objects.get(0).getValid()); + Assertions.assertTrue(agent.listObjects("APP", new MetadataListConstraints("VIEW", 1, 1, List.of("VIEW"))).isEmpty()); + Assertions.assertEquals(List.of("APP", "VIEW_B"), params); + } + + @Test + void skipsValidityQueriesForEmptyPagesAndNonViewResults() { + ScopedViewValidityFixture fixture = new ScopedViewValidityFixture(); + DamengAgent agent = new DamengAgent(); + TestSupport.setPrivateConnection(agent, fixture.connection()); + + Assertions.assertTrue(agent.listTables("APP").isEmpty()); + Assertions.assertTrue(agent.listObjects("APP").isEmpty()); + fixture.rows.addAll(List.of( + List.of("TABLE_A", "TABLE", "table"), + List.of("MV_A", "MATERIALIZED_VIEW", "materialized view") + )); + Assertions.assertEquals(2, agent.listTables("APP").size()); + Assertions.assertTrue(agent.listObjects("APP").stream().allMatch(object -> object.getValid() == null)); + Assertions.assertTrue(fixture.requests.isEmpty()); + } + + @Test + void preservesUnknownStatusesAndExactViewNames() { + ScopedViewValidityFixture fixture = new ScopedViewValidityFixture(); + for (String name : List.of("MixedView", "MIXEDVIEW", "NULL_STATUS", "UNKNOWN_STATUS", "MISSING_STATUS")) { + fixture.rows.add(List.of(name, "VIEW", "view")); + } + fixture.statusRows.addAll(List.of( + List.of("APP", "MixedView", "VIEW", " valid "), + List.of("APP", "MIXEDVIEW", "VIEW", "invalid"), + Arrays.asList("APP", "NULL_STATUS", "VIEW", null), + List.of("APP", "UNKNOWN_STATUS", "VIEW", "UNRECOGNIZED") + )); + DamengAgent agent = new DamengAgent(); + TestSupport.setPrivateConnection(agent, fixture.connection()); + + Assertions.assertEquals(Arrays.asList(true, false, null, null, null), + agent.listTables("APP").stream().map(TableInfo::getValid).toList()); + Assertions.assertEquals(Arrays.asList(true, false, null, null, null), + agent.listObjects("APP").stream().map(ObjectInfo::getValid).toList()); + } + + @Test + void deduplicatesAndBatchesViewValidityNames() { + assertBatchedViewValidity(-1); + } + + @Test + void keepsFailedBatchUnknownAndContinuesOtherValidityBatches() { + assertBatchedViewValidity(1); + } + + private void assertBatchedViewValidity(int failedBatch) { + ScopedViewValidityFixture fixture = new ScopedViewValidityFixture(); + fixture.failedBatch = failedBatch; + List names = new ArrayList<>(); + for (int index = 0; index < 1001; index++) { + String name = "VIEW_" + index; + names.add(name); + fixture.rows.add(List.of(name, "VIEW", "view")); + fixture.statusRows.add(List.of("APP", name, "VIEW", "VALID")); + } + fixture.rows.add(fixture.rows.get(0)); + fixture.rows.add(List.of("TABLE_A", "TABLE", "table")); + DamengAgent agent = new DamengAgent(); + TestSupport.setPrivateConnection(agent, fixture.connection()); + + List tables = agent.listTables("APP"); + + Assertions.assertEquals(1003, tables.size()); + Assertions.assertEquals(List.of(501, 501, 2), fixture.requests.stream().map(List::size).toList()); + Assertions.assertEquals(names, fixture.requests.stream().flatMap(params -> params.stream().skip(1)).toList()); + for (int index = 0; index < 1001; index++) { + Assertions.assertEquals(index / 500 == failedBatch ? null : Boolean.TRUE, tables.get(index).getValid()); + } + Assertions.assertEquals(Boolean.TRUE, tables.get(1001).getValid()); + Assertions.assertNull(tables.get(1002).getValid()); + } + + @Test + void reloadsValidityAfterStatusChangesInsteadOfCaching() { + ScopedViewValidityFixture fixture = new ScopedViewValidityFixture(); + fixture.rows.add(List.of("VIEW_A", "VIEW", "view")); + fixture.statusRows.add(List.of("APP", "VIEW_A", "VIEW", "INVALID")); + DamengAgent agent = new DamengAgent(); + TestSupport.setPrivateConnection(agent, fixture.connection()); + + Assertions.assertEquals(Boolean.FALSE, agent.listObjects("APP").get(0).getValid()); + fixture.statusRows.set(0, List.of("APP", "VIEW_A", "VIEW", "VALID")); + Assertions.assertEquals(Boolean.TRUE, agent.listObjects("APP").get(0).getValid()); + Assertions.assertEquals(List.of(List.of("APP", "VIEW_A"), List.of("APP", "VIEW_A")), fixture.requests); + } + @Test void queriesSequencesAndPackagesWhenRequested() { DamengAgent agent = new DamengAgent(); @@ -383,8 +672,10 @@ class DamengAgentMetadataTest { Assertions.assertEquals(List.of("MATERIALIZED_VIEW", "TABLE", "VIEW"), objects.stream().map(ObjectInfo::getObject_type).toList()); Assertions.assertEquals(List.of("APP_DATA%2026", "APP_DATA%2026", "APP_DATA%2026"), objects.stream().map(ObjectInfo::getSchema).toList()); Assertions.assertEquals(List.of("mv comment", "table comment", "view comment"), objects.stream().map(ObjectInfo::getComment).toList()); - Assertions.assertEquals(4, sqls.size(), String.join("\n", sqls)); - Assertions.assertTrue(sqls.stream().allMatch(sql -> sql.contains("ALL_OBJECTS")), String.join("\n", sqls)); + Assertions.assertEquals(5, sqls.size(), String.join("\n", sqls)); + Assertions.assertTrue(sqls.stream() + .filter(sql -> !sql.contains("FROM DBA_OBJECTS")) + .allMatch(sql -> sql.contains("ALL_OBJECTS")), String.join("\n", sqls)); Assertions.assertEquals(List.of("catalog=null,schema=APP\\_DATA\\%2026,table=%,types=null"), jdbcMetadataCalls); } @@ -1541,6 +1832,171 @@ class DamengAgentMetadataTest { return metadataConnection(allColumnComment, fallbackColumnComment, includeMaterializedView, List.of(), null); } + private static Connection viewValidityConnection( + List sqls, + List validityParams, + List> validityRows, + SQLException validityError + ) { + return viewValidityConnection( + sqls, + validityParams, + validityRows, + validityError, + List.of( + List.of("VALID_VIEW", "VIEW", "valid view"), + List.of("INVALID_VIEW", "VIEW", "invalid view") + ) + ); + } + + private static Connection viewValidityConnection( + List sqls, + List validityParams, + List> validityRows, + SQLException validityError, + List> tableRows + ) { + return proxy(Connection.class, (method, args) -> { + String name = method.getName(); + if ("prepareStatement".equals(name)) { + String sql = (String) args[0]; + sqls.add(sql); + if (sql.contains("FROM DBA_OBJECTS")) { + if (validityError != null) return failingMetadataStatement(validityError); + return statusMetadataStatement(validityRows, validityParams); + } + if (sql.contains("FROM ALL_OBJECTS o")) { + return metadataStatement(tableRows); + } + return metadataStatement(List.of()); + } + if ("close".equals(name)) return null; + if ("isClosed".equals(name)) return false; + return defaultValue(method.getReturnType()); + }); + } + + private static Connection viewValidityJdbcFallbackConnection( + List sqls, + List validityParams, + List> validityRows + ) { + return proxy(Connection.class, (method, args) -> { + String name = method.getName(); + if ("prepareStatement".equals(name)) { + String sql = (String) args[0]; + sqls.add(sql); + if (sql.contains("FROM DBA_OBJECTS")) { + return statusMetadataStatement(validityRows, validityParams); + } + return failingMetadataStatement(new SQLException("no ALL_OBJECTS privilege")); + } + if ("getMetaData".equals(name)) { + return proxy(DatabaseMetaData.class, (metadataMethod, metadataArgs) -> { + if ("getSearchStringEscape".equals(metadataMethod.getName())) { + return "\\"; + } + if ("getTables".equals(metadataMethod.getName())) { + return metadataResultSet(List.of( + List.of("VIEW_B", "VIEW", "view comment"), + List.of("TABLE_A", "TABLE", "table comment") + )); + } + return defaultValue(metadataMethod.getReturnType()); + }); + } + if ("close".equals(name)) return null; + if ("isClosed".equals(name)) return false; + return defaultValue(method.getReturnType()); + }); + } + + private static final class ScopedViewValidityFixture { + final List> rows = new ArrayList<>(); + final List> statusRows = new ArrayList<>(); + final List> requests = new ArrayList<>(); + boolean rawFallback; + int failedBatch = -1; + + Connection connection() { + return proxy(Connection.class, (method, args) -> { + if ("prepareStatement".equals(method.getName())) { + String sql = (String) args[0]; + if (sql.contains("FROM DBA_OBJECTS")) { + List params = new ArrayList<>(); + int batch = requests.size(); + requests.add(params); + return proxy(PreparedStatement.class, (statementMethod, statementArgs) -> { + if ("setString".equals(statementMethod.getName())) { + int index = (Integer) statementArgs[0]; + while (params.size() < index) params.add(null); + params.set(index - 1, (String) statementArgs[1]); + } + if ("executeQuery".equals(statementMethod.getName())) { + Assertions.assertTrue(sql.contains("OWNER = ?"), sql); + Assertions.assertTrue(params.size() > 1 && params.size() <= 501, params.toString()); + Assertions.assertTrue(sql.contains("OBJECT_NAME IN (" + + String.join(", ", Collections.nCopies(params.size() - 1, "?")) + ")"), sql); + Assertions.assertEquals(params.size(), sql.chars().filter(character -> character == '?').count()); + for (String param : params) Assertions.assertFalse(sql.contains(param), sql); + if (batch == failedBatch) throw new SQLException("DBA_OBJECTS permission denied"); + return statusResultSet(statusRows.stream() + .filter(row -> params.get(0).equals(row.get(0)) && params.subList(1, params.size()).contains(row.get(1))) + .toList()); + } + return defaultValue(statementMethod.getReturnType()); + }); + } + if (rawFallback && sql.contains("mv.OWNER")) { + return failingMetadataStatement("no SYS.SYSOBJECTS privilege"); + } + return metadataStatement(rows); + } + if ("isClosed".equals(method.getName())) return false; + return defaultValue(method.getReturnType()); + }); + } + } + + private static PreparedStatement statusMetadataStatement(List> rows, List params) { + return proxy(PreparedStatement.class, (method, args) -> { + String name = method.getName(); + if ("setString".equals(name)) { + params.add(String.valueOf(args[1])); + return null; + } + if ("executeQuery".equals(name)) return statusResultSet(rows); + if ("close".equals(name)) return null; + return defaultValue(method.getReturnType()); + }); + } + + private static ResultSet statusResultSet(List> rows) { + int[] index = {-1}; + return proxy(ResultSet.class, (method, args) -> { + String name = method.getName(); + if ("next".equals(name)) { + index[0] += 1; + return index[0] < rows.size(); + } + if ("getString".equals(name)) { + String column = String.valueOf(args[0]).toUpperCase(); + int columnIndex = switch (column) { + case "OWNER" -> 0; + case "OBJECT_NAME" -> 1; + case "OBJECT_TYPE" -> 2; + case "STATUS" -> 3; + default -> -1; + }; + Object value = columnIndex < 0 ? null : rows.get(index[0]).get(columnIndex); + return value == null ? null : String.valueOf(value); + } + if ("close".equals(name)) return null; + return defaultValue(method.getReturnType()); + }); + } + private static Connection metadataConnectionWithIndexes(List sqls) { return metadataConnection( "id comment", diff --git a/agents/drivers/db2/src/main/java/com/dbx/agent/db2/Db2Agent.java b/agents/drivers/db2/src/main/java/com/dbx/agent/db2/Db2Agent.java index 819e523022..d78666d012 100644 --- a/agents/drivers/db2/src/main/java/com/dbx/agent/db2/Db2Agent.java +++ b/agents/drivers/db2/src/main/java/com/dbx/agent/db2/Db2Agent.java @@ -151,7 +151,7 @@ public final class Db2Agent extends AbstractJdbcAgent { return unchecked(() -> { List result = new ArrayList<>(); for (TableInfo table : listTables(schema)) { - result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment())); + result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment(), table.getValid())); } String sql = "SELECT PROCNAME, 'PROCEDURE' FROM SYSCAT.PROCEDURES WHERE PROCSCHEMA = ? ORDER BY PROCNAME"; diff --git a/agents/drivers/goldendb/src/main/java/com/dbx/agent/goldendb/GoldendbAgent.java b/agents/drivers/goldendb/src/main/java/com/dbx/agent/goldendb/GoldendbAgent.java index 899b52c5c8..0175b7ef6e 100644 --- a/agents/drivers/goldendb/src/main/java/com/dbx/agent/goldendb/GoldendbAgent.java +++ b/agents/drivers/goldendb/src/main/java/com/dbx/agent/goldendb/GoldendbAgent.java @@ -127,7 +127,7 @@ public final class GoldendbAgent extends AbstractJdbcAgent { return unchecked(() -> { List result = new ArrayList<>(); for (TableInfo table : listTables(schema)) { - result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment())); + result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment(), table.getValid())); } try (java.sql.PreparedStatement stmt = requireConnected().prepareStatement( diff --git a/agents/drivers/h2/src/main/java/com/dbx/agent/h2/H2Agent.java b/agents/drivers/h2/src/main/java/com/dbx/agent/h2/H2Agent.java index ce2abcc751..53312ee895 100644 --- a/agents/drivers/h2/src/main/java/com/dbx/agent/h2/H2Agent.java +++ b/agents/drivers/h2/src/main/java/com/dbx/agent/h2/H2Agent.java @@ -231,7 +231,7 @@ public class H2Agent extends AbstractJdbcAgent { String effectiveSchema = resolveSchema(schema); List result = new ArrayList<>(); for (TableInfo table : listTables(schema)) { - result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment())); + result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment(), table.getValid())); } String sql = isVersion2OrLater() diff --git a/agents/drivers/informix/src/main/java/com/dbx/agent/informix/InformixAgent.java b/agents/drivers/informix/src/main/java/com/dbx/agent/informix/InformixAgent.java index 62679289a1..18a966115e 100644 --- a/agents/drivers/informix/src/main/java/com/dbx/agent/informix/InformixAgent.java +++ b/agents/drivers/informix/src/main/java/com/dbx/agent/informix/InformixAgent.java @@ -279,7 +279,7 @@ public final class InformixAgent extends AbstractJdbcAgent { return unchecked(() -> { List result = new ArrayList<>(); for (TableInfo table : listTables(schema)) { - result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment())); + result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment(), table.getValid())); } appendRoutineObjects(result, schema, "f", "FUNCTION"); diff --git a/agents/drivers/snowflake/src/main/java/com/dbx/agent/snowflake/SnowflakeAgent.java b/agents/drivers/snowflake/src/main/java/com/dbx/agent/snowflake/SnowflakeAgent.java index 1dffcc3e72..9f6abe1bec 100644 --- a/agents/drivers/snowflake/src/main/java/com/dbx/agent/snowflake/SnowflakeAgent.java +++ b/agents/drivers/snowflake/src/main/java/com/dbx/agent/snowflake/SnowflakeAgent.java @@ -135,7 +135,7 @@ public final class SnowflakeAgent extends AbstractJdbcAgent { return unchecked(() -> { List result = new ArrayList<>(); for (TableInfo table : listTables(schema)) { - result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment())); + result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment(), table.getValid())); } String sql = "SELECT PROCEDURE_NAME, 'PROCEDURE' " + diff --git a/agents/drivers/sundb/src/main/java/com/dbx/agent/sundb/SundbAgent.java b/agents/drivers/sundb/src/main/java/com/dbx/agent/sundb/SundbAgent.java index af21947170..4240dfbc4c 100644 --- a/agents/drivers/sundb/src/main/java/com/dbx/agent/sundb/SundbAgent.java +++ b/agents/drivers/sundb/src/main/java/com/dbx/agent/sundb/SundbAgent.java @@ -129,7 +129,7 @@ public final class SundbAgent extends AbstractJdbcAgent { return unchecked(() -> { List result = new ArrayList<>(); for (TableInfo table : listTables(schema)) { - result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment())); + result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment(), table.getValid())); } try (var stmt = requireConnected().prepareStatement( diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 8fff669bb5..2048e71d4d 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -124,6 +124,7 @@ import { isCancelSearchShortcut } from "@/lib/editor/keyboardShortcuts"; import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard"; import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess"; import { buildXuguCompileSql } from "@/lib/database/xuguCompileSql"; +import { buildDamengCompileViewSql } from "@/lib/database/damengCompileSql"; import { formatShortcut } from "@/lib/editor/shortcutRegistry"; import { batchTableEmptyFeedback, buildBatchTableEmptyPlan, runBatchTableEmpty, type BatchTableEmptyPlanItem } from "@/lib/sidebar/batchTableEmpty"; import { runBatchTableDrop } from "@/lib/table/batchTableDrop"; @@ -224,6 +225,9 @@ const sourceError = ref(""); const sourceRow = ref(null); const sourceEditing = ref(false); const sourceCanEdit = ref(true); +const showCompileErrorDialog = ref(false); +const compileErrorTitle = ref(""); +const compileErrorMessage = ref(""); // --- Right-side panel state --- // Unified panel: either "table-info" (for tables) or "source" (for views/procedures/etc.) const sidePanelRow = ref(null); @@ -2570,6 +2574,24 @@ async function compileXuguObject(row: ObjectBrowserRow) { } } +async function compileDamengView(row: ObjectBrowserRow) { + if (effectiveDatabaseType.value !== "dameng" || row.type !== "VIEW") return; + const schema = row.schema || selectedSchema.value; + const sql = buildDamengCompileViewSql({ schema, name: row.name }); + if (!sql) return; + try { + const executed = await executeObjectBrowserSqlWithProductionGuard(sql, () => api.executeQuery(props.connection.id, props.database, sql, schema)); + if (!executed) return; + toast(t("contextMenu.compileObjectSuccess", { name: row.name })); + await reload(); + await connectionStore.refreshObjectListTreeNode(props.connection.id, props.database, schema); + } catch (e: any) { + compileErrorTitle.value = t("contextMenu.compileObjectFailedTitle"); + compileErrorMessage.value = t("contextMenu.compileObjectFailedMessage", { name: row.name, message: e?.message || String(e) }); + showCompileErrorDialog.value = true; + } +} + async function refreshTruncatePreviewSql(row: ObjectBrowserRow) { truncatePreviewSql.value = ""; truncatePreviewSql.value = await buildTruncateTableSql(tableAdminSqlOptions(row, { cascade: canTruncateTargetCascade.value && truncateTableCascade.value })).catch(() => ""); @@ -3244,6 +3266,7 @@ function getViewMenuItems(item: ObjectBrowserRow): ContextMenuItem[] { { label: t("contextMenu.viewData"), action: () => openViewData(item), icon: Table2 }, { label: t("contextMenu.editView"), action: () => openSource(item), icon: PencilLine }, { label: t("contextMenu.viewSource"), action: () => openSource(item), icon: Code2 }, + ...(effectiveDatabaseType.value === "dameng" && item.type === "VIEW" && buildDamengCompileViewSql({ schema: item.schema || selectedSchema.value, name: item.name }) ? [{ label: t("contextMenu.compileObject"), action: () => compileDamengView(item), icon: Wrench }] : []), { label: t("contextMenu.viewDdl"), action: () => openTableInfo(item, "ddl"), @@ -3633,7 +3656,12 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] { {{ t("objects.partitions", { count: item.partitionCount }) }} -
{{ typeLabel(item) }}
+
+ {{ typeLabel(item) }} + + {{ t(item.valid ? "objects.validStatus" : "objects.invalidStatus") }} + +
{{ formatObjectBrowserCount(item.estimatedRows) }}
@@ -3679,6 +3707,9 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] { {{ item.displayName }}
{{ typeLabel(item) }} + + {{ t(item.valid ? "objects.validStatus" : "objects.invalidStatus") }} + {{ formatObjectBrowserCount(item.estimatedRows) }} @@ -4058,6 +4089,18 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] { + + + + {{ compileErrorTitle }} + +
{{ compileErrorMessage }}
+ + + +
+
+ { if (open.every((value) => !value)) emit("closed"); @@ -806,5 +810,17 @@ watch( + + + + {{ compileErrorTitle }} + +
{{ compileErrorMessage }}
+ + + +
+
+ diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 85eeecced5..e060677210 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -207,6 +207,7 @@ import { createDatabaseCollationOptionsForCharset, DEFAULT_GBASE8S_DATABASE_LOCA import { executeWithProductionContextGuard, executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard"; import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess"; import { buildXuguCompileSql } from "@/lib/database/xuguCompileSql"; +import { buildDamengCompileViewSql } from "@/lib/database/damengCompileSql"; import type { SidebarDataOpenRequest } from "@/lib/sidebar/sidebarDataOpenCoordinator"; import { createSidebarActionTarget, findSidebarActionTarget, releaseRemovedSidebarActionTarget, type SidebarActionTarget } from "@/lib/sidebar/sidebarActionTarget"; import { createSidebarMenuContext, normalizeSidebarMenuDescriptors } from "@/lib/sidebar/sidebarTreeMenuDescriptors"; @@ -333,6 +334,9 @@ import { editDatabaseCollation, editDatabaseCommentText, showEditSchemaCommentDialog, + showCompileErrorDialog, + compileErrorTitle, + compileErrorMessage, schemaCommentText, schemaCommentLoading, schemaCommentPreviewSql, @@ -2617,6 +2621,26 @@ async function compileXuguObject() { } } +async function compileDamengView() { + const node = activeNode.value; + if (currentDatabaseType() !== "dameng" || node.type !== "view" || !node.connectionId || !node.database) return; + const sql = buildDamengCompileViewSql({ schema: node.schema, name: node.objectName || node.label }); + if (!sql) return; + try { + await connectionStore.ensureConnected(node.connectionId); + const executed = await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema }); + if (!executed) return; + toast(t("contextMenu.compileObjectSuccess", { name: node.label }), 3000); + await connectionStore.refreshObjectListTreeNode(node.connectionId, node.database, node.schema); + } catch (e: any) { + compileErrorTitle.value = t("contextMenu.compileObjectFailedTitle"); + compileErrorMessage.value = t("contextMenu.compileObjectFailedMessage", { name: node.label, message: e?.message || String(e) }); + claimTreeItemDialogOwnership(); + routeTreeItemDialogController(); + showCompileErrorDialog.value = true; + } +} + async function executeXuguSchedulerJobAction(action: XuguSchedulerJobAction) { const node = activeNode.value; if (currentDatabaseType() !== "xugu" || node.type !== "job" || !node.connectionId || !node.database) return; @@ -5291,6 +5315,9 @@ function databaseSpecificDialogCapabilities() { createSchemaName, confirmCreateSchema, showEditSchemaCommentDialog, + showCompileErrorDialog, + compileErrorTitle, + compileErrorMessage, schemaCommentText, schemaCommentLoading, schemaCommentPreviewSql, @@ -6093,6 +6120,9 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean { if (node.type === "view" || node.type === "materialized_view") { items.push({ label: t("contextMenu.editView"), action: () => openObjectSourceDialog(true), icon: Pencil }); items.push({ label: t("contextMenu.viewSource"), action: () => openObjectSourceDialog(false), icon: Code2 }); + if (node.type === "view" && currentDatabaseType() === "dameng" && buildDamengCompileViewSql({ schema: node.schema, name: node.objectName || node.label })) { + items.push({ label: t("contextMenu.compileObject"), action: compileDamengView, icon: Wrench }); + } items.push({ label: t("contextMenu.viewDdl"), action: openDdl, diff --git a/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.compileRefresh.spec.ts b/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.compileRefresh.spec.ts new file mode 100644 index 0000000000..b01e4715bd --- /dev/null +++ b/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.compileRefresh.spec.ts @@ -0,0 +1,28 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const source = readFileSync(new URL("../SidebarTreeRuntimeHost.vue", import.meta.url), "utf8"); + +function functionSource(name: string, nextName: string) { + const start = source.indexOf(`async function ${name}`); + const end = source.indexOf(`async function ${nextName}`, start); + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + return source.slice(start, end); +} + +describe("SidebarTreeRuntimeHost compile refresh routing", () => { + it("refreshes the Dameng schema object list after compiling a view", () => { + const compile = functionSource("compileDamengView", "executeXuguSchedulerJobAction"); + + expect(compile).toContain("await connectionStore.refreshObjectListTreeNode(node.connectionId, node.database, node.schema);"); + expect(compile).not.toContain("await connectionStore.refreshTreeNode(node);"); + }); + + it("keeps Xugu compile refresh behavior unchanged", () => { + const compile = functionSource("compileXuguObject", "compileDamengView"); + + expect(compile).toContain("await connectionStore.refreshTreeNode(node);"); + expect(compile).not.toContain("await connectionStore.refreshObjectListTreeNode"); + }); +}); diff --git a/apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts b/apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts index 47b25de8d8..e3c911eef5 100644 --- a/apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts +++ b/apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts @@ -184,6 +184,9 @@ export const editDatabaseCharset = ref("utf8mb4"); export const editDatabaseCollation = ref("utf8mb4_unicode_ci"); export const editDatabaseCommentText = ref(""); export const showEditSchemaCommentDialog = ref(false); +export const showCompileErrorDialog = ref(false); +export const compileErrorTitle = ref(""); +export const compileErrorMessage = ref(""); export const schemaCommentText = ref(""); export const schemaCommentLoading = ref(false); export const schemaCommentPreviewSql = ref(""); @@ -228,12 +231,15 @@ const openFlags = [ showDropSchemaConfirm, showEditDatabasePropertiesDialog, showEditSchemaCommentDialog, + showCompileErrorDialog, showDeleteGroupConfirm, showMoveToNewGroupDialog, ]; export function resetSidebarTreeDialogState() { for (const flag of openFlags) flag.value = false; + compileErrorTitle.value = ""; + compileErrorMessage.value = ""; createDatabaseUsers.value = []; createDatabaseSelectedUsers.value = []; createDatabaseUsersLoading.value = false; diff --git a/apps/desktop/src/i18n/__tests__/damengViewMessages.spec.ts b/apps/desktop/src/i18n/__tests__/damengViewMessages.spec.ts new file mode 100644 index 0000000000..42cbcd30d2 --- /dev/null +++ b/apps/desktop/src/i18n/__tests__/damengViewMessages.spec.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import en from "@/i18n/locales/en"; +import es from "@/i18n/locales/es"; +import itLocale from "@/i18n/locales/it"; +import ja from "@/i18n/locales/ja"; +import ko from "@/i18n/locales/ko"; +import ptBR from "@/i18n/locales/pt-BR"; +import zhCN from "@/i18n/locales/zh-CN"; +import zhTW from "@/i18n/locales/zh-TW"; + +type ViewMessages = { + contextMenu: { compileObjectFailedTitle: string; compileObjectFailedMessage: string }; + objects: { validStatus: string; invalidStatus: string }; +}; + +const locales = { en, es, it: itLocale, ja, ko, "pt-BR": ptBR, "zh-CN": zhCN, "zh-TW": zhTW } as unknown as Record; + +describe.each(Object.entries(locales))("Dameng view messages: %s", (localeName, messages) => { + it("provides a localized compilation failure with both placeholders", () => { + expect(messages.contextMenu.compileObjectFailedTitle).toBeTypeOf("string"); + expect(messages.contextMenu.compileObjectFailedMessage).toContain("{name}"); + expect(messages.contextMenu.compileObjectFailedMessage).toContain("{message}"); + expect(messages.contextMenu.compileObjectFailedMessage).toContain("\n"); + if (localeName !== "en") expect(messages.contextMenu.compileObjectFailedTitle).not.toBe(en.contextMenu.compileObjectFailedTitle); + }); + + it("preserves the database validity labels", () => { + expect(messages.objects.validStatus).toBe("VALID"); + expect(messages.objects.invalidStatus).toBe("INVALID"); + }); +}); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index a72b7ff181..56f138533e 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -3599,6 +3599,8 @@ export default { executeProcedure: "Execute Procedure", compileObject: "Compile", compileObjectSuccess: '"{name}" compiled successfully', + compileObjectFailedTitle: "View compilation failed", + compileObjectFailedMessage: 'View "{name}" compilation failed:\n{message}', confirmExecuteProcedureTitle: "Execute Procedure", confirmExecuteProcedureMessage: 'Execute procedure "{name}"? You can fill or adjust parameter values first.', loadingProcedureParameters: "Loading parameters...", @@ -4342,6 +4344,8 @@ export default { validity: "Validity", valid: "Valid", invalid: "Invalid", + validStatus: "VALID", + invalidStatus: "INVALID", sequence: "Sequence", package: "Package", packageBody: "Package Body", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index fd791f3f2c..40b12a045f 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -3231,6 +3231,8 @@ export default withEnglishFallback({ maxRetriesHint: "Reintentar automáticamente en caso de limitación de tasa, tiempo de espera o error de red temporal. 0 = no reintentar, máximo 10.", }, contextMenu: { + compileObjectFailedTitle: "Error al compilar la vista", + compileObjectFailedMessage: 'No se pudo compilar la vista "{name}":\n{message}', openConnection: "Abrir conexión", closeConnection: "Cerrar conexión", closeSelectedConnections: "Cerrar {count} conexiones seleccionadas", @@ -4017,6 +4019,8 @@ export default withEnglishFallback({ anyPrivilegeDisabled: "Los privilegios de tipo ANY están deshabilitados por DM8 (ENABLE_DDL_ANY_PRIV=0) y no se pueden otorgar ni revocar", }, objects: { + validStatus: "VALID", + invalidStatus: "INVALID", all: "Todos", tables: "Tablas", collections: "Colecciones", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 5448877195..5bb9e5c4f0 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -3229,6 +3229,8 @@ export default withEnglishFallback({ maxRetriesHint: "Riprova automaticamente in caso di limitazione, timeout o errore di rete temporaneo. 0 = nessun tentativo, massimo 10.", }, contextMenu: { + compileObjectFailedTitle: "Compilazione della vista non riuscita", + compileObjectFailedMessage: 'Compilazione della vista "{name}" non riuscita:\n{message}', openConnection: "Apri Connessione", closeConnection: "Chiudi Connessione", closeSelectedConnections: "Chiudi {count} connessioni selezionate", @@ -4015,6 +4017,8 @@ export default withEnglishFallback({ anyPrivilegeDisabled: "I privilegi di tipo ANY sono disabilitati da DM8 (ENABLE_DDL_ANY_PRIV=0) e non possono essere concessi o revocati", }, objects: { + validStatus: "VALID", + invalidStatus: "INVALID", all: "Tutti", tables: "Tabelle", collections: "Collezioni", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 20eaeca787..05e7bf871e 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -3255,6 +3255,8 @@ export default withEnglishFallback({ maxRetriesHint: "レート制限、タイムアウト、または一時的なネットワークエラーが発生した場合に自動的にリトライします。0 = リトライなし、最大10。", }, contextMenu: { + compileObjectFailedTitle: "ビューのコンパイルに失敗しました", + compileObjectFailedMessage: "ビュー「{name}」のコンパイルに失敗しました:\n{message}", openConnection: "接続を開く", closeConnection: "接続を閉じる", closeSelectedConnections: "選択した{count}件の接続を閉じる", @@ -4077,6 +4079,8 @@ export default withEnglishFallback({ anyPrivilegeDisabled: "ANY系権限はDM8で無効化されています(ENABLE_DDL_ANY_PRIV=0)ため、付与・取り消しはできません", }, objects: { + validStatus: "VALID", + invalidStatus: "INVALID", all: "すべて", tables: "テーブル", collections: "コレクション", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 252a73229f..1265b5f369 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -3129,6 +3129,8 @@ export default withEnglishFallback({ noDatabasesFound: "데이터베이스를 찾을 수 없습니다", }, contextMenu: { + compileObjectFailedTitle: "뷰 컴파일 실패", + compileObjectFailedMessage: '뷰 "{name}" 컴파일에 실패했습니다:\n{message}', openConnection: "연결 열기", closeConnection: "연결 닫기", closeSelectedConnections: "선택한 연결 {count}개 닫기", @@ -4004,6 +4006,8 @@ export default withEnglishFallback({ anyPrivilegeDisabled: "ANY 유형 권한은 DM8에서 비활성화되어(ENABLE_DDL_ANY_PRIV=0) 부여하거나 회수할 수 없습니다", }, objects: { + validStatus: "VALID", + invalidStatus: "INVALID", all: "전체", tables: "테이블", collections: "컬렉션", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 28c0592be3..f49483f0be 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -3231,6 +3231,8 @@ export default withEnglishFallback({ maxRetriesHint: "Tente novamente automaticamente ao encontrar limitação de taxa, tempo limite ou erro de rede temporário. 0 = não tentar novamente, máximo 10.", }, contextMenu: { + compileObjectFailedTitle: "Falha na compilação da visão", + compileObjectFailedMessage: 'Falha ao compilar a visão "{name}":\n{message}', openConnection: "Abrir Conexão", closeConnection: "Fechar Conexão", closeSelectedConnections: "Fechar {count} conexões selecionadas", @@ -4017,6 +4019,8 @@ export default withEnglishFallback({ anyPrivilegeDisabled: "Privilégios do tipo ANY estão desabilitados pelo DM8 (ENABLE_DDL_ANY_PRIV=0) e não podem ser concedidos ou revogados", }, objects: { + validStatus: "VALID", + invalidStatus: "INVALID", all: "Todos", tables: "Tabelas", collections: "Coleções", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 9ffe4fd19d..5f57670945 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -3513,6 +3513,8 @@ export default withEnglishFallback({ executeProcedure: "执行过程", compileObject: "编译", compileObjectSuccess: '"{name}" 编译成功', + compileObjectFailedTitle: "视图编译失败", + compileObjectFailedMessage: "视图「{name}」编译失败:\n{message}", confirmExecuteProcedureTitle: "执行存储过程", confirmExecuteProcedureMessage: "确认执行存储过程「{name}」?可先补充或调整参数值。", loadingProcedureParameters: "正在读取参数...", @@ -4319,6 +4321,8 @@ export default withEnglishFallback({ validity: "有效性", valid: "有效", invalid: "无效", + validStatus: "VALID", + invalidStatus: "INVALID", sequence: "序列", package: "包", packageBody: "包体", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index cb1261844c..9b5b6fbb93 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -3228,6 +3228,8 @@ export default withEnglishFallback({ noDatabasesFound: "未找到資料庫", }, contextMenu: { + compileObjectFailedTitle: "檢視編譯失敗", + compileObjectFailedMessage: "檢視「{name}」編譯失敗:\n{message}", openConnection: "開啟連線", closeConnection: "關閉連線", closeSelectedConnections: "關閉選取的 {count} 個連線", @@ -3762,6 +3764,8 @@ export default withEnglishFallback({ buckets: "Buckets", }, objects: { + validStatus: "VALID", + invalidStatus: "INVALID", all: "全部", tables: "資料表", collections: "集合", diff --git a/apps/desktop/src/lib/__tests__/damengCompileSql.spec.ts b/apps/desktop/src/lib/__tests__/damengCompileSql.spec.ts new file mode 100644 index 0000000000..63ffa54003 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/damengCompileSql.spec.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { buildDamengCompileViewSql } from "@/lib/database/damengCompileSql"; + +describe("buildDamengCompileViewSql", () => { + it("quotes schema and view identifiers", () => { + expect(buildDamengCompileViewSql({ schema: "APP", name: "ACTIVE_VIEW" })).toBe('ALTER VIEW "APP"."ACTIVE_VIEW" COMPILE;'); + }); + + it("escapes embedded double quotes", () => { + expect(buildDamengCompileViewSql({ schema: 'APP"DATA', name: 'V"ACTIVE' })).toBe('ALTER VIEW "APP""DATA"."V""ACTIVE" COMPILE;'); + }); + + it("allows a database-level name when no schema is supplied", () => { + expect(buildDamengCompileViewSql({ name: "ACTIVE_VIEW" })).toBe('ALTER VIEW "ACTIVE_VIEW" COMPILE;'); + }); + + it("rejects a blank view name", () => { + expect(buildDamengCompileViewSql({ schema: "APP", name: " " })).toBeNull(); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/table/objectBrowserRows.spec.ts b/apps/desktop/src/lib/__tests__/table/objectBrowserRows.spec.ts index da892df248..85f59a0a0a 100644 --- a/apps/desktop/src/lib/__tests__/table/objectBrowserRows.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/objectBrowserRows.spec.ts @@ -25,6 +25,20 @@ describe("buildObjectBrowserRows", () => { expect(rows[0]?.schema).toBeUndefined(); }); + + it("preserves nullable view validity in browser rows", () => { + const rows = buildObjectBrowserRows({ + objects: [ + { name: "valid_view", object_type: "VIEW", valid: true }, + { name: "invalid_view", object_type: "VIEW", valid: false }, + { name: "unknown_view", object_type: "VIEW", valid: null }, + ], + database: "app", + fallbackSchema: "app", + }); + + expect(rows.map((row) => row.valid)).toEqual([true, false, null]); + }); }); describe("Object Browser pinned ordering", () => { diff --git a/apps/desktop/src/lib/__tests__/table/tableTree.spec.ts b/apps/desktop/src/lib/__tests__/table/tableTree.spec.ts index b42e560133..7ae068356c 100644 --- a/apps/desktop/src/lib/__tests__/table/tableTree.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/tableTree.spec.ts @@ -38,6 +38,40 @@ describe("case-sensitive database objects", () => { expect(new Set(viewGroup?.children?.map((node) => node.id) ?? []).size).toBe(2); }); + it("preserves view validity in simple and grouped trees", () => { + const objects: ObjectInfo[] = [ + { name: "VALID_VIEW", object_type: "VIEW", schema: "dbx_test", valid: true }, + { name: "INVALID_VIEW", object_type: "VIEW", schema: "dbx_test", valid: false }, + ]; + + const simple = buildSimpleObjectTreeNodes({ ...context, schema: "dbx_test", objects }); + expect(simple.map((node) => node.valid)).toEqual([false, true]); + + const grouped = buildGroupedObjectTreeNodes({ ...context, schema: "dbx_test", objects }); + expect(grouped.find((node) => node.type === "group-views")?.children?.map((node) => node.valid)).toEqual([false, true]); + }); + + it("preserves view validity when building the table-like tree path", () => { + const tables: TableInfo[] = [ + { name: "VALID_VIEW", table_type: "VIEW", valid: true }, + { name: "INVALID_VIEW", table_type: "VIEW", valid: false }, + ]; + const nodes = buildTableTreeNodes({ + ...context, + schema: "dbx_test", + tables, + }); + + expect(nodes.map((node) => node.valid)).toEqual([false, true]); + + const grouped = buildGroupedObjectTreeNodes({ + ...context, + schema: "dbx_test", + objects: mergeTableInfosIntoObjects([], tables, "dbx_test"), + }); + expect(grouped.find((node) => node.type === "group-views")?.children?.map((node) => node.valid)).toEqual([false, true]); + }); + it("keeps table nodes whose names differ only by case across pages", () => { const firstPage = buildTableTreeNodes({ ...context, diff --git a/apps/desktop/src/lib/database/damengCompileSql.ts b/apps/desktop/src/lib/database/damengCompileSql.ts new file mode 100644 index 0000000000..c744e017f8 --- /dev/null +++ b/apps/desktop/src/lib/database/damengCompileSql.ts @@ -0,0 +1,16 @@ +export interface DamengCompileViewSqlInput { + schema?: string | null; + name: string; +} + +export function buildDamengCompileViewSql(input: DamengCompileViewSqlInput): string | null { + const name = input.name.trim(); + if (!name) return null; + const schema = input.schema?.trim(); + const qualifiedName = schema ? `${quoteDamengIdentifier(schema)}.${quoteDamengIdentifier(name)}` : quoteDamengIdentifier(name); + return `ALTER VIEW ${qualifiedName} COMPILE;`; +} + +function quoteDamengIdentifier(value: string): string { + return `"${value.replaceAll('"', '""')}"`; +} diff --git a/apps/desktop/src/lib/table/objectBrowserRows.ts b/apps/desktop/src/lib/table/objectBrowserRows.ts index eb8bb24560..df4e17124f 100644 --- a/apps/desktop/src/lib/table/objectBrowserRows.ts +++ b/apps/desktop/src/lib/table/objectBrowserRows.ts @@ -121,6 +121,7 @@ export function objectBrowserRowLegacyPinnedTreeNodeIds(row: ObjectBrowserRow, c { name: row.name, table_type: row.type, + valid: row.valid, comment: row.comment, parent_schema: row.partitionParentSchema, parent_name: row.partitionParentName, diff --git a/apps/desktop/src/lib/table/tableTree.ts b/apps/desktop/src/lib/table/tableTree.ts index 8968ba8926..023c1d79a1 100644 --- a/apps/desktop/src/lib/table/tableTree.ts +++ b/apps/desktop/src/lib/table/tableTree.ts @@ -24,6 +24,7 @@ export function buildTableTreeNodes({ nodeId, connectionId, database, schema, ta objectType, tableType: table.table_type, comment: table.comment, + valid: table.valid, parentSchema: table.parent_schema, parentName: table.parent_name, catalog, @@ -53,6 +54,7 @@ function makeTableTreeEntry({ objectType, tableType, comment, + valid, parentSchema, parentName, catalog, @@ -66,6 +68,7 @@ function makeTableTreeEntry({ objectType: DatabaseObjectTreeKind; tableType?: string; comment?: string | null; + valid?: boolean | null; parentSchema?: string | null; parentName?: string | null; catalog?: string; @@ -79,6 +82,7 @@ function makeTableTreeEntry({ type: objectType === "VIEW" ? ("view" as const) : objectType === "MATERIALIZED_VIEW" ? ("materialized_view" as const) : ("table" as const), tableType, comment, + valid, connectionId, database, schema, @@ -248,6 +252,9 @@ export function mergeTableInfosIntoObjects(objects: readonly ObjectInfo[], table if (matchingObject && table.comment && !matchingObject.comment) { matchingObject.comment = table.comment; } + if (matchingObject && matchingObject.valid == null && table.valid != null) { + matchingObject.valid = table.valid; + } continue; } seen.add(key); @@ -257,6 +264,7 @@ export function mergeTableInfosIntoObjects(objects: readonly ObjectInfo[], table // downstream template strategies can distinguish special table kinds. object_type: table.table_type, schema: tableSchema, + valid: table.valid, comment: table.comment, created_at: undefined, updated_at: undefined, @@ -541,6 +549,7 @@ function buildObjectTreeEntries({ nodeId, connectionId, database, schema, object objectType, tableType: obj.object_type, comment: obj.comment, + valid: obj.valid, parentSchema: obj.parent_schema, parentName: obj.parent_name, }), @@ -658,6 +667,7 @@ export function buildSimpleObjectTreeNodes({ nodeId, connectionId, database, sch objectType, tableType: obj.object_type, comment: obj.comment, + valid: obj.valid, parentSchema: obj.parent_schema, parentName: obj.parent_name, }); diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 3f4396e3dd..2a1c073554 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -789,6 +789,8 @@ export interface CatalogInfo { export interface TableInfo { name: string; table_type: string; + /** Optional validity populated by status-aware object loaders. */ + valid?: boolean | null; comment?: string | null; parent_schema?: string | null; parent_name?: string | null; diff --git a/crates/dbx-core/src/database_export.rs b/crates/dbx-core/src/database_export.rs index 33ff108ee4..4d3facbc62 100644 --- a/crates/dbx-core/src/database_export.rs +++ b/crates/dbx-core/src/database_export.rs @@ -4036,6 +4036,7 @@ mod tests { TableInfo { name: name.to_string(), table_type: table_type.to_string(), + valid: None, comment: None, parent_schema: None, parent_name: None, diff --git a/crates/dbx-core/src/db/clickhouse_driver.rs b/crates/dbx-core/src/db/clickhouse_driver.rs index 66e01bbcc0..87738809ef 100644 --- a/crates/dbx-core/src/db/clickhouse_driver.rs +++ b/crates/dbx-core/src/db/clickhouse_driver.rs @@ -563,6 +563,7 @@ fn clickhouse_table_info_from_row(row: &[serde_json::Value]) -> TableInfo { TableInfo { name: row.first().and_then(|v| v.as_str()).unwrap_or("").to_string(), table_type: table_type.to_string(), + valid: None, comment: row.get(2).and_then(|v| v.as_str()).filter(|value| !value.is_empty()).map(str::to_string), parent_schema: None, parent_name: None, diff --git a/crates/dbx-core/src/db/cloudflare_d1/mod.rs b/crates/dbx-core/src/db/cloudflare_d1/mod.rs index 60470cdcc8..a1be72b5d3 100644 --- a/crates/dbx-core/src/db/cloudflare_d1/mod.rs +++ b/crates/dbx-core/src/db/cloudflare_d1/mod.rs @@ -128,6 +128,7 @@ pub async fn list_tables(client: &CloudflareD1Client, _schema: &str) -> Result Result Result Result Result Vec { return None; } let table_type = normalize_mysql_table_type(&get_str(row, 1)); - Some(TableInfo { name, table_type, comment: None, parent_schema: None, parent_name: None }) + Some(TableInfo { name, table_type, valid: None, comment: None, parent_schema: None, parent_name: None }) }) .collect::>(); tables.sort_by(|a, b| a.name.cmp(&b.name)); @@ -6927,6 +6928,7 @@ mod tests { TableInfo { name: "audit_2024".to_string(), table_type: "BASE TABLE".to_string(), + valid: None, comment: None, parent_schema: None, parent_name: None, @@ -6934,6 +6936,7 @@ mod tests { TableInfo { name: "audit_view".to_string(), table_type: "VIEW".to_string(), + valid: None, comment: None, parent_schema: None, parent_name: None, @@ -6941,6 +6944,7 @@ mod tests { TableInfo { name: "audit_2025".to_string(), table_type: "BASE TABLE".to_string(), + valid: None, comment: Some("purchase order history".to_string()), parent_schema: None, parent_name: None, @@ -6954,6 +6958,7 @@ mod tests { let rows = vec![TableInfo { name: "t_0001".to_string(), table_type: "BASE TABLE".to_string(), + valid: None, comment: Some("food orders".to_string()), parent_schema: None, parent_name: None, diff --git a/crates/dbx-core/src/db/mysql_compatible.rs b/crates/dbx-core/src/db/mysql_compatible.rs index fa30f94f11..e3a1c9b529 100644 --- a/crates/dbx-core/src/db/mysql_compatible.rs +++ b/crates/dbx-core/src/db/mysql_compatible.rs @@ -152,6 +152,7 @@ pub async fn list_tables_show_from(pool: &MySqlPool, catalog: &str, database: &s Some(TableInfo { name, table_type: if table_type.trim().is_empty() { "TABLE".to_string() } else { table_type }, + valid: None, comment: None, parent_schema: None, parent_name: None, diff --git a/crates/dbx-core/src/db/ob_oracle.rs b/crates/dbx-core/src/db/ob_oracle.rs index b185cafe51..87f422b45a 100644 --- a/crates/dbx-core/src/db/ob_oracle.rs +++ b/crates/dbx-core/src/db/ob_oracle.rs @@ -81,6 +81,7 @@ pub async fn list_tables(pool: &mysql_async::Pool, schema: &str) -> Result>(2).ok().flatten().filter(|s| !s.is_empty()), parent_schema: row.try_get::<_, Option>(3).ok().flatten().filter(|s| !s.is_empty()), parent_name: row.try_get::<_, Option>(4).ok().flatten().filter(|s| !s.is_empty()), diff --git a/crates/dbx-core/src/db/questdb.rs b/crates/dbx-core/src/db/questdb.rs index c742e10471..da0ba4ad48 100644 --- a/crates/dbx-core/src/db/questdb.rs +++ b/crates/dbx-core/src/db/questdb.rs @@ -65,6 +65,7 @@ async fn list_tables_new_version(pool: &Pool, _schema: &str) -> Result(0), table_type: table_type.to_string(), + valid: None, comment: None, parent_schema: None, parent_name: None, @@ -91,6 +92,7 @@ async fn list_tables_mat_view_version(pool: &Pool, _schema: &str) -> Result(0), table_type: table_type.to_string(), + valid: None, comment: None, parent_schema: None, parent_name: None, @@ -113,6 +115,7 @@ async fn list_tables_basic(pool: &Pool, _schema: &str) -> Result, .map(|row| TableInfo { name: row.get::<_, String>(0), table_type: "TABLE".to_string(), + valid: None, comment: None, parent_schema: None, parent_name: None, diff --git a/crates/dbx-core/src/db/rqlite_driver.rs b/crates/dbx-core/src/db/rqlite_driver.rs index 261eed07b4..56119f1ace 100644 --- a/crates/dbx-core/src/db/rqlite_driver.rs +++ b/crates/dbx-core/src/db/rqlite_driver.rs @@ -115,6 +115,7 @@ pub async fn list_tables(client: &RqliteClient, _schema: &str) -> Result Result Result(0).unwrap_or("").to_string(), table_type: row.get::<&str, _>(1).unwrap_or("BASE TABLE").to_string(), + valid: None, comment: row.get::<&str, _>(2).filter(|s: &&str| !s.is_empty()).map(|s: &str| s.to_string()), parent_schema: None, parent_name: None, diff --git a/crates/dbx-core/src/db/starrocks.rs b/crates/dbx-core/src/db/starrocks.rs index 803b1f56e5..dd104c5e74 100644 --- a/crates/dbx-core/src/db/starrocks.rs +++ b/crates/dbx-core/src/db/starrocks.rs @@ -110,6 +110,7 @@ fn merge_materialized_views( tables.push(TableInfo { name: name.clone(), table_type: "MATERIALIZED_VIEW".to_string(), + valid: None, comment: None, parent_schema: None, parent_name: None, @@ -245,6 +246,7 @@ mod tests { TableInfo { name: name.to_string(), table_type: table_type.to_string(), + valid: None, comment: None, parent_schema: None, parent_name: None, diff --git a/crates/dbx-core/src/db/turso_driver.rs b/crates/dbx-core/src/db/turso_driver.rs index b9c168d4a6..0d7f0d1ae9 100644 --- a/crates/dbx-core/src/db/turso_driver.rs +++ b/crates/dbx-core/src/db/turso_driver.rs @@ -157,6 +157,7 @@ pub async fn list_tables(client: &TursoClient, _schema: &str) -> Result Result) -> Vec, table_type: &str) -> Vec Vec, table_type: &str) -> Vec, pub comment: Option, pub parent_schema: Option, pub parent_name: Option, @@ -856,7 +858,7 @@ pub struct CustomTypeDetails { mod tests { use super::{ CompletionAssistantCandidate, CompletionAssistantCandidateKind, ObjectInfo, ObjectSourceKind, QueryMessage, - SpatialColumn, SpatialColumnBuilder, + SpatialColumn, SpatialColumnBuilder, TableInfo, }; #[test] @@ -956,6 +958,39 @@ mod tests { assert_eq!(objects[1].xugu_type_members_expandable, Some(true)); } + #[test] + fn dameng_table_validity_round_trips_true_false_and_unknown() { + for validity in [Some(true), Some(false), None] { + let payload = serde_json::json!({"name": "VIEW_A", "table_type": "VIEW", "valid": validity}); + let table: TableInfo = serde_json::from_value(payload).unwrap(); + assert_eq!(table.valid, validity); + let encoded = serde_json::to_value(&table).unwrap(); + assert_eq!(encoded.get("valid").and_then(serde_json::Value::as_bool), validity); + assert_eq!(encoded.get("valid").is_some(), validity.is_some()); + let decoded: TableInfo = serde_json::from_value(encoded).unwrap(); + assert_eq!(decoded.valid, validity); + } + let legacy: TableInfo = serde_json::from_str(r#"{"name":"TABLE_A","table_type":"TABLE"}"#).unwrap(); + assert_eq!(legacy.valid, None); + } + + #[test] + fn dameng_object_validity_round_trips_true_false_and_unknown() { + for validity in [Some(true), Some(false), None] { + let payload = + serde_json::json!({"name": "VIEW_A", "object_type": "VIEW", "schema": "APP", "valid": validity}); + let object: ObjectInfo = serde_json::from_value(payload).unwrap(); + assert_eq!(object.valid, validity); + let encoded = serde_json::to_value(&object).unwrap(); + assert_eq!(encoded.get("valid").and_then(serde_json::Value::as_bool), validity); + assert_eq!(encoded.get("valid").is_some(), validity.is_some()); + let decoded: ObjectInfo = serde_json::from_value(encoded).unwrap(); + assert_eq!(decoded.valid, validity); + } + let legacy: ObjectInfo = serde_json::from_str(r#"{"name":"TABLE_A","object_type":"TABLE"}"#).unwrap(); + assert_eq!(legacy.valid, None); + } + #[test] fn spatial_builder_reports_first_non_null_srid_per_column() { let mut builder = SpatialColumnBuilder::new([3]); diff --git a/crates/dbx-core/tests/api_contract_verification.rs b/crates/dbx-core/tests/api_contract_verification.rs index f10b2deacb..e66b287d84 100644 --- a/crates/dbx-core/tests/api_contract_verification.rs +++ b/crates/dbx-core/tests/api_contract_verification.rs @@ -23,6 +23,7 @@ fn prepare_schema_diff_function_signature() { source_tables: vec![TableInfo { name: "t".to_string(), table_type: "TABLE".to_string(), + valid: None, comment: None, parent_schema: None, parent_name: None, @@ -30,6 +31,7 @@ fn prepare_schema_diff_function_signature() { target_tables: vec![TableInfo { name: "t".to_string(), table_type: "TABLE".to_string(), + valid: None, comment: None, parent_schema: None, parent_name: None, @@ -122,6 +124,7 @@ fn schema_diff_preparation_field_names() { source_tables: vec![TableInfo { name: "t".to_string(), table_type: "TABLE".to_string(), + valid: None, comment: None, parent_schema: None, parent_name: None, @@ -129,6 +132,7 @@ fn schema_diff_preparation_field_names() { target_tables: vec![TableInfo { name: "t".to_string(), table_type: "TABLE".to_string(), + valid: None, comment: None, parent_schema: None, parent_name: None, @@ -237,6 +241,7 @@ fn core_types_serialization_roundtrip() { let table = TableInfo { name: "users".to_string(), table_type: "BASE TABLE".to_string(), + valid: None, comment: Some("user table".to_string()), parent_schema: Some("public".to_string()), parent_name: None, @@ -245,6 +250,28 @@ fn core_types_serialization_roundtrip() { let deserialized: TableInfo = serde_json::from_value(json).unwrap(); assert_eq!(table.name, deserialized.name); assert_eq!(table.comment, deserialized.comment); + assert_eq!(deserialized.valid, None); + + let valid_view: TableInfo = serde_json::from_value(serde_json::json!({ + "name": "valid_view", + "table_type": "VIEW", + "valid": true, + "comment": null, + "parent_schema": null, + "parent_name": null + })) + .unwrap(); + assert_eq!(valid_view.valid, Some(true)); + + let legacy_view: TableInfo = serde_json::from_value(serde_json::json!({ + "name": "legacy_view", + "table_type": "VIEW", + "comment": null, + "parent_schema": null, + "parent_name": null + })) + .unwrap(); + assert_eq!(legacy_view.valid, None); } /// ColumnInfo must serialize/deserialize consistently diff --git a/crates/dbx-core/tests/bidirectional_diff_e2e.rs b/crates/dbx-core/tests/bidirectional_diff_e2e.rs index 28bccc92fc..80c80f0358 100644 --- a/crates/dbx-core/tests/bidirectional_diff_e2e.rs +++ b/crates/dbx-core/tests/bidirectional_diff_e2e.rs @@ -6,6 +6,7 @@ fn table(name: &str) -> TableInfo { TableInfo { name: name.to_string(), table_type: "BASE TABLE".to_string(), + valid: None, comment: None, parent_schema: None, parent_name: None, diff --git a/crates/dbx-core/tests/cross_dialect_integration.rs b/crates/dbx-core/tests/cross_dialect_integration.rs index 4120667a58..8d9cba14f9 100644 --- a/crates/dbx-core/tests/cross_dialect_integration.rs +++ b/crates/dbx-core/tests/cross_dialect_integration.rs @@ -9,6 +9,7 @@ fn table(name: &str) -> TableInfo { TableInfo { name: name.to_string(), table_type: "BASE TABLE".to_string(), + valid: None, comment: None, parent_schema: None, parent_name: None, diff --git a/crates/dbx-core/tests/live_mysql_schema_diff_column_order.rs b/crates/dbx-core/tests/live_mysql_schema_diff_column_order.rs index 6f582d0299..d6ab58d90f 100644 --- a/crates/dbx-core/tests/live_mysql_schema_diff_column_order.rs +++ b/crates/dbx-core/tests/live_mysql_schema_diff_column_order.rs @@ -13,6 +13,7 @@ fn table(name: &str) -> TableInfo { TableInfo { name: name.to_string(), table_type: "BASE TABLE".to_string(), + valid: None, comment: None, parent_schema: None, parent_name: None, diff --git a/crates/dbx-core/tests/performance_benchmarks.rs b/crates/dbx-core/tests/performance_benchmarks.rs index 127699a139..a7e20bc6c5 100644 --- a/crates/dbx-core/tests/performance_benchmarks.rs +++ b/crates/dbx-core/tests/performance_benchmarks.rs @@ -18,6 +18,7 @@ fn generate_tables(count: usize, prefix: &str) -> Vec { .map(|i| TableInfo { name: format!("{}_{}", prefix, i), table_type: "BASE TABLE".to_string(), + valid: None, comment: None, parent_schema: None, parent_name: None, @@ -261,6 +262,7 @@ fn benchmark_dependency_graph_build() { .map(|i| TableInfo { name: format!("tgt_{}", i), table_type: "BASE TABLE".to_string(), + valid: None, comment: None, parent_schema: None, parent_name: None, diff --git a/crates/dbx-core/tests/regression_compatibility.rs b/crates/dbx-core/tests/regression_compatibility.rs index 796d43ef9c..f8a0e7672a 100644 --- a/crates/dbx-core/tests/regression_compatibility.rs +++ b/crates/dbx-core/tests/regression_compatibility.rs @@ -15,6 +15,7 @@ fn basic_table_info(name: &str) -> TableInfo { TableInfo { name: name.to_string(), table_type: "BASE TABLE".to_string(), + valid: None, comment: None, parent_schema: None, parent_name: None, diff --git a/crates/dbx-core/tests/schema_diff_case_options.rs b/crates/dbx-core/tests/schema_diff_case_options.rs index 8bc2308335..f01b929b5a 100644 --- a/crates/dbx-core/tests/schema_diff_case_options.rs +++ b/crates/dbx-core/tests/schema_diff_case_options.rs @@ -7,6 +7,7 @@ fn table(name: &str) -> TableInfo { TableInfo { name: name.to_string(), table_type: "BASE TABLE".to_string(), + valid: None, comment: None, parent_schema: None, parent_name: None, diff --git a/crates/dbx-core/tests/schema_diff_table_mapping.rs b/crates/dbx-core/tests/schema_diff_table_mapping.rs index 1558d6df10..814b3853c4 100644 --- a/crates/dbx-core/tests/schema_diff_table_mapping.rs +++ b/crates/dbx-core/tests/schema_diff_table_mapping.rs @@ -8,6 +8,7 @@ fn table(name: &str) -> TableInfo { TableInfo { name: name.to_string(), table_type: "TABLE".to_string(), + valid: None, comment: None, parent_schema: None, parent_name: None, diff --git a/crates/dbx-mcp/src/backend.rs b/crates/dbx-mcp/src/backend.rs index 9f4ec0d1bc..7dec69a779 100644 --- a/crates/dbx-mcp/src/backend.rs +++ b/crates/dbx-mcp/src/backend.rs @@ -1447,6 +1447,7 @@ impl DbxBackend for WebBackend { Some(TableInfo { name, table_type: "COLLECTION".to_string(), + valid: None, comment: None, parent_schema: None, parent_name: None,