From 3e6b3d53219446ec6e75949e68cdd655124261e2 Mon Sep 17 00:00:00 2001 From: zipg Date: Thu, 6 Aug 2026 20:10:44 +0800 Subject: [PATCH] fix(navigation): use target db for cross-database table jump --- .../src/components/editor/QueryEditor.vue | 22 +++++-- .../src/lib/sql/queryCursorTableTarget.ts | 14 ++++ .../app-tests/queryCursorTableTarget.test.ts | 65 +++++++++++++++++++ 3 files changed, 96 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 3f6f611ec3..2a1ecb9928 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -114,7 +114,7 @@ import { metadataSchemaForConnection, sqlSnippetDatabaseTypeForConnection } from import { usesLocalOnlyEditorCompletionMetadata, usesOnDemandOnlyEditorColumnMetadata } from "@/lib/metadata/completionMetadataPolicy"; import { loadObjectDdl } from "@/lib/metadata/objectDdlCache"; import { loadObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache"; -import { queryContextObjectActions, queryContextObjectRoute, queryTableCandidateAtSqlPosition, resolveQueryContextCandidateDatabase, resolveQueryContextObjectTarget, type QueryContextObjectAction } from "@/lib/sql/queryCursorTableTarget"; +import { queryContextObjectActions, queryContextObjectRoute, queryTableCandidateAtSqlPosition, queryTableNavigationTargetAtSqlPosition, resolveQueryContextCandidateDatabase, resolveQueryContextObjectTarget, type QueryContextObjectAction } from "@/lib/sql/queryCursorTableTarget"; import * as api from "@/lib/backend/api"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { isMacOS } from "@/lib/backend/platform"; @@ -4512,6 +4512,18 @@ onMounted(async () => { const objectSchemaHint = identity.parts.length >= 3 ? identity.schema : identity.parts.length === 1 ? props.schema : undefined; const isRoutineCall = identity.role === "routine_call"; const isRelationColumnList = identity.role === "relation_column_list"; + const relationNavigationTarget = (target: SqlObjectNavigationTarget) => + queryTableNavigationTargetAtSqlPosition( + { + connectionId: props.connectionId!, + database: props.database!, + schema: props.schema, + databaseType: props.databaseType, + sql: doc, + position: pos, + }, + target, + ); // 1. Local table cache (sync). Relation column lists always prefer tables over routines. if (cachedTables.length === 0) { @@ -4520,7 +4532,7 @@ onMounted(async () => { let matchedTable = matchTable(identifier, cachedTables); if (matchedTable) { - emit("clickTable", sqlObjectNavigationTarget(matchedTable)); + emit("clickTable", relationNavigationTarget(matchedTable)); return; } @@ -4581,7 +4593,7 @@ onMounted(async () => { cachedTables = await connectionStore.listCompletionTables(props.connectionId!, props.database!, tableLookupFilter, MAX_COMPLETION_TABLES, props.schema, false, props.schema, props.catalog); matchedTable = matchTable(identifier, cachedTables); if (matchedTable) { - emit("clickTable", sqlObjectNavigationTarget(matchedTable)); + emit("clickTable", relationNavigationTarget(matchedTable)); return; } } else if (!usesLocalOnlyCompletionMetadata() && isRoutineCall) { @@ -4590,7 +4602,7 @@ onMounted(async () => { cachedTables = await connectionStore.listCompletionTables(props.connectionId!, props.database!, tableLookupFilter, 20, props.schema, false, props.schema, props.catalog); matchedTable = matchTable(identifier, cachedTables); if (matchedTable) { - emit("clickTable", sqlObjectNavigationTarget(matchedTable)); + emit("clickTable", relationNavigationTarget(matchedTable)); return; } } @@ -4629,7 +4641,7 @@ onMounted(async () => { const matchedRef = matchTable(identifier, referencedTables); if (matchedRef) { - emit("clickTable", sqlObjectNavigationTarget(matchedRef)); + emit("clickTable", relationNavigationTarget(matchedRef)); return; } const colName = identifierParts[identifierParts.length - 1] ?? identifier; diff --git a/apps/desktop/src/lib/sql/queryCursorTableTarget.ts b/apps/desktop/src/lib/sql/queryCursorTableTarget.ts index 393a118a74..d0beccc3ad 100644 --- a/apps/desktop/src/lib/sql/queryCursorTableTarget.ts +++ b/apps/desktop/src/lib/sql/queryCursorTableTarget.ts @@ -78,6 +78,20 @@ export function queryTableCandidateAtSqlPosition(input: QueryTableCandidateAtPos return { connectionId: input.connectionId, database, schema, tableName }; } +export function queryTableNavigationTargetAtSqlPosition(input: QueryTableCandidateAtPositionInput, target: SqlObjectNavigationTarget): SqlObjectNavigationTarget { + const parts = extractQualifiedIdentifierPartsAt(input.sql, input.position); + if (parts.length < 2) return sqlObjectNavigationTarget(target); + + const candidate = queryTableCandidateAtSqlPosition(input); + if (!candidate) return sqlObjectNavigationTarget(target); + + return sqlObjectNavigationTarget({ + ...target, + database: candidate.database, + schema: candidate.schema, + }); +} + export function resolveQueryContextCandidateDatabase(candidate: QueryCursorTableCandidate, databases: readonly string[]): QueryCursorTableCandidate { const database = databases.find((name) => sameIdentifier(name, candidate.database)); return database && database !== candidate.database ? { ...candidate, database } : candidate; diff --git a/packages/app-tests/queryCursorTableTarget.test.ts b/packages/app-tests/queryCursorTableTarget.test.ts index a9aaaa0b8a..9969a72ee7 100644 --- a/packages/app-tests/queryCursorTableTarget.test.ts +++ b/packages/app-tests/queryCursorTableTarget.test.ts @@ -8,6 +8,7 @@ import { queryContextTargetFromCandidate, queryCursorTableCandidate, queryTableCandidateAtSqlPosition, + queryTableNavigationTargetAtSqlPosition, resolveQueryContextCandidateDatabase, resolveQueryContextObjectTarget, } from "../../apps/desktop/src/lib/sql/queryCursorTableTarget.ts"; @@ -87,6 +88,70 @@ test("builds database-qualified candidates for multi-database non-schema engines }); }); +test("maps qualified relation navigation to database or schema by dialect", () => { + const mysqlSql = "select * from promotion.p_settlement_account"; + assert.deepEqual( + queryTableNavigationTargetAtSqlPosition( + { + connectionId: "conn-1", + database: "nova", + databaseType: "mysql", + sql: mysqlSql, + position: mysqlSql.indexOf("p_settlement_account"), + }, + { name: "p_settlement_account", schema: "promotion", type: "table" }, + ), + { + name: "p_settlement_account", + database: "promotion", + type: "table", + }, + ); + + const postgresSql = "select * from reporting.orders"; + assert.deepEqual( + queryTableNavigationTargetAtSqlPosition( + { + connectionId: "conn-1", + database: "app", + schema: "public", + databaseType: "postgres", + sql: postgresSql, + position: postgresSql.indexOf("orders"), + }, + { name: "orders", schema: "reporting", type: "view" }, + ), + { + name: "orders", + database: "app", + schema: "reporting", + type: "view", + }, + ); +}); + +test("keeps metadata scope for unqualified relation navigation", () => { + const sql = "select * from orders"; + assert.deepEqual( + queryTableNavigationTargetAtSqlPosition( + { + connectionId: "conn-1", + database: "app", + schema: "public", + databaseType: "postgres", + sql, + position: sql.indexOf("orders"), + }, + { name: "Orders", schema: "archive", type: "table" }, + ), + { + name: "Orders", + schema: "archive", + type: "table", + }, + ); +}); + test("builds three-part candidates at an explicit context-menu position", () => { const sql = 'select * from "warehouse"."reporting"."Daily Sales"';