From cce7fcc600225531842e6070b62693bb7c0f4354 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Mon, 15 Jun 2026 23:35:11 +0800 Subject: [PATCH] fix(mysql): complete database-qualified columns --- .../src/components/editor/QueryEditor.vue | 53 ++++++++++- apps/desktop/src/lib/sqlCompletion.ts | 56 ++++++++--- packages/app-tests/sqlCompletion.test.ts | 92 +++++++++++++++++++ 3 files changed, 189 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 855c3168ac..c20415776d 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -441,6 +441,22 @@ function completionMetadataTarget(table: { name: string; schema?: string | null return { database: props.database, schema: table.schema ?? props.schema }; } +function completionQualifiedTableTarget(completionContext: ReturnType): { name: 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 }; +} + +function completionTablesMatch(left: { name: string; schema?: string | null }, right: { name: string; schema?: string | null }) { + if (left.name.toLowerCase() !== right.name.toLowerCase()) return false; + if (!left.schema || !right.schema) return true; + return left.schema.toLowerCase() === right.schema.toLowerCase(); +} + async function ensureColumnsForTable(table: { name: string; schema?: string | null }) { const cacheKey = completionCacheKey(table); if (cachedColumnsByTable.has(cacheKey) || !props.connectionId || props.database == null) return; @@ -1047,6 +1063,21 @@ function buildLocalSqlCompletionResult(completionContext: ReturnType 0) { + columnsByTable.set(cacheKey, localColumns); + } + } + } + const cteDefs = extractCteDefinitions(fullDoc); for (const refTable of completionContext.referencedTables) { const cteDef = cteDefs.find((c) => c.name.toLowerCase() === refTable.name.toLowerCase()); @@ -1133,9 +1164,23 @@ function scheduleCompletionMetadataRefresh(completionContext: ReturnType {}); } + const qualifiedColumnTarget = completionQualifiedTableTarget(completionContext); + const qualifiedColumnCacheKey = qualifiedColumnTarget ? completionCacheKey(qualifiedColumnTarget) : undefined; + if (qualifiedColumnTarget && qualifiedColumnCacheKey && !cachedColumnsByTable.has(qualifiedColumnCacheKey)) { + const target = completionMetadataTarget(qualifiedColumnTarget); + if (target) { + void connectionStore + .refreshCompletionColumns(connectionId, target.database, qualifiedColumnTarget.name, target.schema) + .then((columns) => { + if (columns.length > 0) cachedColumnsByTable.set(qualifiedColumnCacheKey, columns); + }) + .catch(() => {}); + } + } for (const refTable of completionContext.referencedTables) { if (refTable.columns && refTable.columns.length > 0) continue; const cacheKey = refTable.schema ? `${refTable.schema}.${refTable.name}` : refTable.name; + if (cacheKey === qualifiedColumnCacheKey) continue; if (cachedColumnsByTable.has(cacheKey)) continue; const target = completionMetadataTarget(refTable); if (!target) continue; @@ -1262,6 +1307,11 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext refs = matched.map((t) => ({ name: t.name, schema: t.schema })); } + const qualifiedColumnTarget = completionQualifiedTableTarget(completionContext); + if (qualifiedColumnTarget && !refs.some((ref) => completionTablesMatch(ref, qualifiedColumnTarget))) { + refs.push(qualifiedColumnTarget); + } + // Populate CTE columns from parsed definitions const cteDefs = extractCteDefinitions(fullDoc); for (const refTable of refs) { @@ -1363,7 +1413,8 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext function isReferencedTableQualifier(completionContext: ReturnType): boolean { if (!completionContext.qualifier) return false; const qualifier = completionContext.qualifier.toLowerCase(); - return completionContext.referencedTables.some((table) => table.alias?.toLowerCase() === qualifier || table.name.toLowerCase() === qualifier); + const qualifiedColumnTarget = completionQualifiedTableTarget(completionContext); + return completionContext.referencedTables.some((table) => table.alias?.toLowerCase() === qualifier || table.name.toLowerCase() === qualifier || (!!qualifiedColumnTarget && completionTablesMatch(table, qualifiedColumnTarget))); } function mergeCompletionObjects(existing: SqlCompletionObject[], incoming: SqlCompletionObject[]) { diff --git a/apps/desktop/src/lib/sqlCompletion.ts b/apps/desktop/src/lib/sqlCompletion.ts index 4602a1aed2..466ec332c0 100644 --- a/apps/desktop/src/lib/sqlCompletion.ts +++ b/apps/desktop/src/lib/sqlCompletion.ts @@ -1063,6 +1063,7 @@ export type SqlStatementKind = "select" | "insert" | "update" | "delete" | "crea export interface SqlCompletionContext { prefix: string; qualifier?: string; + qualifierParts?: string[]; suggestTables: boolean; suggestColumns: boolean; suggestKeywords: boolean; @@ -1496,6 +1497,7 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet const trailingIdentifier = parseTrailingIdentifierContext(beforeCursor); const prefix = trailingIdentifier?.prefix ?? ""; const qualifier = trailingIdentifier?.qualifier; + const qualifierParts = trailingIdentifier?.qualifierParts; const bareStart = trailingIdentifier?.start ?? beforeCursor.length; const beforeToken = beforeCursor.slice(0, Math.max(0, bareStart)).trimEnd(); const lastWord = /([A-Za-z_][\w$]*)$/.exec(beforeToken)?.[1]?.toLowerCase() ?? ""; @@ -1546,6 +1548,7 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet return { prefix, qualifier: insertInfo ? undefined : qualifier, + qualifierParts: insertInfo ? undefined : qualifierParts, suggestTables: insertInfo ? false : afterTableTrigger, suggestColumns: !!qualifier || !!updateInfo?.inSetClause || (inColumnContext && referencedTables.length > 0), suggestKeywords: !exclusiveTableSuggestions && !exclusiveColumnSuggestions && !insertInfo && !inCallRoutineContext, @@ -1572,7 +1575,7 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet }; } -function parseTrailingIdentifierContext(input: string): { start: number; prefix: string; qualifier?: string } | null { +function parseTrailingIdentifierContext(input: string): { start: number; prefix: string; qualifier?: string; qualifierParts?: string[] } | null { if (/\s$/.test(input)) return null; let i = input.length - 1; while (i >= 0 && /\s/.test(input[i] ?? "")) i--; @@ -1606,6 +1609,7 @@ function parseTrailingIdentifierContext(input: string): { start: number; prefix: start, prefix: prefixPart, qualifier: qualifierValue || undefined, + qualifierParts: qualifierParts.length > 0 ? qualifierParts : undefined, }; } @@ -2589,16 +2593,9 @@ function buildColumnItems(context: SqlCompletionContext, columnsByTable: Map table.alias === q || table.alias?.toLowerCase() === qLower || table.name === q || table.name.toLowerCase() === qLower); - const tableNameSet = new Set(relatedTables.map((t) => t.name.toLowerCase())); - const tableKeys = new Set(); - for (const table of relatedTables) { - tableKeys.add(table.name); - if (table.schema) { - tableKeys.add(`${table.schema}.${table.name}`); - } - } - relevantCols = allColumns.filter((c) => tableNameSet.has(c.table.toLowerCase()) || tableKeys.has(c.key)); + const qualifiedTarget = qualifiedTableTargetFromContext(context); + const relatedTables = context.referencedTables.filter((table) => referencedTableMatchesColumnQualifier(table, q, qLower, qualifiedTarget)); + relevantCols = allColumns.filter((column) => relatedTables.some((table) => columnMatchesReferencedTable(column, table)) || (!!qualifiedTarget && columnMatchesQualifiedTable(column, qualifiedTarget))); } // Count name frequencies to detect duplicates across tables @@ -2646,6 +2643,43 @@ function buildColumnItems(context: SqlCompletionContext, columnsByTable: Map normalizeIdentifierPart(part)) + .join("."); +} + function buildColumnApply(column: SqlCompletionColumn & { displayLabel: string }, context: SqlCompletionContext, dialect?: "mysql" | "postgres" | "sqlserver"): string { if (context.qualifier || column.displayLabel === column.name || !column.displayLabel.includes(".")) { return quoteSqlIdentifier(column.name, dialect); diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index 5055b791c4..660ec85b2d 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -41,6 +41,18 @@ const columnsByTable = new Map([ ], ]); +const mysqlCrossDatabaseColumnsByTable = new Map([ + [ + "other_db.orders", + [ + { name: "id", table: "orders", dataType: "bigint" }, + { name: "number", table: "orders", dataType: "varchar" }, + { name: "status", table: "orders", dataType: "varchar" }, + ], + ], + ["current_db.orders", [{ name: "local_status", table: "orders", dataType: "varchar" }]], +]); + const completionObjects: SqlCompletionObject[] = [ { name: "refresh_user_stats", schema: "app", type: "procedure" }, { name: "format_user_name", schema: "app", type: "function" }, @@ -488,6 +500,86 @@ test("keeps schema-qualified FROM object input in table suggestion mode", () => ); }); +test("keeps database-qualified FROM input in table suggestion mode", () => { + const sql = "select * from other_db.or"; + const context = getSqlCompletionContext(sql, sql.length); + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [{ name: "orders", schema: "other_db", type: "table" }], + columnsByTable: new Map(), + databaseType: "mysql", + }); + + assert.equal(context.qualifier, "other_db"); + assert.deepEqual(context.qualifierParts, ["other_db"]); + assert.equal(context.prefix, "or"); + assert.equal(context.suggestTables, true); + assert.equal(context.exclusiveColumnSuggestions, false); + assert.deepEqual( + items.map((item) => [item.label, item.type, item.detail]), + [["orders", "table", "other_db.orders"]], + ); +}); + +test("tracks qualifier parts for database-qualified column completion", () => { + const sql = "select * from other_db.orders where other_db.orders.st"; + const context = getSqlCompletionContext(sql, sql.length); + + assert.equal(context.qualifier, "other_db.orders"); + assert.deepEqual(context.qualifierParts, ["other_db", "orders"]); + assert.equal(context.prefix, "st"); + assert.equal(context.exclusiveColumnSuggestions, true); +}); + +test("suggests columns after database-qualified table qualifier", () => { + const sql = "select * from other_db.orders where other_db.orders."; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [{ name: "orders", schema: "other_db", type: "table" }], + columnsByTable: mysqlCrossDatabaseColumnsByTable, + databaseType: "mysql", + }); + + assert.deepEqual( + items.map((item) => [item.label, item.type, item.detail]), + [ + ["id", "column", "orders [bigint]"], + ["number", "column", "orders [varchar]"], + ["status", "column", "orders [varchar]"], + ], + ); +}); + +test("scopes database-qualified table column suggestions to the matching database", () => { + const sql = "select * from other_db.orders where other_db.orders.st"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [{ name: "orders", schema: "other_db", type: "table" }], + columnsByTable: mysqlCrossDatabaseColumnsByTable, + databaseType: "mysql", + }); + + assert.deepEqual( + items.map((item) => [item.label, item.type, item.detail]), + [["status", "column", "orders [varchar]"]], + ); +}); + +test("keeps aliases working for database-qualified tables", () => { + const sql = "select * from other_db.orders o where o."; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [{ name: "orders", schema: "other_db", type: "table" }], + columnsByTable: mysqlCrossDatabaseColumnsByTable, + databaseType: "mysql", + }); + + assert.deepEqual( + items.map((item) => [item.label, item.type]), + [ + ["id", "column"], + ["number", "column"], + ["status", "column"], + ], + ); +}); + test("includes views in exclusive FROM object suggestions", () => { const sql = "select * from tick"; const items = buildSqlCompletionItems(sql, sql.length, {