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 27c3138420..f7eb84a149 100644 --- a/agents/common/src/main/java/com/dbx/agent/DatabaseAgent.java +++ b/agents/common/src/main/java/com/dbx/agent/DatabaseAgent.java @@ -27,6 +27,10 @@ public interface DatabaseAgent { List listTables(String schema); + default List listTables(String schema, List objectTypes) { + return listTables(schema); + } + default List listObjects(String schema) { List result = new ArrayList<>(); for (TableInfo table : listTables(schema)) { diff --git a/agents/common/src/main/java/com/dbx/agent/JsonRpcServer.java b/agents/common/src/main/java/com/dbx/agent/JsonRpcServer.java index 569e1b24bf..243863f35e 100644 --- a/agents/common/src/main/java/com/dbx/agent/JsonRpcServer.java +++ b/agents/common/src/main/java/com/dbx/agent/JsonRpcServer.java @@ -109,7 +109,7 @@ public final class JsonRpcServer { } if (AgentProtocol.METHOD_LIST_TABLES.equals(method)) { switchCatalog(params); - return agent.listTables(params.get("schema").getAsString()); + return agent.listTables(params.get("schema").getAsString(), stringListOrNull(params, "object_types")); } if (AgentProtocol.METHOD_LIST_OBJECTS.equals(method)) { switchCatalog(params); 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 39d5d0858c..5e13cccfe7 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 @@ -30,7 +30,9 @@ import java.sql.Types; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Arrays; +import java.util.Comparator; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -44,6 +46,7 @@ public final class DamengAgent extends BaseDatabaseAgent { ); private Connection connection; + private String connectedUsername; @Override public Connection getConnection() { @@ -56,6 +59,7 @@ public final class DamengAgent extends BaseDatabaseAgent { withSuppressedStdout(() -> { Class.forName("dm.jdbc.driver.DmDriver"); connection = DriverManager.getConnection(buildUrl(params), params.getUsername(), params.getPassword()); + connectedUsername = params.getUsername(); }); }); } @@ -136,31 +140,191 @@ public final class DamengAgent extends BaseDatabaseAgent { @Override public List listTables(String schema) { + return listTables(schema, null); + } + + @Override + public List listTables(String schema, List objectTypes) { return unchecked(() -> { - List result = new ArrayList<>(); - String sql = """ - SELECT TABLE_NAME, 'TABLE' AS TABLE_TYPE, COMMENTS - FROM ALL_TAB_COMMENTS - WHERE OWNER = ? AND TABLE_TYPE = 'TABLE' - UNION ALL - SELECT TABLE_NAME, 'VIEW' AS TABLE_TYPE, COMMENTS - FROM ALL_TAB_COMMENTS - WHERE OWNER = ? AND TABLE_TYPE = 'VIEW' - ORDER BY 1 - """.stripIndent().trim(); - try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { - stmt.setString(1, schema); - stmt.setString(2, schema); - try (ResultSet rs = stmt.executeQuery()) { - while (rs.next()) { - result.add(new TableInfo(rs.getString(1), rs.getString(2), rs.getString(3))); - } - } + Map tablesByName = new LinkedHashMap<>(); + if (objectTypesInclude(objectTypes, "TABLE")) { + loadTableOrView(schema, "TABLE", tablesByName); } + if (objectTypesInclude(objectTypes, "VIEW")) { + loadTableOrView(schema, "VIEW", tablesByName); + } + if (objectTypesInclude(objectTypes, "MATERIALIZED_VIEW")) { + loadMaterializedViews(schema, tablesByName); + } + List result = new ArrayList<>(tablesByName.values()); + result.sort(Comparator.comparing(TableInfo::getName)); return result; }); } + private void loadTableOrView(String schema, String tableType, Map tablesByName) { + if (!loadTableOrViewFromAllObjects(schema, tableType, tablesByName)) { + loadTableOrViewFromComments(schema, tableType, tablesByName); + } + if ("VIEW".equals(tableType)) { + removeMaterializedViewsFromRegularViews(schema, tablesByName); + } + } + + private boolean loadTableOrViewFromAllObjects(String schema, String tableType, Map tablesByName) { + String sql = (""" + SELECT o.OBJECT_NAME AS TABLE_NAME, c.COMMENTS + FROM ALL_OBJECTS o + LEFT JOIN ALL_TAB_COMMENTS c ON c.OWNER = o.OWNER AND c.TABLE_NAME = o.OBJECT_NAME + WHERE o.OWNER = ? AND o.OBJECT_TYPE = '%s' + ORDER BY o.OBJECT_NAME + """).formatted(tableType).stripIndent().trim(); + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, schema); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + addTableInfo(tablesByName, rs.getString(1), tableType, rs.getString(2)); + } + } + return true; + } catch (Exception ignored) { + return false; + } + } + + private void loadTableOrViewFromComments(String schema, String tableType, Map tablesByName) { + String sql = (""" + SELECT TABLE_NAME, COMMENTS + FROM ALL_TAB_COMMENTS + WHERE OWNER = ? AND TABLE_TYPE = '%s' + ORDER BY TABLE_NAME + """).formatted(tableType).stripIndent().trim(); + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, schema); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + addTableInfo(tablesByName, rs.getString(1), tableType, rs.getString(2)); + } + } + } catch (Exception ignored) { + // Keep metadata browsing usable even when optional catalog views are restricted. + } + } + + private void loadMaterializedViews(String schema, Map tablesByName) { + loadMaterializedViewsFromAllObjects(schema, tablesByName); + loadMaterializedViewsFromUserMviews(schema, tablesByName); + } + + private void loadMaterializedViewsFromAllObjects(String schema, Map tablesByName) { + String sql = """ + SELECT o.OBJECT_NAME AS TABLE_NAME, c.COMMENTS + FROM ALL_OBJECTS o + LEFT JOIN ALL_TAB_COMMENTS c ON c.OWNER = o.OWNER AND c.TABLE_NAME = o.OBJECT_NAME + WHERE o.OWNER = ? AND o.OBJECT_TYPE IN ('MATERIALIZED VIEW', 'MATERIALIZED_VIEW') + ORDER BY o.OBJECT_NAME + """.stripIndent().trim(); + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, schema); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + addTableInfo(tablesByName, rs.getString(1), "MATERIALIZED_VIEW", rs.getString(2)); + } + } + } catch (Exception ignored) { + // Older or restricted Dameng catalogs may not expose this object type. + } + } + + private void loadMaterializedViewsFromUserMviews(String schema, Map tablesByName) { + if (!schemaMatchesConnectedUser(schema)) { + return; + } + loadUserMviews("SELECT MVIEW_NAME FROM USER_MVIEWS", tablesByName); + } + + private boolean loadUserMviews(String sql, Map tablesByName) { + try (PreparedStatement stmt = requireConnected().prepareStatement(sql); + ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + addTableInfo(tablesByName, rs.getString(1), "MATERIALIZED_VIEW", null); + } + return true; + } catch (Exception ignored) { + return false; + } + } + + private void removeMaterializedViewsFromRegularViews(String schema, Map tablesByName) { + if (!schemaMatchesConnectedUser(schema)) { + return; + } + for (String name : listUserMviewNames()) { + tablesByName.remove(name); + } + } + + private Set listUserMviewNames() { + Set names = new java.util.HashSet<>(); + String sql = "SELECT MVIEW_NAME FROM USER_MVIEWS"; + try (PreparedStatement stmt = requireConnected().prepareStatement(sql); + ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String key = metadataNameKey(rs.getString(1)); + if (!key.isEmpty()) { + names.add(key); + } + } + } catch (Exception ignored) { + // Some Dameng versions or users do not expose USER_MVIEWS. + } + return names; + } + + private boolean schemaMatchesConnectedUser(String schema) { + return connectedUsername != null + && schema != null + && !connectedUsername.isBlank() + && schema.equalsIgnoreCase(connectedUsername); + } + + private static void addTableInfo(Map tablesByName, String name, String tableType, String comment) { + String key = metadataNameKey(name); + if (!key.isEmpty()) { + tablesByName.put(key, new TableInfo(name, tableType, comment)); + } + } + + private static boolean objectTypesInclude(List objectTypes, String expectedType) { + if (objectTypes == null || objectTypes.isEmpty()) { + return true; + } + for (String objectType : objectTypes) { + if (expectedType.equals(normalizeObjectType(objectType))) { + return true; + } + } + return false; + } + + private static String normalizeObjectType(String value) { + String upper = value == null ? "" : value.trim().toUpperCase(Locale.ROOT).replace(' ', '_').replace('-', '_'); + if (upper.contains("MATERIALIZED") && upper.contains("VIEW")) { + return "MATERIALIZED_VIEW"; + } + if (upper.contains("VIEW")) { + return "VIEW"; + } + if (upper.contains("TABLE")) { + return "TABLE"; + } + return upper; + } + + private static String metadataNameKey(String value) { + return value == null ? "" : value.trim().toUpperCase(Locale.ROOT); + } + @Override public List listObjects(String schema) { return unchecked(() -> { @@ -190,6 +354,7 @@ public final class DamengAgent extends BaseDatabaseAgent { return unchecked(() -> { String dbmsType = switch (objectType.toUpperCase(Locale.ROOT)) { case "VIEW" -> "VIEW"; + case "MATERIALIZED_VIEW", "MATERIALIZED VIEW" -> "MATERIALIZED_VIEW"; case "PROCEDURE" -> "PROCEDURE"; case "FUNCTION" -> "FUNCTION"; default -> throw new IllegalArgumentException("Unsupported object type: " + objectType); 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 cb47a92bd5..f13ad47215 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 @@ -2,18 +2,21 @@ package com.dbx.agent.dameng; import com.dbx.agent.ColumnInfo; import com.dbx.agent.ObjectInfo; +import com.dbx.agent.ObjectSource; import com.dbx.agent.TableInfo; import com.dbx.agent.test.JdbcMetadataSqlFake; import com.dbx.agent.test.TestSupport; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import java.lang.reflect.Field; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; import java.lang.reflect.Proxy; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -43,7 +46,11 @@ class DamengAgentMetadataTest { .filter(sql -> sql.contains("ALL_TAB_COMMENTS")) .findFirst() .orElseThrow(); + String allTablesSql = String.join("\n", JdbcMetadataSqlFake.statements); Assertions.assertTrue(tablesSql.contains("COMMENTS"), tablesSql); + Assertions.assertTrue(allTablesSql.contains("ALL_OBJECTS"), allTablesSql); + Assertions.assertTrue(allTablesSql.contains("MATERIALIZED_VIEW"), allTablesSql); + Assertions.assertFalse(allTablesSql.contains("ALL_MVIEWS"), allTablesSql); Assertions.assertFalse(tablesSql.contains("ALL_TABLES"), tablesSql); Assertions.assertFalse(tablesSql.contains("ALL_VIEWS"), tablesSql); } @@ -72,6 +79,55 @@ class DamengAgentMetadataTest { Assertions.assertEquals("用户示例表", objects.get(0).getComment()); } + @Test + void mapsMaterializedViewsFromMetadata() { + DamengAgent agent = new DamengAgent(); + TestSupport.setPrivateConnection(agent, metadataConnection("id comment", null, true)); + + List tables = agent.listTables("APP"); + List objects = agent.listObjects("APP"); + + Assertions.assertTrue(tables.stream().anyMatch(table -> + "USER_SUMMARY_MV".equals(table.getName()) && "MATERIALIZED_VIEW".equals(table.getTable_type()) + )); + Assertions.assertFalse(tables.stream().anyMatch(table -> + "USER_SUMMARY_MV".equals(table.getName()) && "VIEW".equals(table.getTable_type()) + )); + Assertions.assertTrue(objects.stream().anyMatch(object -> + "USER_SUMMARY_MV".equals(object.getName()) && "MATERIALIZED_VIEW".equals(object.getObject_type()) + )); + } + + @Test + void filtersTableListingByRequestedObjectTypes() { + DamengAgent agent = new DamengAgent(); + TestSupport.setPrivateConnection(agent, metadataConnection("id comment", null, true)); + setConnectedUsername(agent, "APP"); + + List views = agent.listTables("APP", List.of("VIEW")); + List materializedViews = agent.listTables("APP", List.of("MATERIALIZED_VIEW")); + + Assertions.assertTrue(views.stream().noneMatch(table -> "MATERIALIZED_VIEW".equals(table.getTable_type()))); + Assertions.assertTrue(views.stream().noneMatch(table -> "USER_SUMMARY_MV".equals(table.getName()))); + Assertions.assertEquals(List.of("USER_SUMMARY_MV"), materializedViews.stream().map(TableInfo::getName).toList()); + } + + @Test + void readsMaterializedViewSourceWithDbmsMetadataType() { + DamengAgent agent = new DamengAgent(); + List params = new ArrayList<>(); + TestSupport.setPrivateConnection( + agent, + objectSourceConnection(params, "CREATE MATERIALIZED VIEW \"APP\".\"USER_SUMMARY_MV\" AS SELECT 1 AS ID FROM DUAL") + ); + + ObjectSource source = agent.getObjectSource("APP", "USER_SUMMARY_MV", "MATERIALIZED_VIEW"); + + Assertions.assertEquals(List.of("MATERIALIZED_VIEW", "USER_SUMMARY_MV", "APP"), params); + Assertions.assertEquals("MATERIALIZED_VIEW", source.getObject_type()); + Assertions.assertTrue(source.getSource().contains("CREATE MATERIALIZED VIEW"), source.getSource()); + } + @Test void triggerMetadataDoesNotRequireOracleTriggerTypeColumn() { DamengAgent agent = new DamengAgent(); @@ -137,6 +193,10 @@ class DamengAgentMetadataTest { } private static Connection metadataConnection(String allColumnComment, String fallbackColumnComment) { + return metadataConnection(allColumnComment, fallbackColumnComment, false); + } + + private static Connection metadataConnection(String allColumnComment, String fallbackColumnComment, boolean includeMaterializedView) { return proxy(Connection.class, (method, args) -> { String name = method.getName(); if ("prepareStatement".equals(name)) { @@ -159,15 +219,44 @@ class DamengAgentMetadataTest { if (sql.contains("SYSCOLUMNCOMMENTS")) { return metadataStatement(List.of()); } - if (sql.contains("ALL_TAB_COMMENTS")) { - return metadataStatement(List.of(List.of("USERS", "TABLE", "用户示例表"))); + if (sql.contains("ALL_OBJECTS") && sql.contains("OBJECT_TYPE = 'TABLE'")) { + return metadataStatement(List.of(List.of("USERS", "用户示例表"))); } - if (sql.contains("ALL_COL_COMMENTS") && !sql.contains("ALL_TAB_COLUMNS")) { - return metadataStatement(List.of()); + if (sql.contains("ALL_OBJECTS") && sql.contains("OBJECT_TYPE = 'VIEW'")) { + return metadataStatement( + includeMaterializedView + ? List.of(Arrays.asList("USER_SUMMARY_MV", "mv comment")) + : List.of() + ); + } + if (sql.contains("ALL_OBJECTS") && sql.contains("MATERIALIZED VIEW")) { + return metadataStatement( + includeMaterializedView + ? List.of(Arrays.asList("USER_SUMMARY_MV", "mv comment")) + : List.of() + ); } if (sql.contains("ALL_OBJECTS")) { return metadataStatement(List.of()); } + if (sql.contains("ALL_TAB_COMMENTS")) { + List> rows = new ArrayList<>(); + rows.add(List.of("USERS", "TABLE", "用户示例表")); + if (includeMaterializedView) { + rows.add(Arrays.asList("USER_SUMMARY_MV", "VIEW", "mv comment")); + } + return metadataStatement(rows); + } + if (sql.contains("USER_MVIEWS")) { + return metadataStatement( + includeMaterializedView + ? List.of(List.of("USER_SUMMARY_MV")) + : List.of() + ); + } + if (sql.contains("ALL_COL_COMMENTS") && !sql.contains("ALL_TAB_COLUMNS")) { + return metadataStatement(List.of()); + } if (sql.contains("ALL_TAB_COLUMNS")) { return metadataStatement(List.of(Arrays.asList( "ID", @@ -191,13 +280,49 @@ class DamengAgentMetadataTest { }); } + private static void setConnectedUsername(DamengAgent agent, String username) { + try { + Field field = DamengAgent.class.getDeclaredField("connectedUsername"); + field.setAccessible(true); + field.set(agent, username); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Unable to set connected username", e); + } + } + + private static Connection objectSourceConnection(List params, String source) { + return proxy(Connection.class, (method, args) -> { + String name = method.getName(); + if ("prepareStatement".equals(name)) { + return metadataStatement(List.of(List.of(source)), params); + } + if ("close".equals(name)) { + return null; + } + if ("isClosed".equals(name)) { + return false; + } + return defaultValue(method.getReturnType()); + }); + } + private static PreparedStatement metadataStatement(List> rows) { + return metadataStatement(rows, null); + } + + private static PreparedStatement metadataStatement(List> rows, List params) { return proxy(PreparedStatement.class, (method, args) -> { String name = method.getName(); if ("executeQuery".equals(name)) { return metadataResultSet(rows); } - if ("setString".equals(name) || "close".equals(name)) { + if ("setString".equals(name)) { + if (params != null) { + params.add(String.valueOf(args[1])); + } + return null; + } + if ("close".equals(name)) { return null; } return defaultValue(method.getReturnType()); diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 82fdb7fddf..da9fdf532c 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -83,7 +83,7 @@ import { type ObjectBrowserSortKey, } from "@/lib/objectBrowserRows"; -type ObjectFilter = "all" | "tables" | "views" | "procedures" | "functions" | "sequences" | "packages"; +type ObjectFilter = "all" | "tables" | "views" | "materializedViews" | "procedures" | "functions" | "sequences" | "packages"; type ObjectBrowserColumnKey = "select" | "name" | "type" | "estimatedRows" | "totalBytes" | "created_at" | "updated_at" | "comment"; const props = defineProps<{ @@ -169,7 +169,8 @@ const { addTask: addExportTask } = useExportTracker(); const needsSchema = computed(() => isSchemaAware(props.connection.db_type) && !connectionUsesDatabaseObjectTreeMode(props.connection)); const tableCount = computed(() => rows.value.filter((row) => row.type === "TABLE").length); -const viewCount = computed(() => rows.value.filter((row) => row.type === "VIEW" || row.type === "MATERIALIZED_VIEW").length); +const viewCount = computed(() => rows.value.filter((row) => row.type === "VIEW").length); +const materializedViewCount = computed(() => rows.value.filter((row) => row.type === "MATERIALIZED_VIEW").length); const procedureCount = computed(() => rows.value.filter((row) => row.type === "PROCEDURE").length); const functionCount = computed(() => rows.value.filter((row) => row.type === "FUNCTION").length); const sequenceCount = computed(() => rows.value.filter((row) => row.type === "SEQUENCE").length); @@ -186,6 +187,7 @@ const objectFilters = computed(() => ["all", rows.value.length], ["tables", tableCount.value], ["views", viewCount.value], + ["materializedViews", materializedViewCount.value], ["procedures", procedureCount.value], ["functions", functionCount.value], ["sequences", sequenceCount.value], @@ -245,7 +247,8 @@ function iconFor(row: ObjectBrowserRow) { } function typeLabel(type: ObjectBrowserRow["type"]) { - if (type === "VIEW" || type === "MATERIALIZED_VIEW") return t("objects.view"); + if (type === "MATERIALIZED_VIEW") return t("common.materializedView"); + if (type === "VIEW") return t("objects.view"); if (type === "PROCEDURE") return t("objects.procedure"); if (type === "FUNCTION") return t("objects.function"); if (type === "SEQUENCE") return t("objects.sequence"); @@ -313,7 +316,8 @@ function resetObjectColumnWidth(key: ObjectBrowserColumnKey, width: number, even function rowMatchesObjectFilter(row: ObjectBrowserRow) { if (objectFilter.value === "tables") return row.type === "TABLE"; - if (objectFilter.value === "views") return row.type === "VIEW" || row.type === "MATERIALIZED_VIEW"; + if (objectFilter.value === "views") return row.type === "VIEW"; + if (objectFilter.value === "materializedViews") return row.type === "MATERIALIZED_VIEW"; if (objectFilter.value === "procedures") return row.type === "PROCEDURE"; if (objectFilter.value === "functions") return row.type === "FUNCTION"; if (objectFilter.value === "sequences") return row.type === "SEQUENCE"; @@ -435,13 +439,16 @@ async function openSource(row: ObjectBrowserRow) { async function openViewDdl(row: ObjectBrowserRow) { if (row.type !== "VIEW" && row.type !== "MATERIALIZED_VIEW") return; try { - const result = await api.getObjectSource(props.connection.id, props.database, row.schema || selectedSchema.value || props.database, row.name, "VIEW"); - const ddl = await buildViewDdl({ - databaseType: effectiveDatabaseType.value, - schema: row.schema || selectedSchema.value || props.database, - name: row.name, - source: result.source, - }); + const schema = row.schema || selectedSchema.value || props.database; + const ddl = + row.type === "MATERIALIZED_VIEW" + ? await api.getTableDdl(props.connection.id, props.database, schema, row.name, "MATERIALIZED_VIEW") + : await buildViewDdl({ + databaseType: effectiveDatabaseType.value, + schema, + name: row.name, + source: (await api.getObjectSource(props.connection.id, props.database, schema, row.name, "VIEW")).source, + }); const formatted = await formatSqlForDisplay(ddl, sourceFormatDialect.value, settingsStore.editorSettings.sqlFormatter); const tabId = queryStore.createTab(props.connection.id, props.database, `DDL - ${row.name}`); queryStore.updateSql(tabId, formatted); @@ -791,13 +798,18 @@ async function confirmBatchDropTables() { async function exportStructure(row: ObjectBrowserRow) { try { const schema = row.schema || selectedSchema.value || props.database; - const ddl = await api.getTableDdl(props.connection.id, props.database, schema, row.name, row.type === "VIEW" || row.type === "MATERIALIZED_VIEW" ? "VIEW" : undefined); + const ddl = await api.getTableDdl(props.connection.id, props.database, schema, row.name, tableDdlObjectType(row.type)); await saveFileContent(ddl + "\n", `${row.name}.sql`, "SQL", "sql"); } catch (e: any) { console.error("Export structure failed:", e); } } +function tableDdlObjectType(type: ObjectBrowserRow["type"]): ObjectSourceKind | undefined { + if (type === "VIEW" || type === "MATERIALIZED_VIEW") return type; + return undefined; +} + async function exportDataLegacy(row: ObjectBrowserRow, format: "json" | "sql") { try { const schema = row.schema || selectedSchema.value; @@ -1170,6 +1182,7 @@ function onSchemaChange(value: any) { function filterCount(filter: ObjectFilter) { if (filter === "tables") return tableCount.value; if (filter === "views") return viewCount.value; + if (filter === "materializedViews") return materializedViewCount.value; if (filter === "procedures") return procedureCount.value; if (filter === "functions") return functionCount.value; if (filter === "sequences") return sequenceCount.value; @@ -1178,7 +1191,22 @@ function filterCount(filter: ObjectFilter) { } function filterLabel(filter: ObjectFilter) { - const key = filter === "tables" ? "objects.tables" : filter === "views" ? "objects.views" : filter === "procedures" ? "objects.procedures" : filter === "functions" ? "objects.functions" : filter === "sequences" ? "objects.sequences" : filter === "packages" ? "objects.packages" : "objects.all"; + const key = + filter === "tables" + ? "objects.tables" + : filter === "views" + ? "objects.views" + : filter === "materializedViews" + ? "tree.materializedViews" + : filter === "procedures" + ? "objects.procedures" + : filter === "functions" + ? "objects.functions" + : filter === "sequences" + ? "objects.sequences" + : filter === "packages" + ? "objects.packages" + : "objects.all"; return `${t(key)} ${filterCount(filter)}`; } diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 282971e7b5..41353a0422 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -63,7 +63,7 @@ import { useSettingsStore } from "@/stores/settingsStore"; import { useSavedSqlStore } from "@/stores/savedSqlStore"; import { useToast } from "@/composables/useToast"; import { useDatabaseOptions } from "@/composables/useDatabaseOptions"; -import type { ColumnInfo, ConnectionConfig, DatabaseType, TreeNode, TreeNodeType } from "@/types/database"; +import type { ColumnInfo, ConnectionConfig, DatabaseType, ObjectSourceKind, TreeNode, TreeNodeType } from "@/types/database"; import * as api from "@/lib/api"; import { uuid } from "@/lib/utils"; import { resolveDefaultDatabase } from "@/lib/defaultDatabase"; @@ -1269,9 +1269,10 @@ async function generateDdlTemplate() { let ddl: string; if (node.type === "table") { ddl = await api.getTableDdl(node.connectionId, node.database, schema, node.label); + } else if (node.type === "materialized_view") { + ddl = await api.getTableDdl(node.connectionId, node.database, schema, node.label, "MATERIALIZED_VIEW"); } else { - const objectType = node.type === "materialized_view" ? "MATERIALIZED_VIEW" : "VIEW"; - const result = await api.getObjectSource(node.connectionId, node.database, schema, node.label, objectType); + const result = await api.getObjectSource(node.connectionId, node.database, schema, node.label, "VIEW"); ddl = await buildViewDdl({ databaseType: currentDatabaseType(), schema, @@ -2576,7 +2577,7 @@ async function exportStructure() { const parts: string[] = []; for (const target of targets) { await connectionStore.ensureConnected(target.connectionId); - const ddl = await api.getTableDdl(target.connectionId, target.database, target.schema || target.database, target.label, target.type === "view" ? "VIEW" : undefined); + const ddl = await api.getTableDdl(target.connectionId, target.database, target.schema || target.database, target.label, tableDdlObjectTypeForNode(target.type)); parts.push(ddl.trim()); } structurePreviewSql.value = `${parts.filter(Boolean).join("\n\n")}\n`; @@ -2589,7 +2590,13 @@ async function exportStructure() { } function canExportStructureNode(node: TreeNode): node is TreeNode & { connectionId: string; database: string } { - return (node.type === "table" || node.type === "view") && !!node.connectionId && !!node.database; + return (node.type === "table" || node.type === "view" || node.type === "materialized_view") && !!node.connectionId && !!node.database; +} + +function tableDdlObjectTypeForNode(type: TreeNodeType): ObjectSourceKind | undefined { + if (type === "view") return "VIEW"; + if (type === "materialized_view") return "MATERIALIZED_VIEW"; + return undefined; } function selectedStructureNodes(): TreeNode[] { diff --git a/apps/desktop/src/lib/__tests__/databaseObjectCapabilities.spec.ts b/apps/desktop/src/lib/__tests__/databaseObjectCapabilities.spec.ts new file mode 100644 index 0000000000..1c397dd935 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/databaseObjectCapabilities.spec.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { normalizeSidebarObjectKind, sidebarObjectKindsForDatabase } from "@/lib/databaseObjectCapabilities"; + +describe("databaseObjectCapabilities", () => { + it("exposes materialized views for Dameng", () => { + expect(sidebarObjectKindsForDatabase("dameng")).toContain("MATERIALIZED_VIEW"); + }); + + it("normalizes space separated materialized view types", () => { + expect(normalizeSidebarObjectKind("MATERIALIZED VIEW")).toBe("MATERIALIZED_VIEW"); + }); +}); diff --git a/apps/desktop/src/lib/databaseObjectCapabilities.ts b/apps/desktop/src/lib/databaseObjectCapabilities.ts index 6bb3882398..51b5beca8f 100644 --- a/apps/desktop/src/lib/databaseObjectCapabilities.ts +++ b/apps/desktop/src/lib/databaseObjectCapabilities.ts @@ -29,6 +29,7 @@ const DATABASE_TYPE_OBJECTS = new Map([ ["redshift", POSTGRES_LIKE_OBJECTS], // oracle ["oracle", ORACLE_OBJECTS], + ["dameng", ORACLE_OBJECTS], ["oceanbase-oracle", ORACLE_OBJECTS], // table and view ["sqlite", TABLE_VIEW_OBJECTS], @@ -69,9 +70,10 @@ export function sidebarObjectKindsForDatabase(dbType?: DatabaseType): SidebarObj export function normalizeSidebarObjectKind(type: string): SidebarObjectKind { const value = type.toUpperCase(); - if (value.includes("PACKAGE BODY") || value.includes("PACKAGE_BODY")) return "PACKAGE_BODY"; - if (value.includes("PACKAGE")) return "PACKAGE"; - if (value.includes("MATERIALIZED_VIEW")) return "MATERIALIZED_VIEW"; + const normalized = value.replace(/[\s-]+/g, "_"); + if (normalized.includes("PACKAGE_BODY")) return "PACKAGE_BODY"; + if (normalized.includes("PACKAGE")) return "PACKAGE"; + if (normalized.includes("MATERIALIZED_VIEW")) return "MATERIALIZED_VIEW"; if (value.includes("VIEW")) return "VIEW"; if (value.includes("SEQ")) return "SEQUENCE"; if (value.includes("PROC")) return "PROCEDURE"; diff --git a/apps/desktop/src/lib/objectBrowserRows.ts b/apps/desktop/src/lib/objectBrowserRows.ts index 0c9899b079..c5fa39a51b 100644 --- a/apps/desktop/src/lib/objectBrowserRows.ts +++ b/apps/desktop/src/lib/objectBrowserRows.ts @@ -23,9 +23,10 @@ export type ObjectBrowserSortDirection = "asc" | "desc"; export function normalizeObjectBrowserType(type: string): ObjectBrowserRow["type"] { const value = type.toUpperCase(); - if (value.includes("PACKAGE BODY") || value.includes("PACKAGE_BODY")) return "PACKAGE_BODY"; - if (value.includes("PACKAGE")) return "PACKAGE"; - if (value.includes("MATERIALIZED_VIEW")) return "MATERIALIZED_VIEW"; + const normalized = value.replace(/[\s-]+/g, "_"); + if (normalized.includes("PACKAGE_BODY")) return "PACKAGE_BODY"; + if (normalized.includes("PACKAGE")) return "PACKAGE"; + if (normalized.includes("MATERIALIZED_VIEW")) return "MATERIALIZED_VIEW"; if (value.includes("VIEW")) return "VIEW"; if (value.includes("SEQ")) return "SEQUENCE"; if (value.includes("PROC")) return "PROCEDURE"; diff --git a/apps/desktop/src/lib/tableTree.ts b/apps/desktop/src/lib/tableTree.ts index 15ddb13170..edfab74498 100644 --- a/apps/desktop/src/lib/tableTree.ts +++ b/apps/desktop/src/lib/tableTree.ts @@ -601,7 +601,7 @@ export function buildGroupedObjectTreeNodes({ nodeId, connectionId, database, sc database, schema, objects: items, - objectType: def.objectTypes[0] as "TABLE" | "VIEW", + objectType: def.objectTypes[0] as "TABLE" | "VIEW" | "MATERIALIZED_VIEW", }) : sortDatabaseObjectsByName(items, (obj) => obj.name).map((obj) => { const childSchema = obj.schema ? normalizeDatabaseObjectName(obj.schema) : schema; diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 2414cc1fe4..0fc1bab970 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -661,10 +661,15 @@ export const useConnectionStore = defineStore("connection", () => { return tables.map((table) => ({ name: table.name, schema, - type: table.table_type === "VIEW" || table.table_type === "MATERIALIZED VIEW" ? "view" : "table", + type: isViewLikeTableType(table.table_type) ? "view" : "table", })); } + function isViewLikeTableType(tableType: string): boolean { + const normalized = tableType.toUpperCase().replace(/[\s-]+/g, "_"); + return normalized === "VIEW" || normalized === "MATERIALIZED_VIEW"; + } + function sameSidebarObjectName(left: string | undefined, right: string | undefined): boolean { return (left || "").toLowerCase() === (right || "").toLowerCase(); } @@ -3016,7 +3021,7 @@ export const useConnectionStore = defineStore("connection", () => { results = tables.map((table) => ({ name: table.name, schema, - type: table.table_type === "VIEW" || table.table_type === "MATERIALIZED_VIEW" ? ("view" as const) : ("table" as const), + type: isViewLikeTableType(table.table_type) ? ("view" as const) : ("table" as const), })); } else { results = lookupLocalCompletionTables(connectionId, database, normalizedFilter, limit); @@ -3029,7 +3034,7 @@ export const useConnectionStore = defineStore("connection", () => { results = tables.map((table) => ({ name: table.name, schema, - type: table.table_type === "VIEW" || table.table_type === "MATERIALIZED_VIEW" ? ("view" as const) : ("table" as const), + type: isViewLikeTableType(table.table_type) ? ("view" as const) : ("table" as const), })); } catch { results = []; @@ -3050,7 +3055,7 @@ export const useConnectionStore = defineStore("connection", () => { completionTablesCache.value[cacheKey] = tables.map((table) => ({ name: table.name, schema, - type: table.table_type === "VIEW" || table.table_type === "MATERIALIZED_VIEW" ? ("view" as const) : ("table" as const), + type: isViewLikeTableType(table.table_type) ? ("view" as const) : ("table" as const), })); } else { completionTablesCache.value[cacheKey] = lookupLocalCompletionTables(connectionId, database, normalizedFilter, limit); @@ -3066,7 +3071,7 @@ export const useConnectionStore = defineStore("connection", () => { } completionTablesCache.value[cacheKey] = tables.map((table) => ({ name: table.name, - type: table.table_type === "VIEW" || table.table_type === "MATERIALIZED_VIEW" ? ("view" as const) : ("table" as const), + type: isViewLikeTableType(table.table_type) ? ("view" as const) : ("table" as const), })); completionTablesCache.value[cacheKey] = limit ? completionTablesCache.value[cacheKey].slice(0, limit) : completionTablesCache.value[cacheKey]; indexCompletionTables(connectionId, database, schema, completionTablesCache.value[cacheKey]); diff --git a/crates/dbx-core/src/db/agent_driver.rs b/crates/dbx-core/src/db/agent_driver.rs index 5f0c3f0137..06684de6d5 100644 --- a/crates/dbx-core/src/db/agent_driver.rs +++ b/crates/dbx-core/src/db/agent_driver.rs @@ -608,8 +608,21 @@ impl AgentDriverClient { schema: &str, timeout_duration: Option, ) -> Result { - self.call_method_with_timeout(AgentMethod::ListTables, agent_schema_params(database, schema), timeout_duration) - .await + self.list_tables_filtered(database, schema, None, timeout_duration).await + } + + pub async fn list_tables_filtered( + &mut self, + database: &str, + schema: &str, + object_types: Option<&[String]>, + timeout_duration: Option, + ) -> Result { + let mut params = agent_schema_params(database, schema); + if let Some(object_types) = object_types { + params["object_types"] = serde_json::json!(object_types); + } + self.call_method_with_timeout(AgentMethod::ListTables, params, timeout_duration).await } pub async fn list_objects( diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index 32e74f5013..374ec04ee0 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -999,7 +999,12 @@ async fn list_tables_once( drop(connections); let mut client = client.lock().await; match client - .list_tables::>(database, schema, agent_metadata_timeout(db_config.as_ref())) + .list_tables_filtered::>( + database, + schema, + object_types, + agent_metadata_timeout(db_config.as_ref()), + ) .await { Ok(tables) if !tables.is_empty() => { @@ -2834,6 +2839,18 @@ pub async fn get_table_ddl_core( source: source.source, })); } + if matches!(object_type, Some(db::ObjectSourceKind::MaterializedView)) { + let source = get_object_source_core( + state, + connection_id, + database, + schema, + table, + db::ObjectSourceKind::MaterializedView, + ) + .await?; + return Ok(source.source); + } let pool_key = state.get_or_create_pool(connection_id, Some(database)).await?; let db_config = connection_config(state, connection_id).await; diff --git a/packages/app-tests/objectBrowserRows.test.ts b/packages/app-tests/objectBrowserRows.test.ts index 4266379c2d..edd9956bb6 100644 --- a/packages/app-tests/objectBrowserRows.test.ts +++ b/packages/app-tests/objectBrowserRows.test.ts @@ -54,6 +54,20 @@ test("object browser rows normalize PostgreSQL sequence objects", () => { ); }); +test("object browser rows normalize space separated materialized views", () => { + const rows = buildObjectBrowserRows({ + objects: [{ name: "user_summary_mv", object_type: "MATERIALIZED VIEW", schema: "APP" }], + database: "dameng", + fallbackSchema: "APP", + needsSchema: true, + }); + + assert.deepEqual( + rows.map((row) => ({ id: row.id, type: row.type })), + [{ id: "APP:user_summary_mv:MATERIALIZED_VIEW:0", type: "MATERIALIZED_VIEW" }], + ); +}); + test("object browser search matches names, types, and comments but not schema names", () => { const rows = buildObjectBrowserRows({ objects: [ diff --git a/packages/app-tests/tableTree.test.ts b/packages/app-tests/tableTree.test.ts index a1b06a0990..41e446a367 100644 --- a/packages/app-tests/tableTree.test.ts +++ b/packages/app-tests/tableTree.test.ts @@ -195,6 +195,30 @@ test("buildGroupedObjectTreeNodes groups Oracle packages and package bodies", () ); }); +test("buildGroupedObjectTreeNodes groups materialized views separately from views", () => { + const groups = buildGroupedObjectTreeNodes({ + nodeId: "conn:app:APP", + connectionId: "conn", + database: "app", + schema: "APP", + objects: [ + { name: "ACTIVE_USERS", object_type: "VIEW", schema: "APP" }, + { name: "USER_SUMMARY_MV", object_type: "MATERIALIZED_VIEW", schema: "APP" }, + ], + }); + + const viewGroup = groups.find((node) => node.type === "group-views"); + const materializedViewGroup = groups.find((node) => node.type === "group-materialized-views"); + assert.deepEqual( + viewGroup?.children?.map((node) => ({ label: node.label, type: node.type })), + [{ label: "ACTIVE_USERS", type: "view" }], + ); + assert.deepEqual( + materializedViewGroup?.children?.map((node) => ({ label: node.label, type: node.type })), + [{ label: "USER_SUMMARY_MV", type: "materialized_view" }], + ); +}); + test("buildObjectGroupPlaceholderNodes creates capability-driven lazy sidebar groups", () => { const groups = buildObjectGroupPlaceholderNodes({ nodeId: "conn:app:HR",