fix(editor): preserve view type in object navigation
This commit is contained in:
parent
39aa506e44
commit
69dd363417
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue