fix(editor): preserve view type in object navigation

This commit is contained in:
t8y2 2026-07-14 18:41:26 +08:00
parent 39aa506e44
commit 69dd363417
8 changed files with 108 additions and 25 deletions

View File

@ -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"
/>

View File

@ -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<SqlCompletionReferencedTable & Pick<SqlCompletionTable, "type">> = 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;

View File

@ -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 {

View File

@ -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) {

View File

@ -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");
});
});

View File

@ -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();
});
});

View File

@ -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<T extends { name: string; schema?: string }>(identifier: string, tables: T[]): T | null {
const parts = splitQualifiedIdentifier(identifier);
const normalizedIdentifier = parts.length > 0 ? parts.join(".").toLowerCase() : identifier.toLowerCase();

View File

@ -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;
}