diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 2f5ec2501..82a2be0ca 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -39,6 +39,7 @@ import { connectionRedactedNameLabel } from "@/lib/connection/connectionPresenta import { quickConnectionOpenTarget } from "@/lib/connection/connectionOpenTarget"; import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase"; import { findTreeNodeById, resolveNewQueryTarget, resolveNewQueryInitialSql } from "@/lib/sql/newQueryContext"; +import { sqlObjectNavigationTableType, type SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation"; import { buildExecutableObjectSourceStatements, executeObjectSourceSave } from "@/lib/table/objectSourceEditor"; import { resolveExecutableSql, resolveExecutableSqlWithBackend, type SqlExecutionSnapshot } from "@/lib/sql/sqlExecutionTarget"; import { uuid } from "@/lib/common/utils"; @@ -157,7 +158,7 @@ const cursorPos = ref(0); const formatSqlRequest = ref<{ id: number; tabId: string } | null>(null); const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result"); const newQueryContextSource = ref<"tab" | "sidebar">("tab"); -const queryEditorDdlTarget = ref<{ connectionId: string; database: string; schema?: string; tableName: string } | null>(null); +const queryEditorDdlTarget = ref<{ connectionId: string; database: string; schema?: string; tableName: string; objectType?: ObjectSourceKind } | null>(null); const showSaveSqlDialog = ref(false); const saveSqlName = ref(""); const saveSqlFolderId = ref(""); @@ -1187,12 +1188,13 @@ async function openSavedSqlFromWelcome(fileId: string) { toast(t("welcome.fileOpened", { name: file.name }), 2000); } -function tableTargetFromActiveTab(tableName: string) { +function tableTargetFromActiveTab(table: string | SqlObjectNavigationTarget) { const tab = activeTab.value; if (!tab) return null; const connectionId = tab.connectionId; let database = tab.database; - let schema = tab.schema; + let schema = typeof table === "string" ? tab.schema : table.schema || tab.schema; + const tableName = typeof table === "string" ? table : table.name; const parts = tableName.split(".").filter(Boolean); const rawTableName = parts[parts.length - 1] || tableName; @@ -1209,12 +1211,18 @@ function tableTargetFromActiveTab(tableName: string) { } } - return { connectionId, database, schema, tableName: rawTableName }; + return { connectionId, database, schema, tableName: rawTableName, tableType: typeof table === "string" ? undefined : sqlObjectNavigationTableType(table) }; } -async function onClickTable(tableName: string) { - const target = tableTargetFromActiveTab(tableName); +async function onClickTable(table: SqlObjectNavigationTarget) { + const target = tableTargetFromActiveTab(table); if (!target) return; + if (table.type === "view") { + // Definition navigation for views must not run the view query, which may be expensive or have side effects upstream. + queryEditorDdlTarget.value = { ...target, objectType: "VIEW" }; + showQueryEditorDdlDialog.value = true; + return; + } try { await openTableTarget(target, { tableInfoTab: "ddl" }); } catch (e: any) { @@ -2187,6 +2195,7 @@ onUnmounted(() => { :database="queryEditorDdlTarget.database" :schema="queryEditorDdlTarget.schema" :table-name="queryEditorDdlTarget.tableName" + :object-type="queryEditorDdlTarget.objectType" :database-type="queryEditorDdlDatabaseType" :dialect="queryEditorDdlDialect" /> diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 65c0a972b..f6084053d 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -40,7 +40,7 @@ import { mergeSqlSemanticReferenceAnalysis, resolveSqlSemanticNavigationTarget } import { buildElasticsearchCompletionItemsFromContext, getElasticsearchCompletionContext, getElasticsearchCompletionResultValidFor, shouldAutoOpenElasticsearchCompletion, type ElasticsearchCompletionItem } from "@/lib/elasticsearch/elasticsearchCompletion"; import { buildMongoCompletionItemsFromContext, getMongoCompletionContext, getMongoCompletionResultValidFor, shouldAutoOpenMongoCompletion, type MongoCompletionItem } from "@/lib/mongo/mongoCompletion"; import { resolveSqlCompletionTableLookupTarget } from "@/lib/sql/sqlCompletionLookupTarget"; -import { extractIdentifierDetailsAt, isSqlKeyword, matchTable, splitQualifiedIdentifier } from "@/lib/sql/sqlNavigation"; +import { extractIdentifierDetailsAt, isSqlKeyword, matchTable, splitQualifiedIdentifier, sqlObjectHoverDetail, sqlObjectNavigationTarget, type SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation"; import { lineColumnToOffset, parseSqlErrorLocation } from "@/lib/sql/sqlDiagnostics"; import { DBX_TABLE_REFERENCE_MIME, @@ -69,7 +69,7 @@ import * as api from "@/lib/backend/api"; import { areSqlSemanticDiagnosticsEqual, buildSqlParserErrorDiagnostic, buildSqlSemanticDiagnostics, isSqlSemanticDiagnosticInputContext, shouldRunSqlSemanticDiagnostics, sqlSemanticDiagnosticRangesForViewport, tableReferenceKey, type SqlSemanticDiagnostic } from "@/lib/sql/semantic/diagnostics"; import { buildRedisSyntaxDiagnostics, shouldRunRedisDiagnostics } from "@/lib/redis/redisSyntaxDiagnostics"; import { buildRedisCompletionItemsFromContext, getRedisCompletionContext, getRedisCompletionResultValidFor, shouldAutoOpenRedisCompletion, takesKeyArgument, type RedisCompletionItem } from "@/lib/redis/redisCompletion"; -import type { SqlCompletionColumn, SqlCompletionForeignKey, SqlCompletionItem, SqlCompletionObject, SqlCompletionTable } from "@/lib/sql/sqlCompletion"; +import type { SqlCompletionColumn, SqlCompletionForeignKey, SqlCompletionItem, SqlCompletionObject, SqlCompletionReferencedTable, SqlCompletionTable } from "@/lib/sql/sqlCompletion"; import type { DatabaseType, SqlReferenceAnalysis, SqlTableReference, SqlTextSpan } from "@/types/database"; const props = defineProps<{ @@ -102,7 +102,7 @@ const emit = defineEmits<{ formatError: [message: string]; execute: [source: SqlExecutionOverride]; save: []; - clickTable: [tableName: string]; + clickTable: [target: SqlObjectNavigationTarget]; viewTableData: [tableName: string]; viewTableDdl: [tableName: string]; editTableStructure: [tableName: string]; @@ -1376,7 +1376,7 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number) pos: range.from, end: range.to, create: () => ({ - dom: createHoverDom(table.name, table.schema ? `table in ${table.schema}` : "table"), + dom: createHoverDom(table.name, sqlObjectHoverDetail(table)), }), }; } @@ -3149,20 +3149,22 @@ onMounted(async () => { // 1. Check if it's a table name const matchedTable = matchTable(identifier, cachedTables); if (matchedTable) { - emit("clickTable", matchedTable.schema ? `${matchedTable.schema}.${matchedTable.name}` : matchedTable.name); + emit("clickTable", sqlObjectNavigationTarget(matchedTable)); return; } // 2. Parse SQL at click position to get referenced tables const context = getSqlCompletionContext(doc, pos); - let referencedTables = context.referencedTables; + let referencedTables: Array> = context.referencedTables; // Enrich referenced tables with schema from cachedTables referencedTables = referencedTables.map((rt) => { - const cached = cachedTables.find((ct) => ct.name.toLowerCase() === rt.name.toLowerCase()); - if (cached && cached.schema && !rt.schema) { - return { ...rt, schema: cached.schema }; - } - return rt; + const cached = cachedTables.find((ct) => ct.name.toLowerCase() === rt.name.toLowerCase() && (!rt.schema || !ct.schema || ct.schema.toLowerCase() === rt.schema.toLowerCase())); + if (!cached) return rt; + return { + ...rt, + ...(!rt.schema && cached.schema ? { schema: cached.schema } : {}), + ...(cached.type ? { type: cached.type } : {}), + }; }); // Check if identifier has a qualifier (e.g., c.card_name or schema.table) @@ -3170,7 +3172,7 @@ onMounted(async () => { const matchedRef = matchTable(identifier, referencedTables); if (matchedRef) { - emit("clickTable", matchedRef.schema ? `${matchedRef.schema}.${matchedRef.name}` : matchedRef.name); + emit("clickTable", sqlObjectNavigationTarget(matchedRef)); return; } const colName = identifierParts[identifierParts.length - 1] ?? identifier; diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 8b823e205..38e578b7d 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -84,6 +84,7 @@ import ImagePreviewDialog from "@/components/grid/ImagePreviewDialog.vue"; import TemporalCellEditor from "@/components/grid/TemporalCellEditor.vue"; import EnumCellEditor from "@/components/grid/EnumCellEditor.vue"; import type { QueryResult, ColumnInfo, DatabaseType, ForeignKeyInfo, IndexInfo, TriggerInfo, TableInfoTab } from "@/types/database"; +import { tableObjectSourceKind } from "@/lib/table/tableObjectSourceKind"; import * as api from "@/lib/backend/api"; import { formatElapsedSeconds } from "@/lib/common/elapsedTime"; import { dataGridCellDisplayText, dataGridCellEditorText } from "@/lib/dataGrid/dataGridCellCoercion"; @@ -8289,7 +8290,8 @@ async function fetchDdl() { showTableInfo.value = true; ddlLoading.value = true; try { - ddlContent.value = await api.getTableDdl(props.connectionId, props.database || "", props.tableMeta.schema || props.database || "", props.tableMeta.tableName, undefined, props.tableMeta.catalog); + // Preserve view identity so the backend loads the stored view source instead of synthesizing table DDL. + ddlContent.value = await api.getTableDdl(props.connectionId, props.database || "", props.tableMeta.schema || props.database || "", props.tableMeta.tableName, tableObjectSourceKind(props.tableMeta.tableType), props.tableMeta.catalog); } catch (e: any) { ddlContent.value = `-- Error: ${e}`; } finally { diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 905be936c..679a23141 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -83,6 +83,7 @@ import { useTabScroll } from "@/composables/useTabScroll"; import { formatElapsedSeconds } from "@/lib/common/elapsedTime"; import type { CustomSaveHandler } from "@/composables/useDataGridEditor"; import type { QueryTab, ConnectionConfig, TableInfoTab, TreeNode, VectorCollectionMeta, ObjectBrowserViewport } from "@/types/database"; +import type { SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation"; import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter"; import { productionContextForDatabase } from "@/lib/database/productionSafety"; @@ -148,7 +149,7 @@ const emit = defineEmits<{ paginate: [offset: number, limit: number, whereInput?: string, orderBy?: string]; sort: [column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string, mode?: DataGridSortMode]; executeSql: [sql: string]; - clickTable: [tableName: string]; + clickTable: [target: SqlObjectNavigationTarget]; viewTableData: [tableName: string]; viewTableDdl: [tableName: string]; editTableStructure: [tableName: string]; @@ -646,8 +647,8 @@ function closeColumnInfo() { columnInfoError.value = undefined; } -function onHandleClickTable(tableName: string) { - emit("clickTable", tableName); +function onHandleClickTable(target: SqlObjectNavigationTarget) { + emit("clickTable", target); } function onHandleViewTableData(tableName: string) { diff --git a/apps/desktop/src/lib/__tests__/sql/sqlNavigation.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlNavigation.spec.ts index 9898d627f..a18329fff 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlNavigation.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlNavigation.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { extractIdentifierAt, extractIdentifierDetailsAt, isSqlKeyword, matchTable, splitQualifiedIdentifier } from "@/lib/sql/sqlNavigation"; +import { extractIdentifierAt, extractIdentifierDetailsAt, isSqlKeyword, matchTable, splitQualifiedIdentifier, sqlObjectHoverDetail, sqlObjectNavigationTableType, sqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation"; describe("extractIdentifierAt", () => { it("extracts unquoted qualified identifiers", () => { @@ -52,9 +52,10 @@ describe("splitQualifiedIdentifier", () => { describe("matchTable", () => { it("matches schema-qualified table identifiers", () => { - const table = { schema: "MAAC00", name: "Accounts" }; + const table = { schema: "MAAC00", name: "Accounts", type: "view" as const }; expect(matchTable("maac00.accounts", [table])).toBe(table); + expect(matchTable("maac00.accounts", [table])?.type).toBe("view"); }); it("matches catalog.schema.table identifiers against schema-scoped tables", () => { @@ -73,3 +74,23 @@ describe("matchTable", () => { expect(matchTable("u.users", [{ schema: "public", name: "users" }])).toBeNull(); }); }); + +describe("SQL object navigation metadata", () => { + it("preserves view type and schema for command-click navigation", () => { + expect(sqlObjectNavigationTarget({ name: "active_users", schema: "dbo", type: "view" })).toEqual({ + name: "active_users", + schema: "dbo", + type: "view", + }); + }); + + it("uses the object type in hover details", () => { + expect(sqlObjectHoverDetail({ name: "active_users", schema: "dbo", type: "view" })).toBe("view in dbo"); + expect(sqlObjectHoverDetail({ name: "users", schema: "dbo", type: "table" })).toBe("table in dbo"); + }); + + it("maps navigation types to table metadata types", () => { + expect(sqlObjectNavigationTableType({ name: "active_users", type: "view" })).toBe("VIEW"); + expect(sqlObjectNavigationTableType({ name: "users", type: "table" })).toBe("TABLE"); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/table/tableObjectSourceKind.spec.ts b/apps/desktop/src/lib/__tests__/table/tableObjectSourceKind.spec.ts new file mode 100644 index 000000000..b6a3a1ce0 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/table/tableObjectSourceKind.spec.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { tableObjectSourceKind } from "@/lib/table/tableObjectSourceKind"; + +describe("tableObjectSourceKind", () => { + it("routes views to object-source DDL", () => { + expect(tableObjectSourceKind("VIEW")).toBe("VIEW"); + expect(tableObjectSourceKind("materialized view")).toBe("MATERIALIZED_VIEW"); + }); + + it("keeps regular tables on table DDL generation", () => { + expect(tableObjectSourceKind("BASE TABLE")).toBeUndefined(); + expect(tableObjectSourceKind("TABLE")).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/lib/sql/sqlNavigation.ts b/apps/desktop/src/lib/sql/sqlNavigation.ts index a9653f05c..1abd38702 100644 --- a/apps/desktop/src/lib/sql/sqlNavigation.ts +++ b/apps/desktop/src/lib/sql/sqlNavigation.ts @@ -128,6 +128,29 @@ export interface ExtractedSqlIdentifier { quoted: boolean; } +export interface SqlObjectNavigationTarget { + name: string; + schema?: string; + type?: "table" | "view"; +} + +export function sqlObjectNavigationTarget(table: SqlObjectNavigationTarget): SqlObjectNavigationTarget { + return { + name: table.name, + ...(table.schema ? { schema: table.schema } : {}), + ...(table.type ? { type: table.type } : {}), + }; +} + +export function sqlObjectHoverDetail(table: SqlObjectNavigationTarget): string { + const objectType = table.type === "view" ? "view" : "table"; + return table.schema ? `${objectType} in ${table.schema}` : objectType; +} + +export function sqlObjectNavigationTableType(table: SqlObjectNavigationTarget): "TABLE" | "VIEW" { + return table.type === "view" ? "VIEW" : "TABLE"; +} + function isIdentifierChar(char: string | undefined): boolean { return !!char && /^[A-Za-z0-9_$]$/.test(char); } @@ -237,7 +260,7 @@ export function splitQualifiedIdentifier(identifier: string): string[] { } /** Match identifier against known table names (case-insensitive). Supports qualified identifiers like schema.table. */ -export function matchTable(identifier: string, tables: Array<{ name: string; schema?: string }>): { name: string; schema?: string } | null { +export function matchTable(identifier: string, tables: T[]): T | null { const parts = splitQualifiedIdentifier(identifier); const normalizedIdentifier = parts.length > 0 ? parts.join(".").toLowerCase() : identifier.toLowerCase(); diff --git a/apps/desktop/src/lib/table/tableObjectSourceKind.ts b/apps/desktop/src/lib/table/tableObjectSourceKind.ts new file mode 100644 index 000000000..d8d5f727d --- /dev/null +++ b/apps/desktop/src/lib/table/tableObjectSourceKind.ts @@ -0,0 +1,11 @@ +import type { ObjectSourceKind } from "@/types/database"; + +export function tableObjectSourceKind(tableType: string | null | undefined): ObjectSourceKind | undefined { + const normalized = tableType + ?.trim() + .toUpperCase() + .replace(/[\s-]+/g, "_"); + if (normalized === "VIEW") return "VIEW"; + if (normalized === "MATERIALIZED_VIEW") return "MATERIALIZED_VIEW"; + return undefined; +}