fix(mysql): complete database-qualified columns

This commit is contained in:
t8y2
2026-06-15 23:35:11 +08:00
parent 3574113feb
commit cce7fcc600
3 changed files with 189 additions and 12 deletions
@@ -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<typeof getSqlCompletionContext>): { 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<typeof getS
}
}
const qualifiedColumnTarget = completionQualifiedTableTarget(completionContext);
if (qualifiedColumnTarget) {
const cacheKey = completionCacheKey(qualifiedColumnTarget);
const cached = cachedColumnsByTable.get(cacheKey);
if (cached) {
columnsByTable.set(cacheKey, cached);
} else {
const target = completionMetadataTarget(qualifiedColumnTarget);
const localColumns = target ? connectionStore.lookupLocalCompletionColumns(props.connectionId, target.database, qualifiedColumnTarget.name, target.schema) : [];
if (localColumns.length > 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<typeof
})
.catch(() => {});
}
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<typeof getSqlCompletionContext>): 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[]) {
+45 -11
View File
@@ -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<str
} else if (context.qualifier) {
const q = context.qualifier;
const qLower = q.toLowerCase();
const relatedTables = context.referencedTables.filter((table) => 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<string>();
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<str
.sort(compareCompletionItems);
}
function qualifiedTableTargetFromContext(context: SqlCompletionContext): { 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 };
}
function referencedTableMatchesColumnQualifier(table: SqlCompletionReferencedTable, qualifier: string, qualifierLower: string, qualifiedTarget: { 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;
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 });
}
function columnMatchesQualifiedTable(column: SqlCompletionColumn & { key: string }, target: { 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}`);
}
function normalizeCompletionKey(key: string): string {
return key
.split(".")
.filter(Boolean)
.map((part) => 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);
+92
View File
@@ -41,6 +41,18 @@ const columnsByTable = new Map<string, SqlCompletionColumn[]>([
],
]);
const mysqlCrossDatabaseColumnsByTable = new Map<string, SqlCompletionColumn[]>([
[
"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, {