From 63dd0e88b50937302c441bd9c3186a8abb79c9e7 Mon Sep 17 00:00:00 2001 From: zipg Date: Wed, 22 Jul 2026 15:08:44 +0800 Subject: [PATCH] feat(editor): support SQL Server cross-database completion --- .../src/components/editor/QueryEditor.vue | 84 +++++++---- .../database/databaseFeatureSupport.spec.ts | 12 +- .../__tests__/sql/semantic/completion.spec.ts | 18 +++ .../lib/__tests__/sql/semantic/model.spec.ts | 2 +- .../__tests__/sql/semantic/references.spec.ts | 8 ++ .../sql/sqlCompletionLookupTarget.spec.ts | 40 ++++++ .../lib/__tests__/sql/sqlNavigation.spec.ts | 7 + .../lib/database/databaseCapabilitySets.ts | 5 + .../lib/database/databaseFeatureSupport.ts | 6 +- .../src/lib/sql/semantic/completion.ts | 3 + .../src/lib/sql/semantic/diagnostics.ts | 9 +- apps/desktop/src/lib/sql/semantic/model.ts | 7 +- .../src/lib/sql/semantic/references.ts | 18 ++- apps/desktop/src/lib/sql/sqlCompletion.ts | 94 ++++++++---- .../src/lib/sql/sqlCompletionLookupTarget.ts | 15 +- apps/desktop/src/lib/sql/sqlNavigation.ts | 5 +- apps/desktop/src/types/database.ts | 1 + .../tests/live_sqlserver_completion.rs | 135 ++++++++++++++++++ packages/app-tests/sqlCompletion.test.ts | 44 ++++++ 19 files changed, 438 insertions(+), 75 deletions(-) diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 47af8a5a80..a20cae334e 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -71,7 +71,7 @@ import { sqlSemanticTableNameSpansForSyntaxTree } from "@/lib/editor/codemirrorS import { startsQueryEditorRectangularSelection } from "@/lib/editor/queryEditorPointerSelection"; import { LARGE_PASTE_HISTORY_USER_EVENT, normalizeQueryEditorPasteText, recoverableNativePasteSuffix, shouldRecoverLargeTauriPaste } from "@/lib/editor/queryEditorLargePaste"; import type { StatementExecutionMarker } from "@/lib/tabs/tabPresentation"; -import { isSchemaAware, isSingleDatabase, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport"; +import { isSchemaAware, isSingleDatabase, supportsDatabaseSchemaQualifier, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport"; import { metadataSchemaForConnection } from "@/lib/database/jdbcDialect"; import { usesLocalOnlyEditorCompletionMetadata, usesOnDemandOnlyEditorColumnMetadata } from "@/lib/metadata/completionMetadataPolicy"; import { queryContextObjectActions, queryContextObjectRoute, queryTableCandidateAtSqlPosition, resolveQueryContextCandidateDatabase, resolveQueryContextObjectTarget, type QueryContextObjectAction } from "@/lib/sql/queryCursorTableTarget"; @@ -1444,9 +1444,10 @@ function identifierRangeAt(sql: string, pos: number): { from: number; to: number return { from, to, text }; } -function completionCacheKey(table: { name: string; schema?: string | null }) { +function completionCacheKey(table: { name: string; database?: string | null; schema?: string | null }) { const schema = table.schema ?? props.schema; - return schema ? `${schema}.${table.name}` : table.name; + const database = supportsDatabaseSchemaQualifierCompletion() ? table.database : undefined; + return schema ? `${database ? `${database}.` : ""}${schema}.${table.name}` : table.name; } const pendingInsertValueHintColumnLoads = new Set(); @@ -1510,6 +1511,10 @@ function supportsDatabaseQualifierCompletion(): boolean { return !!props.databaseType && !isSchemaAware(props.databaseType) && !isSingleDatabase(props.databaseType); } +function supportsDatabaseSchemaQualifierCompletion(): boolean { + return supportsDatabaseSchemaQualifier(props.databaseType); +} + function usesLocalOnlyCompletionMetadata(): boolean { return usesLocalOnlyEditorCompletionMetadata(props.databaseType); } @@ -1524,30 +1529,35 @@ function allowsOnDemandQualifiedTableCompletion(prefix: string): boolean { return prefix.trim().length >= PRESTO_ON_DEMAND_TABLE_COMPLETION_MIN_PREFIX; } -function completionMetadataTarget(table: { name: string; schema?: string | null }): { database: string; schema?: string } | null { +function completionMetadataTarget(table: { name: string; database?: string | null; schema?: string | null }): { database: string; schema?: string } | null { if (props.database == null) return null; + if (supportsDatabaseSchemaQualifierCompletion() && table.database) { + return { database: table.database, schema: table.schema ?? undefined }; + } if (supportsDatabaseQualifierCompletion() && table.schema) { return { database: table.schema }; } return { database: props.database, schema: table.schema ?? props.schema }; } -function isVirtualCompletionTableReference(table: { name: string; schema?: string | null }): boolean { +function isVirtualCompletionTableReference(table: { name: string; database?: string | null; schema?: string | null }): boolean { return isSqlVirtualTableReference(table, props.databaseType); } -function completionQualifiedTableTarget(completionContext: ReturnType): { name: string; schema: string } | null { +function completionQualifiedTableTarget(completionContext: ReturnType): { name: string; database?: string; schema: string } | null { if (!completionContext.suggestColumns) return null; const parts = completionContext.qualifierParts ?? completionContext.qualifier?.split(".").filter(Boolean) ?? []; if (parts.length < 2) return null; const name = parts[parts.length - 1]; const schema = parts[parts.length - 2]; if (!name || !schema) return null; - return { name, schema }; + const database = supportsDatabaseSchemaQualifierCompletion() && parts.length >= 3 ? parts[parts.length - 3] : undefined; + return { name, database, schema }; } -function completionTablesMatch(left: { name: string; schema?: string | null }, right: { name: string; schema?: string | null }) { +function completionTablesMatch(left: { name: string; database?: string | null; schema?: string | null }, right: { name: string; database?: string | null; schema?: string | null }) { if (left.name.toLowerCase() !== right.name.toLowerCase()) return false; + if (left.database && right.database && left.database.toLowerCase() !== right.database.toLowerCase()) return false; if (!left.schema || !right.schema) return true; return left.schema.toLowerCase() === right.schema.toLowerCase(); } @@ -1565,7 +1575,7 @@ async function findExactSemanticDiagnosticTable(table: SqlTableReference): Promi return remoteMatches.find((item) => completionTablesMatch(item, table)) ?? null; } -async function ensureColumnsForTable(table: { name: string; schema?: string | null }): Promise { +async function ensureColumnsForTable(table: { name: string; database?: string | null; schema?: string | null }): Promise { if (isVirtualCompletionTableReference(table)) return false; const cacheKey = completionCacheKey(table); if (cachedColumnsByTable.has(cacheKey)) return true; @@ -1583,7 +1593,7 @@ function isMissingTableMetadataError(error: unknown) { return message.includes("42s02") || message.includes("1146") || message.includes("doesn't exist") || message.includes("does not exist") || message.includes("unknown table"); } -async function ensureForeignKeysForTable(table: { name: string; schema?: string | null }) { +async function ensureForeignKeysForTable(table: { name: string; database?: string | null; schema?: string | null }) { if (isVirtualCompletionTableReference(table)) return; const cacheKey = completionCacheKey(table); if (cachedForeignKeysByTable.has(cacheKey) || !props.connectionId || props.database == null) return; @@ -1598,7 +1608,7 @@ async function ensureForeignKeysForTable(table: { name: string; schema?: string } } -async function ensureForeignKeysForTables(tables: Array<{ name: string; schema?: string | null }>) { +async function ensureForeignKeysForTables(tables: Array<{ name: string; database?: string | null; schema?: string | null }>) { const seen = new Set(); const uniqueTables = tables.filter((table) => { const key = completionCacheKey(table).toLowerCase(); @@ -2542,6 +2552,7 @@ function buildLocalSqlCompletionResult(completionContext: ReturnType(); if (completionContext.insertTable) { + const insertDatabase = (supportsDatabaseSchemaQualifierCompletion() ? completionContext.insertDatabase : undefined) ?? props.database; const insertSchema = completionContext.insertSchema ?? props.schema; - const insertColumns = usesOracleSessionCompletionColumns(insertSchema) ? [] : connectionStore.lookupLocalCompletionColumns(props.connectionId, props.database, completionContext.insertTable, insertSchema); + const insertColumns = usesOracleSessionCompletionColumns(insertSchema) ? [] : connectionStore.lookupLocalCompletionColumns(props.connectionId, insertDatabase, completionContext.insertTable, insertSchema); if (insertColumns.length > 0) { - columnsByTable.set(insertSchema ? `${insertSchema}.${completionContext.insertTable}` : completionContext.insertTable, insertColumns); + columnsByTable.set(completionCacheKey({ name: completionContext.insertTable, database: completionContext.insertDatabase, schema: insertSchema }), insertColumns); } } @@ -2593,7 +2605,7 @@ function buildLocalSqlCompletionResult(completionContext: ReturnType { - cachedTables = mergeCompletionTables(cachedTables, tables); + const scopedTables = tables.map((table) => ({ ...table, database: table.database ?? tableLookupTarget.database })); + cachedTables = mergeCompletionTables(cachedTables, scopedTables); if (completionContext.suggestTables && completionContext.referencedTables.length > 0) { - void ensureForeignKeysForTables([...completionContext.referencedTables, ...tables.slice(0, MAX_JOIN_FK_PREFETCH_TABLES)]); + void ensureForeignKeysForTables([...completionContext.referencedTables, ...scopedTables.slice(0, MAX_JOIN_FK_PREFETCH_TABLES)]); } }) .catch(() => {}); @@ -2676,10 +2690,11 @@ function scheduleCompletionMetadataRefresh(completionContext: ReturnType { const insertSchema = completionContext.insertSchema ?? props.schema; - cachedColumnsByTable.set(insertSchema ? `${insertSchema}.${insertTable}` : insertTable, columns); + cachedColumnsByTable.set(completionCacheKey({ name: insertTable, database: completionContext.insertDatabase, schema: insertSchema }), columns); }) .catch(() => {}); } @@ -2699,7 +2714,7 @@ function scheduleCompletionMetadataRefresh(completionContext: ReturnType 0) continue; - const cacheKey = refTable.schema ? `${refTable.schema}.${refTable.name}` : refTable.name; + const cacheKey = completionCacheKey(refTable); if (cacheKey === qualifiedColumnCacheKey) continue; if (cachedColumnsByTable.has(cacheKey)) continue; const target = completionMetadataTarget(refTable); @@ -2718,9 +2733,9 @@ function scheduleCompletionMetadataRefresh(completionContext: ReturnType [`${table.schema ?? ""}.${table.name}`.toLowerCase(), index])); + const indexes = new Map(existing.map((table, index) => [`${table.database ?? ""}.${table.schema ?? ""}.${table.name}`.toLowerCase(), index])); for (const table of incoming) { - const key = `${table.schema ?? ""}.${table.name}`.toLowerCase(); + const key = `${table.database ?? ""}.${table.schema ?? ""}.${table.name}`.toLowerCase(); const index = indexes.get(key); if (index == null) { indexes.set(key, merged.length); @@ -2743,10 +2758,11 @@ function withCompletionLatencyBudget(remote: Promise, local: T): Promise { - const local = connectionStore.lookupLocalCompletionTables(connectionId, database, filter, limit, globalSearch ? undefined : schema); + const local = connectionStore.lookupLocalCompletionTables(connectionId, database, filter, limit, globalSearch ? undefined : schema).map((table) => ({ ...table, database: table.database ?? database })); const remote = connectionStore.listCompletionTables(connectionId, database, filter, limit, schema, globalSearch, props.schema).then((tables) => { - cachedTables = mergeCompletionTables(cachedTables, tables); - return tables; + const scopedTables = tables.map((table) => ({ ...table, database: table.database ?? database })); + cachedTables = mergeCompletionTables(cachedTables, scopedTables); + return scopedTables; }); if (local.length === 0) return remote; return withCompletionLatencyBudget(remote, local); @@ -2807,11 +2823,12 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext let insertColumnsByTable = new Map(); if (completionContext.insertTable) { try { - const insertCols = await listCompletionColumnsForEditor(props.connectionId!, props.database!, completionContext.insertTable, completionContext.insertSchema ?? props.schema); + const insertDatabase = (supportsDatabaseSchemaQualifierCompletion() ? completionContext.insertDatabase : undefined) ?? props.database!; + const insertCols = await listCompletionColumnsForEditor(props.connectionId!, insertDatabase, completionContext.insertTable, completionContext.insertSchema ?? props.schema); if (epoch !== completionEpoch) return null; if (insertCols.length > 0) { const insertSchema = completionContext.insertSchema ?? props.schema; - const insertKey = insertSchema ? `${insertSchema}.${completionContext.insertTable}` : completionContext.insertTable; + const insertKey = completionCacheKey({ name: completionContext.insertTable, database: completionContext.insertDatabase, schema: insertSchema }); insertColumnsByTable.set(insertKey, insertCols); } } catch { @@ -2833,6 +2850,7 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext currentDatabase: props.database!, currentSchema: props.schema, supportsDatabaseQualifier: supportsDatabaseQualifierCompletion(), + supportsDatabaseSchemaQualifier: supportsDatabaseSchemaQualifierCompletion(), completionContext, knownDatabases: databaseNames, }); @@ -2842,6 +2860,9 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext ? connectionStore.lookupLocalCompletionTables(props.connectionId!, tableLookupTarget.database, tableLookupTarget.filter, MAX_COMPLETION_TABLES, globalOracleTableSearch ? undefined : tableLookupTarget.schema) : await listCompletionTablesWithLatencyBudget(props.connectionId!, tableLookupTarget.database, tableLookupTarget.filter, MAX_COMPLETION_TABLES, tableLookupTarget.schema, globalOracleTableSearch) : cachedTables; + if (localOnlyMetadata && tables.length === 0 && supportsDatabaseSchemaQualifierCompletion() && (completionContext.qualifierParts?.length ?? 0) >= 2 && allowsOnDemandQualifiedTableCompletion(completionContext.prefix)) { + tables = await listCompletionTablesWithLatencyBudget(props.connectionId!, tableLookupTarget.database, tableLookupTarget.filter, PRESTO_ON_DEMAND_TABLE_COMPLETION_LIMIT, tableLookupTarget.schema); + } if (epoch !== completionEpoch) return null; const shouldLoadObjects = shouldLoadCompletionObjects(completionContext); @@ -2942,7 +2963,7 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext refs.map(async (refTable) => { if (isVirtualCompletionTableReference(refTable)) return; if (refTable.columns && refTable.columns.length > 0) return; - const cacheKey = refTable.schema ? `${refTable.schema}.${refTable.name}` : refTable.name; + const cacheKey = completionCacheKey(refTable); if (cachedColumnsByTable.has(cacheKey)) return; try { const target = completionMetadataTarget(refTable); @@ -2986,7 +3007,7 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext ); continue; } - const cacheKey = refTable.schema ? `${refTable.schema}.${refTable.name}` : refTable.name; + const cacheKey = completionCacheKey(refTable); const cached = cachedColumnsByTable.get(cacheKey); if (cached) { columnsByTable.set(cacheKey, cached); @@ -3797,14 +3818,15 @@ onMounted(async () => { }> = []; for (const refTable of tablesToCheck) { - const cacheKey = refTable.schema ? `${refTable.schema}.${refTable.name}` : refTable.name; + const cacheKey = completionCacheKey(refTable); // Use persistent column cache; fetch only if missing let cols = cachedColumnsByTable.get(cacheKey); if (!cols) { try { - const columnSchema = refTable.schema ?? props.schema; - cols = await listCompletionColumnsForEditor(props.connectionId!, props.database!, refTable.name, columnSchema); + const target = completionMetadataTarget(refTable); + if (!target) continue; + cols = await listCompletionColumnsForEditor(props.connectionId!, target.database, refTable.name, target.schema); cachedColumnsByTable.set(cacheKey, cols); } catch { continue; diff --git a/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts b/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts index c1d64e44ab..340f27177c 100644 --- a/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { connectionNamespaceCreationTarget, databaseNodeNamespaceCreationTarget } from "@/lib/database/databaseNamespaceCreation"; import { editableDatabasePropertyGroups, editableSchemaPropertyGroups } from "@/lib/database/databasePropertyEditing"; import { buildGetDatabaseCommentSql } from "@/lib/database/dbAdminSql"; -import { isSchemaAware, supportsSqlInListPaste, supportsTransaction } from "@/lib/database/databaseFeatureSupport"; +import { isSchemaAware, supportsDatabaseSchemaQualifier, supportsSqlInListPaste, supportsTransaction } from "@/lib/database/databaseFeatureSupport"; describe("schema awareness", () => { it("keeps SQLite database aliases separate from schema-capable databases", () => { @@ -10,6 +10,16 @@ describe("schema awareness", () => { }); }); +describe("database and schema qualifiers", () => { + it.each(["sqlserver", "trino", "prestosql"] as const)("supports three-part object names for %s", (databaseType) => { + expect(supportsDatabaseSchemaQualifier(databaseType)).toBe(true); + }); + + it.each(["mysql", "postgres", "oracle", "snowflake"] as const)("does not widen unverified three-part completion for %s", (databaseType) => { + expect(supportsDatabaseSchemaQualifier(databaseType)).toBe(false); + }); +}); + describe("supportsTransaction", () => { it("returns true for supported database types", () => { expect(supportsTransaction("postgres")).toBe(true); diff --git a/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts b/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts index 9bd6776dcd..43af356306 100644 --- a/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts @@ -35,6 +35,24 @@ function semanticCompletion(markedSql: string, input: Partial { + it("isolates SQL Server columns for database-qualified tables with the same schema and name", () => { + const columnsByTable = new Map([ + ["DatabaseA.OUT.orders", [{ name: "source_marker", table: "orders", schema: "OUT" }]], + ["DatabaseB.OUT.orders", [{ name: "target_marker", table: "orders", schema: "OUT" }]], + ]); + const { context, items } = semanticCompletion("SELECT * FROM [DatabaseA].[OUT].[orders] a LEFT JOIN [DatabaseB].[OUT].[orders] b ON b.|", { columnsByTable }, { databaseType: "sqlserver", dialect: "sqlserver" }); + + expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ name: "orders", database: "DatabaseB", schema: "OUT", alias: "b" })])); + expect(items.filter((item) => item.type === "column").map((item) => item.label)).toEqual(["target_marker"]); + }); + + it("resolves columns after a full SQL Server database.schema.table qualifier", () => { + const columnsByTable = new Map([["DatabaseB.OUT.orders", [{ name: "target_marker", table: "orders", schema: "OUT" }]]]); + const { context, items } = semanticCompletion("SELECT * FROM [DatabaseB].[OUT].[orders] WHERE [DatabaseB].[OUT].[orders].|", { columnsByTable }, { databaseType: "sqlserver", dialect: "sqlserver" }); + + expect(context.qualifierParts).toEqual(["DatabaseB", "OUT", "orders"]); + expect(items.filter((item) => item.type === "column").map((item) => item.label)).toEqual(["target_marker"]); + }); it.each([ ["MySQL ORDER BY", "SELECT * FROM t LIMIT 100 or|", "mysql", "mysql", "ORDER BY"], ["PostgreSQL ON CONFLICT", "INSERT INTO t VALUES (1) on|", "postgres", "postgres", "ON CONFLICT"], diff --git a/apps/desktop/src/lib/__tests__/sql/semantic/model.spec.ts b/apps/desktop/src/lib/__tests__/sql/semantic/model.spec.ts index fa2d5f71b6..f936d9e664 100644 --- a/apps/desktop/src/lib/__tests__/sql/semantic/model.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/semantic/model.spec.ts @@ -226,7 +226,7 @@ describe("sqlSemanticModel baseline fixtures", () => { const { sql, cursor } = sqlFixtureCursor("SELECT * FROM [ServerOne].[AppDb].[dbo].[Users] U WHERE u.na|"); const model = buildSqlSemanticModel(sql, cursor, { databaseType: "sqlserver" }); - expect(model.rowSources[0]).toEqual(expect.objectContaining({ name: "Users", qualifierParts: ["ServerOne", "AppDb", "dbo"], alias: "U" })); + expect(model.rowSources[0]).toEqual(expect.objectContaining({ name: "Users", qualifierParts: ["ServerOne", "AppDb", "dbo"], alias: "U", metadataTarget: { database: "AppDb", schema: "dbo", table: "Users" } })); expect(model.cursorIntent.kind).toBe("alias_column"); expect(model.cursorIntent.qualifierParts).toEqual(["u"]); }); diff --git a/apps/desktop/src/lib/__tests__/sql/semantic/references.spec.ts b/apps/desktop/src/lib/__tests__/sql/semantic/references.spec.ts index ac93fcc81c..5fa32b2880 100644 --- a/apps/desktop/src/lib/__tests__/sql/semantic/references.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/semantic/references.spec.ts @@ -51,4 +51,12 @@ describe("sqlSemanticReferences shared consumers", () => { expect(diagnosticRefs).toEqual(expect.arrayContaining([expect.objectContaining({ name: "recent_orders", alias: "ro", columns: ["id", "total"] })])); expect(navigationTarget).toEqual(expect.objectContaining({ name: "recent_orders", alias: "ro", columns: ["id", "total"] })); }); + + it("resolves full SQL Server table navigation to the qualified database", () => { + const { sql, cursor } = sqlFixtureCursor("SELECT * FROM [DatabaseA].[OUT].[orders] a JOIN [DatabaseB].[OUT].[orders] b ON a.id = b.id|"); + const model = buildSqlSemanticModel(sql, cursor, { databaseType: "sqlserver" }); + const target = resolveSqlSemanticNavigationTarget(model, ["DatabaseB", "OUT", "orders"]); + + expect(target).toEqual(expect.objectContaining({ name: "orders", database: "DatabaseB", schema: "OUT", alias: "b" })); + }); }); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlCompletionLookupTarget.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlCompletionLookupTarget.spec.ts index 5e12df62ec..e8b677a50c 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlCompletionLookupTarget.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlCompletionLookupTarget.spec.ts @@ -59,6 +59,46 @@ describe("sqlCompletionLookupTarget", () => { }); }); + it.each(["sqlserver", "trino", "prestosql"])("routes %s three-part table completion to the qualified database or catalog", () => { + const target = resolveSqlCompletionTableLookupTarget({ + currentDatabase: "default_db", + currentSchema: "dbo", + supportsDatabaseQualifier: false, + supportsDatabaseSchemaQualifier: true, + knownDatabases: ["Reporting"], + completionContext: { + qualifier: "reporting.OUT", + qualifierParts: ["reporting", "OUT"], + prefix: "ord", + suggestTables: true, + }, + }); + + expect(target).toEqual({ + database: "Reporting", + schema: "OUT", + filter: "ord", + qualifierDatabase: "Reporting", + }); + }); + + it("keeps PostgreSQL schema completion in the current database", () => { + const target = resolveSqlCompletionTableLookupTarget({ + currentDatabase: "app", + currentSchema: "public", + supportsDatabaseQualifier: false, + supportsDatabaseSchemaQualifier: false, + completionContext: { + qualifier: "sales", + qualifierParts: ["sales"], + prefix: "ord", + suggestTables: true, + }, + }); + + expect(target).toEqual({ database: "app", schema: "sales", filter: "ord" }); + }); + it("uses the current schema for unqualified table completion", () => { const target = resolveSqlCompletionTableLookupTarget({ currentDatabase: "app", diff --git a/apps/desktop/src/lib/__tests__/sql/sqlNavigation.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlNavigation.spec.ts index 1ebf882cdd..fa02aa336a 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlNavigation.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlNavigation.spec.ts @@ -76,6 +76,13 @@ describe("matchTable", () => { expect(matchTable("catalog.maac00.accounts", [table])).toBe(table); }); + it("uses the database qualifier when scoped tables provide it", () => { + const databaseA = { database: "DatabaseA", schema: "OUT", name: "orders" }; + const databaseB = { database: "DatabaseB", schema: "OUT", name: "orders" }; + + expect(matchTable("[DatabaseB].[OUT].[orders]", [databaseA, databaseB])).toBe(databaseB); + }); + it("matches quoted schema-qualified table identifiers", () => { const table = { schema: "MAAC00", name: "Accounts" }; diff --git a/apps/desktop/src/lib/database/databaseCapabilitySets.ts b/apps/desktop/src/lib/database/databaseCapabilitySets.ts index 1c1ddaf2e4..06f98496bf 100644 --- a/apps/desktop/src/lib/database/databaseCapabilitySets.ts +++ b/apps/desktop/src/lib/database/databaseCapabilitySets.ts @@ -37,6 +37,11 @@ export const SCHEMA_AWARE_TYPES = new Set([ "duckdb", ]); +// Engines where an object can be addressed as database/catalog.schema.table. +// Keep this narrower than SCHEMA_AWARE_TYPES: PostgreSQL, for example, cannot +// query another database through a three-part name on the same connection. +export const DATABASE_SCHEMA_QUALIFIED_TYPES = new Set(["sqlserver", "trino", "prestosql"]); + export const SINGLE_DATABASE_TYPES = new Set(["oracle", "dameng", "firebird", "oceanbase-oracle", "access", "questdb"]); export const CLEARABLE_QUERY_SCHEMA_TYPES = new Set(["oracle", "dameng", "gaussdb", "oceanbase-oracle"]); diff --git a/apps/desktop/src/lib/database/databaseFeatureSupport.ts b/apps/desktop/src/lib/database/databaseFeatureSupport.ts index 51c32eb661..21027a6a86 100644 --- a/apps/desktop/src/lib/database/databaseFeatureSupport.ts +++ b/apps/desktop/src/lib/database/databaseFeatureSupport.ts @@ -1,12 +1,16 @@ import type { ConnectionConfig, DatabaseType, TreeNodeType } from "@/types/database"; import { supportsDatabaseFeature } from "@/lib/database/databaseDriverManifest"; import { canEditTableStructure } from "@/lib/table/tableStructureCapabilities"; -import { CLEARABLE_QUERY_SCHEMA_TYPES, DATABASE_OBJECT_TREE_TYPES, FETCH_FIRST_TYPES, PG_LIKE_STRUCTURE_TYPES, SCHEMA_AWARE_TYPES, SINGLE_DATABASE_TYPES, TREE_SCHEMA_TYPES } from "@/lib/database/databaseCapabilitySets"; +import { CLEARABLE_QUERY_SCHEMA_TYPES, DATABASE_OBJECT_TREE_TYPES, DATABASE_SCHEMA_QUALIFIED_TYPES, FETCH_FIRST_TYPES, PG_LIKE_STRUCTURE_TYPES, SCHEMA_AWARE_TYPES, SINGLE_DATABASE_TYPES, TREE_SCHEMA_TYPES } from "@/lib/database/databaseCapabilitySets"; export function isSchemaAware(dbType?: DatabaseType): boolean { return !!dbType && SCHEMA_AWARE_TYPES.has(dbType); } +export function supportsDatabaseSchemaQualifier(dbType?: DatabaseType): boolean { + return !!dbType && DATABASE_SCHEMA_QUALIFIED_TYPES.has(dbType); +} + /** * Doris-family engines that support multi-catalog federation (`SHOW CATALOGS`): * Doris (incl. SelectDB) and StarRocks. Manticore Search shares the MySQL code diff --git a/apps/desktop/src/lib/sql/semantic/completion.ts b/apps/desktop/src/lib/sql/semantic/completion.ts index 6d86e9d5d9..12c985c266 100644 --- a/apps/desktop/src/lib/sql/semantic/completion.ts +++ b/apps/desktop/src/lib/sql/semantic/completion.ts @@ -18,6 +18,7 @@ export function sqlSemanticReferencedTables(model: SqlSemanticModel): SqlComplet .filter((source) => source.kind !== "unknown") .map((source) => ({ name: source.name, + database: source.metadataTarget?.database, schema: source.qualifierParts[source.qualifierParts.length - 1], alias: source.alias, columns: source.columns, @@ -180,6 +181,7 @@ export function sqlCompletionContextFromSemantic(model: SqlSemanticModel, base: const qualifier = model.cursorIntent.qualifierParts.length > 0 ? model.cursorIntent.qualifierParts.join(".") : undefined; const referencedTables = sqlSemanticReferencedTables(model); const mutationTarget = semanticMutationTarget(model); + const mutationDatabase = mutationTarget?.metadataTarget?.database; const mutationSchema = mutationTarget?.qualifierParts[mutationTarget.qualifierParts.length - 1]; const suggestTables = scope.kind === "table" || scope.kind === "schema" || scope.kind === "catalog"; const suggestColumns = scope.kind === "columns"; @@ -203,6 +205,7 @@ export function sqlCompletionContextFromSemantic(model: SqlSemanticModel, base: selectAliases: projectionAliases.length > 0 ? projectionAliases : base.selectAliases, referencedTables: referencedTables.length > 0 ? referencedTables : base.referencedTables, insertTable: model.cursorIntent.kind === "insert_column" ? mutationTarget?.name : base.insertTable, + insertDatabase: model.cursorIntent.kind === "insert_column" ? mutationDatabase : base.insertDatabase, insertSchema: model.cursorIntent.kind === "insert_column" ? mutationSchema : base.insertSchema, updateTarget: model.cursorIntent.kind === "update_column" && mutationTarget ? { table: mutationTarget.name, schema: mutationSchema } : base.updateTarget, deleteTarget: model.cursorIntent.kind === "delete_target" && mutationTarget ? { table: mutationTarget.name, schema: mutationSchema } : base.deleteTarget, diff --git a/apps/desktop/src/lib/sql/semantic/diagnostics.ts b/apps/desktop/src/lib/sql/semantic/diagnostics.ts index f987bbce3f..638ec3359b 100644 --- a/apps/desktop/src/lib/sql/semantic/diagnostics.ts +++ b/apps/desktop/src/lib/sql/semantic/diagnostics.ts @@ -255,6 +255,7 @@ function tableLookupFor(tables: SqlTableReference[]): Map !!key) : [table.name, ...keysWithTableName(columnsByTable, table.name)]; for (const key of keys) { const normalizedKey = normalizeName(key); const columns = columnsByTable.get(key) ?? columnsByTable.get(normalizedKey); @@ -321,12 +322,12 @@ function rangesIntersect(left: SqlSemanticDiagnosticVisibleRange, right: SqlSema return left.from < right.to && right.from < left.to; } -export function tableReferenceKey(table: Pick): string { - return normalizeName(table.schema ? `${table.schema}.${table.name}` : table.name); +export function tableReferenceKey(table: Pick): string { + return normalizeName(table.schema ? `${table.database ? `${table.database}.` : ""}${table.schema}.${table.name}` : table.name); } function displayTableName(table: SqlTableReference): string { - return table.schema ? `${table.schema}.${table.name}` : table.name; + return table.schema ? `${table.database ? `${table.database}.` : ""}${table.schema}.${table.name}` : table.name; } function isCursorAfterTableTrigger(sql: string, cursor: number): boolean { diff --git a/apps/desktop/src/lib/sql/semantic/model.ts b/apps/desktop/src/lib/sql/semantic/model.ts index 636847d62f..d85c3b8a2a 100644 --- a/apps/desktop/src/lib/sql/semantic/model.ts +++ b/apps/desktop/src/lib/sql/semantic/model.ts @@ -409,6 +409,7 @@ function parseTableSource(state: ParseState, nameIndex: number, introducer: stri columns: cte?.columns ? mergeColumnAliases(cte.columns, alias.columns) : undefined, columnAliases: alias.columns, metadataTarget: { + database: qualifierParts.length >= 2 ? qualifierParts[qualifierParts.length - 2] : undefined, schema: qualifierParts[qualifierParts.length - 1], table: name, }, @@ -640,7 +641,11 @@ function buildCursorIntent(tokens: readonly SqlSemanticToken[], cursor: number, if ( trailing.qualifierParts.length > 0 && - (previous === "from" || previous === "join" || TABLE_INTRODUCERS.has(previous) || TABLE_INTRODUCERS.has(wordBeforeReplacement) || (!!targetSource && !targetSource.alias && TABLE_INTRODUCERS.has(wordBeforePosition(tokens, targetSource.sourceSpan.start)))) + (previous === "from" || + previous === "join" || + TABLE_INTRODUCERS.has(previous) || + TABLE_INTRODUCERS.has(wordBeforeReplacement) || + (!!targetSource && !targetSource.alias && trailing.replacementRange.start <= targetSource.sourceSpan.end + 1 && TABLE_INTRODUCERS.has(wordBeforePosition(tokens, targetSource.sourceSpan.start)))) ) { const role = dialect.qualifierRole(trailing.qualifierParts, "table"); return { kind: role === "catalog" ? "catalog" : "table", prefix: trailing.prefix, replacementRange: trailing.replacementRange, qualifierParts: trailing.qualifierParts, expectedObjectKinds: ["table", "view"], confidence: "medium" }; diff --git a/apps/desktop/src/lib/sql/semantic/references.ts b/apps/desktop/src/lib/sql/semantic/references.ts index 3918407549..155d9684ae 100644 --- a/apps/desktop/src/lib/sql/semantic/references.ts +++ b/apps/desktop/src/lib/sql/semantic/references.ts @@ -10,6 +10,7 @@ export type SqlSemanticTableReference = SqlTableReference & { export interface SqlSemanticNavigationTarget { name: string; + database?: string; schema?: string; alias?: string; columns?: string[]; @@ -51,12 +52,16 @@ function sourceSchema(source: SqlSemanticRowSource): string | undefined { return source.metadataTarget?.schema ?? source.qualifierParts[source.qualifierParts.length - 1]; } +function sourceDatabase(source: SqlSemanticRowSource): string | undefined { + return source.metadataTarget?.database; +} + function sourceTableName(source: SqlSemanticRowSource): string { return source.metadataTarget?.table ?? source.name; } function sourceReferenceKey(source: SqlSemanticTableReference): string { - return [normalized(source.schema), normalized(source.name), normalized(source.alias), source.span.start_line, source.span.start_column].join(":"); + return [normalized(source.database), normalized(source.schema), normalized(source.name), normalized(source.alias), source.span.start_line, source.span.start_column].join(":"); } function existingScopeId(analysis: SqlReferenceAnalysis): number { @@ -72,6 +77,7 @@ export function sqlSemanticTableReferences(model: SqlSemanticModel, scopeId = 0) const schema = sourceSchema(source); return { name: sourceTableName(source), + database: sourceDatabase(source), schema, alias: source.alias, span: semanticSpanToSqlTextSpan(model.sql, span), @@ -95,11 +101,13 @@ export function mergeSqlSemanticReferenceAnalysis(analysis: SqlReferenceAnalysis for (const table of semanticTables) { const existing = [...merged.values()].find((candidate) => { if (normalized(candidate.name) !== normalized(table.name)) return false; + if (candidate.database && table.database && normalized(candidate.database) !== normalized(table.database)) return false; if (candidate.alias && table.alias && normalized(candidate.alias) !== normalized(table.alias)) return false; if (candidate.schema && table.schema && normalized(candidate.schema) !== normalized(table.schema)) return false; return true; }); if (existing) { + existing.database = existing.database ?? table.database; existing.schema = existing.schema ?? table.schema; existing.alias = existing.alias ?? table.alias; existing.columns = existing.columns ?? table.columns; @@ -118,6 +126,7 @@ export function mergeSqlSemanticReferenceAnalysis(analysis: SqlReferenceAnalysis export function sqlSemanticCompletionReferenceTables(model: SqlSemanticModel): SqlCompletionReferencedTable[] { return sqlSemanticTableReferences(model).map((table) => ({ name: table.name, + database: table.database ?? undefined, schema: table.schema ?? undefined, alias: table.alias ?? undefined, columns: table.columns, @@ -129,15 +138,17 @@ export function resolveSqlSemanticNavigationTarget(model: SqlSemanticModel, iden const normalizedParts = identifierParts.map(normalized).filter(Boolean); const name = normalizedParts[normalizedParts.length - 1]; const qualifier = normalizedParts.length > 1 ? normalizedParts[normalizedParts.length - 2] : undefined; + const database = normalizedParts.length > 2 ? normalizedParts[normalizedParts.length - 3] : undefined; const source = model.rowSources.find((candidate) => { const candidateName = normalized(sourceTableName(candidate)); const candidateAlias = normalized(candidate.alias); + const candidateDatabase = normalized(sourceDatabase(candidate)); const candidateSchema = normalized(sourceSchema(candidate)); if (qualifier) { - if (candidateAlias && candidateAlias === qualifier) return true; - return candidateName === name && (!candidateSchema || candidateSchema === qualifier); + if (!database && candidateAlias && candidateAlias === qualifier) return true; + return candidateName === name && (!candidateSchema || candidateSchema === qualifier) && (!database || !candidateDatabase || candidateDatabase === database); } return candidateName === name || candidateAlias === name; }) ?? null; @@ -146,6 +157,7 @@ export function resolveSqlSemanticNavigationTarget(model: SqlSemanticModel, iden const schema = sourceSchema(source); return { name: sourceTableName(source), + database: sourceDatabase(source), schema, alias: source.alias, columns: source.columns, diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index f851d60a52..02aca1a84c 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -1255,6 +1255,7 @@ function sqlAliasKeywordWords(...sources: Array): string[] { export interface SqlCompletionTable { name: string; + database?: string; schema?: string; type?: SqlObjectNavigationType; detail?: string; @@ -1312,6 +1313,7 @@ export type SqlKeywordCase = "preserve" | "upper" | "lower"; export interface SqlCompletionReferencedTable { name: string; + database?: string; schema?: string; alias?: string; columns?: string[]; @@ -1338,6 +1340,7 @@ export interface SqlCompletionContext { selectAliases: string[]; referencedTables: SqlCompletionReferencedTable[]; insertTable?: string; + insertDatabase?: string; insertSchema?: string; statementKind: SqlStatementKind; tableTriggerWord?: string; @@ -1967,6 +1970,7 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet selectAliases: prioritizeSelectAliases ? extractSelectAliases(fullStatement) : [], referencedTables, insertTable: insertInfo?.table, + insertDatabase: insertInfo?.database, insertSchema: insertInfo?.schema, statementKind, tableTriggerWord: lastWord || undefined, @@ -2115,6 +2119,15 @@ function parseTrailingIdentifierPart(input: string, endExclusive: number): { sta return { start, raw: input.slice(start, endExclusive) }; } + if (tailChar === "]") { + let start = end - 1; + while (start >= 0) { + if (input[start] === "[") return { start, raw: input.slice(start, endExclusive) }; + start -= 1; + } + return null; + } + let start = end; while (start >= 0 && /[A-Za-z0-9_$@]/.test(input[start] ?? "")) start -= 1; start += 1; @@ -2305,19 +2318,24 @@ function detectComparisonLeftColumn(beforeCursor: string): string | undefined { return match?.[1]; } -function detectInsertColumnListContext(beforeCursor: string): { table: string; schema?: string } | null { +function detectInsertColumnListContext(beforeCursor: string): { table: string; database?: string; schema?: string } | null { // Keep quoted identifiers intact so schema/table targets resolve to their // real names instead of placeholder string contents. const cleaned = beforeCursor.replace(/'[^']*'/g, "''"); - const identifier = '(?:"[^"]+"|`[^`]+`|[A-Za-z_][\\w$]*)'; + const identifier = '(?:"[^"]+"|`[^`]+`|\\[[^\\]]+\\]|[A-Za-z_][\\w$]*)'; const qualifiedIdentifier = `${identifier}(?:\\.${identifier}){0,2}`; const match = new RegExp(`\\binsert\\s+into\\s+(${qualifiedIdentifier})\\s*\\([^)]*$`, "i").exec(cleaned); if (!match) return null; const fullTable = match[1]; if (!fullTable) return null; - const [first, second] = splitQualifiedName(fullTable); - if (second) return { table: second, schema: first! }; - return { table: first! }; + const parts = splitQualifiedNameParts(fullTable); + const table = parts[parts.length - 1]; + if (!table) return null; + return { + table, + database: parts.length >= 3 ? parts[parts.length - 3] : undefined, + schema: parts.length >= 2 ? parts[parts.length - 2] : undefined, + }; } function detectUpdateCompletionContext(beforeCursor: string): { target: { table: string; schema?: string }; afterTarget: boolean; inSetClause: boolean; afterSetAssignments: boolean } | null { @@ -2576,7 +2594,8 @@ function extractReferencedTables(sql: string): SqlCompletionReferencedTable[] { ]); // STRAIGHT_JOIN is a standalone MySQL table introducer, not a modifier followed by JOIN. - const pattern = /\b(?:from|join|straight_join|update|apply)\s+((?:"[^"]+"|`[^`]+`|[^\s,;()]+)(?:\.(?:"[^"]+"|`[^`]+`|[^\s,;()]+))?)(?:\s+(?:as\s+)?([A-Za-z_][\w$]*))?/gi; + const identifier = '(?:"[^"]+"|`[^`]+`|\\[[^\\]]+\\]|[A-Za-z_][\\w$@#]*)'; + const pattern = new RegExp(`\\b(?:from|join|straight_join|update|apply)\\s+(${identifier}(?:\\.${identifier}){0,3})(?:\\s+(?:as\\s+)?([A-Za-z_][\\w$]*))?`, "gi"); const referenced: SqlCompletionReferencedTable[] = []; let match: RegExpExecArray | null; while ((match = pattern.exec(sql)) !== null) { @@ -2585,7 +2604,7 @@ function extractReferencedTables(sql: string): SqlCompletionReferencedTable[] { if (alias && ALIAS_BLACKLIST.has(alias.toLowerCase())) { pattern.lastIndex = match.index + match[0].length - alias.length; } - const quotedName = !!rawName && (rawName.startsWith('"') || rawName.startsWith("`")); + const quotedName = !!rawName && (rawName.startsWith('"') || rawName.startsWith("`") || rawName.startsWith("[")); if (!quotedName && rawName && ALIAS_BLACKLIST.has(rawName.toLowerCase())) continue; // Filter out SQL keywords that accidentally matched as aliases const cleanAlias = alias && !ALIAS_BLACKLIST.has(alias.toLowerCase()) ? alias : undefined; @@ -2593,9 +2612,15 @@ function extractReferencedTables(sql: string): SqlCompletionReferencedTable[] { referenced.push({ name: unquoteIdentifier(rawName), alias: cleanAlias }); continue; } - const [first, second] = splitQualifiedName(rawName); - if (!first) continue; - const table = second ? { schema: first, name: second, alias: cleanAlias } : { name: first, alias: cleanAlias }; + const parts = splitQualifiedNameParts(rawName); + const name = parts[parts.length - 1]; + if (!name) continue; + const table: SqlCompletionReferencedTable = { + name, + database: parts.length >= 3 ? parts[parts.length - 3] : undefined, + schema: parts.length >= 2 ? parts[parts.length - 2] : undefined, + alias: cleanAlias, + }; referenced.push(table); } return referenced; @@ -2848,24 +2873,33 @@ function splitTopLevel(text: string, separator: string): string[] { } function splitQualifiedName(input: string): [string | undefined, string | undefined] { + const unquoted = splitQualifiedNameParts(input); + if (unquoted.length >= 2) return [unquoted[unquoted.length - 2], unquoted[unquoted.length - 1]]; + return [unquoted[0], undefined]; +} + +function splitQualifiedNameParts(input: string): string[] { const parts: string[] = []; let current = ""; let inDoubleQuote = false; let inBacktick = false; + let inBracket = false; for (let i = 0; i < input.length; i++) { const ch = input[i]; - if (ch === '"' && !inBacktick) { + if (ch === '"' && !inBacktick && !inBracket) { inDoubleQuote = !inDoubleQuote; current += ch; continue; } - if (ch === "`" && !inDoubleQuote) { + if (ch === "`" && !inDoubleQuote && !inBracket) { inBacktick = !inBacktick; current += ch; continue; } - if (ch === "." && !inDoubleQuote && !inBacktick) { + if (ch === "[" && !inDoubleQuote && !inBacktick) inBracket = true; + if (ch === "]" && inBracket) inBracket = false; + if (ch === "." && !inDoubleQuote && !inBacktick && !inBracket) { parts.push(current.trim()); current = ""; } else { @@ -2874,15 +2908,11 @@ function splitQualifiedName(input: string): [string | undefined, string | undefi } if (current.trim()) parts.push(current.trim()); - const unquoted = parts.map((p) => unquoteIdentifier(p)).filter(Boolean); - // For three-part names, the completion model can carry schema.table today; - // keep the table as the final segment instead of accidentally using catalog.schema. - if (unquoted.length >= 2) return [unquoted[unquoted.length - 2], unquoted[unquoted.length - 1]]; - return [unquoted[0], undefined]; + return parts.map((p) => unquoteIdentifier(p)).filter(Boolean); } function unquoteIdentifier(value: string): string { - if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("`") && value.endsWith("`"))) { + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("`") && value.endsWith("`")) || (value.startsWith("[") && value.endsWith("]"))) { return value.slice(1, -1); } return value; @@ -3585,11 +3615,12 @@ function columnsForInsertTarget(context: SqlCompletionContext, columnsByTable: M if (!context.insertTable) return []; const tableKey = normalizeIdentifierPart(context.insertTable); const schemaKey = context.insertSchema ? normalizeIdentifierPart(context.insertSchema) : undefined; - const qualifiedKey = schemaKey ? normalizeCompletionKey(`${context.insertSchema}.${context.insertTable}`) : undefined; + const databaseKey = context.insertDatabase ? normalizeIdentifierPart(context.insertDatabase) : undefined; + const qualifiedKey = schemaKey ? normalizeCompletionKey(`${context.insertDatabase ? `${context.insertDatabase}.` : ""}${context.insertSchema}.${context.insertTable}`) : undefined; return collectCompletionColumns(columnsByTable).filter((column) => { if (normalizeIdentifierPart(column.table) !== tableKey) return false; if (!schemaKey) return true; - if (column.schema && normalizeIdentifierPart(column.schema) === schemaKey) return true; + if (!databaseKey && column.schema && normalizeIdentifierPart(column.schema) === schemaKey) return true; return !!qualifiedKey && normalizeCompletionKey(column.key) === qualifiedKey; }); } @@ -3679,33 +3710,36 @@ function hasMatchingReferencedColumnPrefix(context: SqlCompletionContext, column return context.referencedTables.some((table) => columnsForReferencedTable(table, columnsByTable).some((column) => matchesPrefix(column.name, context.prefix))); } -function qualifiedTableTargetFromContext(context: SqlCompletionContext): { schema: string; table: string } | null { +function qualifiedTableTargetFromContext(context: SqlCompletionContext): { database?: string; schema: string; table: string } | null { const parts = context.qualifierParts ?? context.qualifier?.split(".").filter(Boolean) ?? []; if (parts.length < 2) return null; const table = parts[parts.length - 1]; const schema = parts[parts.length - 2]; if (!schema || !table) return null; - return { schema, table }; + const database = parts.length >= 3 ? parts[parts.length - 3] : undefined; + return { database, schema, table }; } -function referencedTableMatchesColumnQualifier(table: SqlCompletionReferencedTable, qualifier: string, qualifierLower: string, qualifiedTarget: { schema: string; table: string } | null): boolean { +function referencedTableMatchesColumnQualifier(table: SqlCompletionReferencedTable, qualifier: string, qualifierLower: string, qualifiedTarget: { database?: string; schema: string; table: string } | null): boolean { if (table.alias === qualifier || table.alias?.toLowerCase() === qualifierLower) return true; if (table.name === qualifier || table.name.toLowerCase() === qualifierLower) return true; if (!qualifiedTarget) return false; if (normalizeIdentifierPart(table.name) !== normalizeIdentifierPart(qualifiedTarget.table)) return false; + if (table.database && qualifiedTarget.database && normalizeIdentifierPart(table.database) !== normalizeIdentifierPart(qualifiedTarget.database)) return false; return !table.schema || normalizeIdentifierPart(table.schema) === normalizeIdentifierPart(qualifiedTarget.schema); } function columnMatchesReferencedTable(column: SqlCompletionColumn & { key: string }, table: SqlCompletionReferencedTable): boolean { if (normalizeIdentifierPart(column.table) !== normalizeIdentifierPart(table.name)) return false; if (!table.schema) return true; - return columnMatchesQualifiedTable(column, { schema: table.schema, table: table.name }); + return columnMatchesQualifiedTable(column, { database: table.database, schema: table.schema, table: table.name }); } -function columnMatchesQualifiedTable(column: SqlCompletionColumn & { key: string }, target: { schema: string; table: string }): boolean { +function columnMatchesQualifiedTable(column: SqlCompletionColumn & { key: string }, target: { database?: string; schema: string; table: string }): boolean { if (normalizeIdentifierPart(column.table) !== normalizeIdentifierPart(target.table)) return false; - if (column.schema && normalizeIdentifierPart(column.schema) === normalizeIdentifierPart(target.schema)) return true; - return normalizeCompletionKey(column.key) === normalizeCompletionKey(`${target.schema}.${target.table}`); + if (!target.database && column.schema && normalizeIdentifierPart(column.schema) === normalizeIdentifierPart(target.schema)) return true; + const key = `${target.database ? `${target.database}.` : ""}${target.schema}.${target.table}`; + return normalizeCompletionKey(column.key) === normalizeCompletionKey(key); } function normalizeCompletionKey(key: string): string { @@ -3769,7 +3803,7 @@ function buildJoinConditionItems(context: SqlCompletionContext, columnsByTable: } function columnsForReferencedTable(table: SqlCompletionReferencedTable, columnsByTable: Map): SqlCompletionColumn[] { - const keys = table.schema ? [`${table.schema}.${table.name}`, table.name] : [table.name]; + const keys = table.schema ? [table.database ? `${table.database}.${table.schema}.${table.name}` : undefined, `${table.schema}.${table.name}`, table.name].filter((key): key is string => !!key) : [table.name]; for (const key of keys) { const columns = columnsByTable.get(key); if (columns) return applyReferencedColumnAliases(table, columns); @@ -3779,7 +3813,7 @@ function columnsForReferencedTable(table: SqlCompletionReferencedTable, columnsB function foreignKeysForReferencedTable(table: SqlCompletionReferencedTable, foreignKeysByTable?: Map): SqlCompletionForeignKey[] { if (!foreignKeysByTable) return []; - const keys = table.schema ? [`${table.schema}.${table.name}`, table.name] : [table.name]; + const keys = table.schema ? [table.database ? `${table.database}.${table.schema}.${table.name}` : undefined, `${table.schema}.${table.name}`, table.name].filter((key): key is string => !!key) : [table.name]; for (const key of keys) { const foreignKeys = foreignKeysByTable.get(key); if (foreignKeys) return foreignKeys; diff --git a/apps/desktop/src/lib/sql/sqlCompletionLookupTarget.ts b/apps/desktop/src/lib/sql/sqlCompletionLookupTarget.ts index 70ec178be3..b45ca1fa09 100644 --- a/apps/desktop/src/lib/sql/sqlCompletionLookupTarget.ts +++ b/apps/desktop/src/lib/sql/sqlCompletionLookupTarget.ts @@ -20,11 +20,24 @@ export function resolveSqlCompletionTableLookupTarget(options: { currentDatabase: string; currentSchema?: string; supportsDatabaseQualifier: boolean; - completionContext: Pick; + supportsDatabaseSchemaQualifier?: boolean; + completionContext: Pick; knownDatabases?: readonly string[]; }): SqlCompletionTableLookupTarget { const { completionContext } = options; const qualifier = completionContext.qualifier?.trim(); + const qualifierParts = completionContext.qualifierParts?.filter(Boolean) ?? qualifier?.split(".").filter(Boolean) ?? []; + if (options.supportsDatabaseSchemaQualifier && completionContext.suggestTables && !completionContext.insertTable && qualifierParts.length >= 2) { + const databaseQualifier = qualifierParts[qualifierParts.length - 2]!; + const schema = qualifierParts[qualifierParts.length - 1]!; + const database = findExactName(options.knownDatabases, databaseQualifier) ?? databaseQualifier; + return { + database, + schema, + filter: completionContext.prefix, + qualifierDatabase: database, + }; + } const qualifierIsDatabase = options.supportsDatabaseQualifier && !!qualifier && completionContext.suggestTables && !completionContext.insertTable; if (qualifierIsDatabase) { diff --git a/apps/desktop/src/lib/sql/sqlNavigation.ts b/apps/desktop/src/lib/sql/sqlNavigation.ts index 56b55c6c0d..13d0df2511 100644 --- a/apps/desktop/src/lib/sql/sqlNavigation.ts +++ b/apps/desktop/src/lib/sql/sqlNavigation.ts @@ -300,7 +300,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: T[]): T | null { +export function matchTable(identifier: string, tables: T[]): T | null { const parts = splitQualifiedIdentifier(identifier); const normalizedIdentifier = parts.length > 0 ? parts.join(".").toLowerCase() : identifier.toLowerCase(); @@ -311,7 +311,8 @@ export function matchTable(identifi // Use the final two parts so catalog.schema.table still resolves against schema-scoped metadata. const qualifier = parts[parts.length - 2].toLowerCase(); const name = parts[parts.length - 1].toLowerCase(); - const qualified = tables.find((t) => t.name.toLowerCase() === name && t.schema?.toLowerCase() === qualifier); + const database = parts.length >= 3 ? parts[parts.length - 3].toLowerCase() : undefined; + const qualified = tables.find((t) => t.name.toLowerCase() === name && t.schema?.toLowerCase() === qualifier && (!database || !t.database || t.database.toLowerCase() === database)); if (qualified) return qualified; } diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index cfc58a515e..8cc41d2a2c 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -589,6 +589,7 @@ export interface SqlTextSpan { export interface SqlTableReference { name: string; + database?: string | null; schema?: string | null; alias?: string | null; span: SqlTextSpan; diff --git a/crates/dbx-core/tests/live_sqlserver_completion.rs b/crates/dbx-core/tests/live_sqlserver_completion.rs index 026c82ac89..2ccbfb529c 100644 --- a/crates/dbx-core/tests/live_sqlserver_completion.rs +++ b/crates/dbx-core/tests/live_sqlserver_completion.rs @@ -704,3 +704,138 @@ async fn live_sqlserver_completion_assistant_searches_metadata_before_limiting() let cleanup = format!("DROP TABLE [{schema}].[{table}];"); let _ = dbx_core::db::sqlserver::execute_batch(&mut client, &cleanup).await; } + +#[tokio::test] +#[ignore = "requires DBX_LIVE_SQLSERVER_HOST/PORT/USER/PASSWORD pointing at a writable SQL Server instance"] +async fn live_sqlserver_cross_database_metadata_and_query() { + let host = std::env::var("DBX_LIVE_SQLSERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); + let port = std::env::var("DBX_LIVE_SQLSERVER_PORT").ok().and_then(|value| value.parse().ok()).unwrap_or(1433); + let user = std::env::var("DBX_LIVE_SQLSERVER_USER").unwrap_or_else(|_| "sa".to_string()); + let password = std::env::var("DBX_LIVE_SQLSERVER_PASSWORD").expect("DBX_LIVE_SQLSERVER_PASSWORD"); + let default_database = std::env::var("DBX_LIVE_SQLSERVER_DATABASE").unwrap_or_else(|_| "master".to_string()); + let suffix = uuid::Uuid::new_v4().simple().to_string(); + let database_a = format!("dbx_completion_a_{}", &suffix[..8]); + let database_b = format!("dbx_completion_b_{}", &suffix[..8]); + + let mut master = + dbx_core::db::sqlserver::connect(&host, port, &user, &password, Some("master"), None, Duration::from_secs(10)) + .await + .expect("connect SQL Server master"); + dbx_core::db::sqlserver::execute_batch( + &mut master, + &format!("CREATE DATABASE [{database_a}]; CREATE DATABASE [{database_b}];"), + ) + .await + .expect("create completion databases"); + + let mut client_a = dbx_core::db::sqlserver::connect( + &host, + port, + &user, + &password, + Some(&database_a), + None, + Duration::from_secs(10), + ) + .await + .expect("connect database A"); + let mut client_b = dbx_core::db::sqlserver::connect( + &host, + port, + &user, + &password, + Some(&database_b), + None, + Duration::from_secs(10), + ) + .await + .expect("connect database B"); + dbx_core::db::sqlserver::execute_batch(&mut client_a, "CREATE SCHEMA [IN]") + .await + .expect("create database A IN schema"); + dbx_core::db::sqlserver::execute_batch(&mut client_a, "CREATE SCHEMA [OUT]") + .await + .expect("create database A OUT schema"); + dbx_core::db::sqlserver::execute_batch( + &mut client_a, + "CREATE TABLE [IN].[orders_in] (id INT PRIMARY KEY); CREATE TABLE [OUT].[orders] (id INT PRIMARY KEY, source_marker NVARCHAR(20)); INSERT INTO [OUT].[orders] VALUES (1, N'A');", + ) + .await + .expect("create database A fixtures"); + dbx_core::db::sqlserver::execute_batch(&mut client_b, "CREATE SCHEMA [OUT]") + .await + .expect("create database B OUT schema"); + dbx_core::db::sqlserver::execute_batch( + &mut client_b, + "CREATE TABLE [OUT].[orders_out] (id INT PRIMARY KEY); CREATE TABLE [OUT].[orders] (id INT PRIMARY KEY, target_marker NVARCHAR(20)); INSERT INTO [OUT].[orders] VALUES (1, N'B');", + ) + .await + .expect("create database B fixtures"); + drop(client_a); + drop(client_b); + + let dir = std::env::temp_dir().join(format!("dbx-live-sqlserver-cross-database-{suffix}")); + std::fs::create_dir_all(&dir).unwrap(); + let storage = Storage::open(&dir.join("storage.db")).await.unwrap(); + let state = AppState::new(storage); + let connection_id = "live-sqlserver-cross-database"; + let mut config = live_sqlserver_config(connection_id, &default_database); + config.host = host; + config.port = port; + config.username = user; + config.password = password; + state.configs.write().await.insert(connection_id.to_string(), config); + + let tables_a = dbx_core::schema::list_tables_core( + &state, + connection_id, + &database_a, + "IN", + Some("orders"), + Some(20), + None, + None, + ) + .await + .expect("list database A tables"); + let tables_b = dbx_core::schema::list_tables_core( + &state, + connection_id, + &database_b, + "OUT", + Some("orders"), + Some(20), + None, + None, + ) + .await + .expect("list database B tables"); + assert!(tables_a.iter().any(|table| table.name == "orders_in")); + assert!(tables_b.iter().any(|table| table.name == "orders_out")); + + let columns_a = dbx_core::schema::get_columns_core(&state, connection_id, &database_a, "OUT", "orders") + .await + .expect("load database A columns"); + let columns_b = dbx_core::schema::get_columns_core(&state, connection_id, &database_b, "OUT", "orders") + .await + .expect("load database B columns"); + assert!(columns_a.iter().any(|column| column.name == "source_marker")); + assert!(!columns_a.iter().any(|column| column.name == "target_marker")); + assert!(columns_b.iter().any(|column| column.name == "target_marker")); + assert!(!columns_b.iter().any(|column| column.name == "source_marker")); + + let query = format!("SELECT a.source_marker, b.target_marker FROM [{database_a}].[OUT].[orders] a JOIN [{database_b}].[OUT].[orders] b ON a.id = b.id"); + let result = + dbx_core::query::execute_sql_statement(&state, connection_id, &default_database, &query, Some("dbo"), None) + .await + .expect("execute cross-database join"); + assert_eq!(result.rows, vec![vec![serde_json::json!("A"), serde_json::json!("B")]]); + + state.remove_connection_pools_detached(connection_id).await; + for database in [&database_a, &database_b] { + let cleanup = + format!("ALTER DATABASE [{database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{database}];"); + dbx_core::db::sqlserver::execute_batch(&mut master, &cleanup).await.expect("drop completion database"); + } + let _ = std::fs::remove_dir_all(dir); +} diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index b1a918c94d..9e189befd0 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -2054,9 +2054,53 @@ test("detects INSERT INTO column list with three-part qualified table", () => { const context = getSqlCompletionContext(sql, sql.length); assert.equal(context.insertTable, "users"); + assert.equal(context.insertDatabase, "analytics"); assert.equal(context.insertSchema, "public"); }); +test("detects SQL Server bracket-qualified database, schema, and table references", () => { + const sql = "SELECT * FROM [DatabaseA].[IN].[orders] a LEFT JOIN [DatabaseB].[OUT].[orders] b ON b."; + const context = getSqlCompletionContext(sql, sql.length); + + assert.deepEqual( + context.referencedTables.map(({ name, database, schema, alias }) => ({ name, database, schema, alias })), + [ + { name: "orders", database: "DatabaseA", schema: "IN", alias: "a" }, + { name: "orders", database: "DatabaseB", schema: "OUT", alias: "b" }, + ], + ); +}); + +test("scopes SQL Server cross-database alias columns to the referenced database", () => { + const sql = "SELECT * FROM [DatabaseA].[OUT].[orders] a LEFT JOIN [DatabaseB].[OUT].[orders] b ON b."; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [], + columnsByTable: new Map([ + ["DatabaseA.OUT.orders", [{ name: "source_marker", table: "orders", schema: "OUT" }]], + ["DatabaseB.OUT.orders", [{ name: "target_marker", table: "orders", schema: "OUT" }]], + ]), + databaseType: "sqlserver", + dialect: "sqlserver", + }); + + assert.deepEqual( + items.filter((item) => item.type === "column").map((item) => item.label), + ["target_marker"], + ); +}); + +test("suggests INSERT columns for a SQL Server three-part target", () => { + const sql = "INSERT INTO [DatabaseB].[OUT].[orders] ("; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [], + columnsByTable: new Map([["DatabaseB.OUT.orders", [{ name: "target_marker", table: "orders", schema: "OUT" }]]]), + databaseType: "sqlserver", + dialect: "sqlserver", + }); + + assert.equal(items.find((item) => item.type === "snippet" && item.label === "orders.*")?.apply, "target_marker"); +}); + test("detects MySQL backtick-qualified INSERT INTO column list context", () => { const sql = "INSERT INTO `other_db`.`orders` ("; const context = getSqlCompletionContext(sql, sql.length);