mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-22 02:06:26 +00:00
fix(mysql): complete database-qualified columns
This commit is contained in:
@@ -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[]) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user