mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 06:00:39 +00:00
fix: improve SQL table and JOIN completion (#2629)
This commit is contained in:
@@ -1632,7 +1632,8 @@ async function provideSqlCompletions(context: CompletionContext) {
|
||||
if (!explicit && !shouldAutoOpenSqlCompletion(fullDoc, position)) return null;
|
||||
|
||||
const legacyCompletionContext = getSqlCompletionContext(fullDoc, position);
|
||||
const completionContext = SEMANTIC_SQL_COMPLETION_ENABLED ? sqlCompletionContextFromSemantic(buildSqlSemanticModel(fullDoc, position, { databaseType: props.databaseType, dialect: props.dialect }), legacyCompletionContext) : legacyCompletionContext;
|
||||
const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED ? buildSqlSemanticModel(fullDoc, position, { databaseType: props.databaseType, dialect: props.dialect }) : null;
|
||||
const completionContext = semanticModel ? sqlCompletionContextFromSemantic(semanticModel, legacyCompletionContext) : legacyCompletionContext;
|
||||
|
||||
if (!hasDatabase) {
|
||||
const items = buildSqlCompletionItemsFromContext(completionContext, {
|
||||
@@ -1674,8 +1675,9 @@ async function provideSqlCompletions(context: CompletionContext) {
|
||||
scheduleCompletionMetadataRefresh(completionContext);
|
||||
if (!explicit) return localResult;
|
||||
}
|
||||
const shouldResolveAsyncColumnCompletion = completionContext.suggestColumns && completionContext.referencedTables.length > 0 && completionContext.prefix.length > 0;
|
||||
if (!explicit && !shouldResolveAsyncColumnCompletion) {
|
||||
const tableNameCompletion = isTableNameCompletionContext(completionContext);
|
||||
const shouldResolveAsyncCompletion = tableNameCompletion || (completionContext.suggestColumns && completionContext.referencedTables.length > 0 && completionContext.prefix.length > 0);
|
||||
if (!explicit && !shouldResolveAsyncCompletion) {
|
||||
scheduleCompletionMetadataRefresh(completionContext);
|
||||
return null;
|
||||
}
|
||||
@@ -1729,6 +1731,22 @@ function flushImeComposition() {
|
||||
if (editorIsActive) emitEditorSelection(latestSelection);
|
||||
}
|
||||
|
||||
function shouldStartSqlCompletionAfterInput(insertedText: string, currentView: EditorViewType): boolean {
|
||||
const position = currentView.state.selection.main.head;
|
||||
const fullDoc = currentView.state.doc.toString();
|
||||
if (insertedText.endsWith(".")) return true;
|
||||
if (/[,(]$/.test(insertedText)) {
|
||||
const completionContext = getSqlCompletionContext(fullDoc, position);
|
||||
return !!completionContext.insertTable;
|
||||
}
|
||||
if (/\s$/.test(insertedText)) {
|
||||
return shouldAutoOpenSqlCompletion(fullDoc, position);
|
||||
}
|
||||
if (!/[\w$@]$/.test(insertedText)) return false;
|
||||
const completionContext = getSqlCompletionContext(fullDoc, position);
|
||||
return isTableNameCompletionContext(completionContext);
|
||||
}
|
||||
|
||||
function buildLocalSqlCompletionResult(completionContext: ReturnType<typeof getSqlCompletionContext>, fullDoc: string, position: number) {
|
||||
if (!props.connectionId || props.database == null) return null;
|
||||
const databaseNames = supportsDatabaseQualifierCompletion() && completionContext.suggestTables && !completionContext.insertTable ? connectionStore.lookupLocalCompletionDatabases(props.connectionId, completionContext.qualifier || completionContext.prefix, MAX_COMPLETION_TABLES) : [];
|
||||
@@ -1823,6 +1841,7 @@ function scheduleCompletionMetadataRefresh(completionContext: ReturnType<typeof
|
||||
if (!props.connectionId || props.database == null) return;
|
||||
const localOnlyMetadata = usesLocalOnlyCompletionMetadata();
|
||||
const onDemandOnlyColumns = usesOnDemandOnlyCompletionColumns();
|
||||
const tableNameCompletion = isTableNameCompletionContext(completionContext);
|
||||
const connectionId = props.connectionId;
|
||||
const database = props.database;
|
||||
const schema = completionContext.qualifier && completionContext.suggestTables ? completionContext.qualifier : props.schema;
|
||||
@@ -1874,7 +1893,7 @@ function scheduleCompletionMetadataRefresh(completionContext: ReturnType<typeof
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
if (!onDemandOnlyColumns) {
|
||||
if (!onDemandOnlyColumns && !tableNameCompletion) {
|
||||
for (const refTable of completionContext.referencedTables) {
|
||||
if (refTable.columns && refTable.columns.length > 0) continue;
|
||||
const cacheKey = refTable.schema ? `${refTable.schema}.${refTable.name}` : refTable.name;
|
||||
@@ -1890,7 +1909,7 @@ function scheduleCompletionMetadataRefresh(completionContext: ReturnType<typeof
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
if ((completionContext.suggestTables || completionContext.suggestJoinConditions) && completionContext.referencedTables.length > 0) {
|
||||
if (!tableNameCompletion && (completionContext.suggestTables || completionContext.suggestJoinConditions) && completionContext.referencedTables.length > 0) {
|
||||
void ensureForeignKeysForTables(completionContext.referencedTables);
|
||||
}
|
||||
}
|
||||
@@ -2054,8 +2073,8 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext
|
||||
}
|
||||
}
|
||||
|
||||
const isTableNameCompletionContext = completionContext.suggestTables || completionContext.exclusiveTableSuggestions;
|
||||
const shouldFetchColumnsForCompletion = !onDemandOnlyColumns || ((completionContext.suggestColumns || completionContext.exclusiveColumnSuggestions) && !isTableNameCompletionContext) || !!completionContext.insertTable;
|
||||
const tableNameCompletion = isTableNameCompletionContext(completionContext);
|
||||
const shouldFetchColumnsForCompletion = !tableNameCompletion && (!onDemandOnlyColumns || completionContext.suggestColumns || completionContext.exclusiveColumnSuggestions || !!completionContext.insertTable);
|
||||
if (shouldFetchColumnsForCompletion) {
|
||||
await Promise.all(
|
||||
refs.map(async (refTable) => {
|
||||
@@ -2077,7 +2096,7 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext
|
||||
}
|
||||
if (epoch !== completionEpoch) return null;
|
||||
|
||||
if ((completionContext.suggestTables || completionContext.suggestJoinConditions) && refs.length > 0) {
|
||||
if (!tableNameCompletion && (completionContext.suggestTables || completionContext.suggestJoinConditions) && refs.length > 0) {
|
||||
const fkPrefetchTables = completionContext.suggestTables ? [...refs, ...tables.slice(0, MAX_JOIN_FK_PREFETCH_TABLES)] : refs;
|
||||
await ensureForeignKeysForTables(fkPrefetchTables.filter((table) => !("columns" in table) || !table.columns || table.columns.length === 0));
|
||||
if (epoch !== completionEpoch) return null;
|
||||
@@ -2155,6 +2174,10 @@ function isReferencedTableQualifier(completionContext: ReturnType<typeof getSqlC
|
||||
return completionContext.referencedTables.some((table) => table.alias?.toLowerCase() === qualifier || table.name.toLowerCase() === qualifier || (!!qualifiedColumnTarget && completionTablesMatch(table, qualifiedColumnTarget)));
|
||||
}
|
||||
|
||||
function isTableNameCompletionContext(completionContext: ReturnType<typeof getSqlCompletionContext>): boolean {
|
||||
return completionContext.suggestTables || completionContext.exclusiveTableSuggestions;
|
||||
}
|
||||
|
||||
function mergeCompletionObjects(existing: SqlCompletionObject[], incoming: SqlCompletionObject[]) {
|
||||
const merged = [...existing];
|
||||
const seen = new Set(existing.map((object) => `${object.type}:${object.schema ?? ""}:${object.name}:${object.parentName ?? ""}`.toLowerCase()));
|
||||
@@ -2444,8 +2467,10 @@ onMounted(async () => {
|
||||
update.changes.iterChanges((_fromA, _toA, _fromB, _toB, inserted) => {
|
||||
insertedText += inserted.toString();
|
||||
});
|
||||
if (insertedText.endsWith(".")) {
|
||||
startCompletion(update.view);
|
||||
if (shouldStartSqlCompletionAfterInput(insertedText, update.view)) {
|
||||
window.setTimeout(() => {
|
||||
if (!isEditorComposing(update.view)) startCompletion(update.view);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,4 +97,54 @@ describe("semantic SQL completion candidates", () => {
|
||||
expect(context.insertTable).toBe("users");
|
||||
expect(items.find((item) => item.type === "snippet" && item.label === "users.*")?.apply).toBe("id, name, email");
|
||||
});
|
||||
|
||||
it("keeps partial INSERT INTO targets in table completion context", () => {
|
||||
const { context, items } = semanticCompletion("INSERT INTO ex|", {
|
||||
tables: [
|
||||
{ name: "express", type: "table" },
|
||||
{ name: "orders", type: "table" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(context.suggestTables).toBe(true);
|
||||
expect(context.exclusiveTableSuggestions).toBe(true);
|
||||
expect(context.suggestColumns).toBe(false);
|
||||
expect(context.referencedTables).toEqual([]);
|
||||
expect(items.filter((item) => item.type === "table").map((item) => item.label)).toEqual(["express"]);
|
||||
});
|
||||
|
||||
it("keeps partial SELECT FROM targets in table completion context", () => {
|
||||
const { context, items } = semanticCompletion("SELECT * FROM ex|", {
|
||||
tables: [
|
||||
{ name: "express", type: "table" },
|
||||
{ name: "orders", type: "table" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(context.suggestTables).toBe(true);
|
||||
expect(context.exclusiveTableSuggestions).toBe(true);
|
||||
expect(context.qualifier).toBeUndefined();
|
||||
expect(context.qualifierParts).toBeUndefined();
|
||||
expect(items.filter((item) => item.type === "table").map((item) => item.label)).toEqual(["express"]);
|
||||
});
|
||||
|
||||
it("keeps JOIN modifier completion in keyword context", () => {
|
||||
const { context, items } = semanticCompletion("SELECT * FROM users left |", {
|
||||
tables: [{ name: "orders", type: "table" }],
|
||||
});
|
||||
|
||||
expect(context.suggestTables).toBe(false);
|
||||
expect(context.preferredKeywords).toContain("JOIN");
|
||||
expect(items[0]?.label).toBe("JOIN");
|
||||
expect(items.some((item) => item.type === "table")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps table completion after completed LEFT JOIN", () => {
|
||||
const { context, items } = semanticCompletion("SELECT * FROM users left join |", {
|
||||
tables: [{ name: "orders", type: "table" }],
|
||||
});
|
||||
|
||||
expect(context.suggestTables).toBe(true);
|
||||
expect(items.filter((item) => item.type === "table").map((item) => item.label)).toEqual(["orders"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -171,6 +171,9 @@ export function sqlCompletionContextFromSemantic(model: SqlSemanticModel, base:
|
||||
if (model.cursorIntent.confidence === "low" || model.cursorIntent.kind === "suppressed") {
|
||||
return base;
|
||||
}
|
||||
if ((base.suggestTables || base.exclusiveTableSuggestions) && model.cursorIntent.kind !== "table" && model.cursorIntent.kind !== "schema" && model.cursorIntent.kind !== "catalog" && model.cursorIntent.kind !== "delete_target") {
|
||||
return base;
|
||||
}
|
||||
|
||||
const scope = sqlSemanticCompletionScope(model);
|
||||
const qualifier = model.cursorIntent.qualifierParts.length > 0 ? model.cursorIntent.qualifierParts.join(".") : undefined;
|
||||
|
||||
@@ -381,23 +381,30 @@ function trailingIdentifier(tokens: readonly SqlSemanticToken[], cursor: number,
|
||||
let prefix = "";
|
||||
let replacementRange: SqlSemanticSpan = { start: cursor, end: cursor };
|
||||
let index = before.length - 1;
|
||||
let hasQualifier = false;
|
||||
if (tokenIsIdentifier(last) && cursor <= last.span.end) {
|
||||
const rawPrefix = tokenTextAt(last.text, { start: 0, end: Math.max(0, cursor - last.span.start) });
|
||||
prefix = last.kind === "quoted_identifier" ? unquoteSqlSemanticIdentifier({ ...last, text: rawPrefix.endsWith(last.quote ?? "") ? rawPrefix : rawPrefix + (last.quote === "[" ? "]" : (last.quote ?? "")) }) : rawPrefix;
|
||||
replacementRange = { start: last.span.start, end: cursor };
|
||||
index -= 1;
|
||||
if (before[index]?.text === ".") index -= 1;
|
||||
if (before[index]?.text === ".") {
|
||||
hasQualifier = true;
|
||||
index -= 1;
|
||||
}
|
||||
} else if (last.text === ".") {
|
||||
hasQualifier = true;
|
||||
index -= 1;
|
||||
}
|
||||
|
||||
const qualifierParts: string[] = [];
|
||||
while (index >= 0) {
|
||||
const identifier = before[index];
|
||||
if (!tokenIsIdentifier(identifier)) break;
|
||||
qualifierParts.unshift(identifierPart(identifier, dialect).name);
|
||||
if (before[index - 1]?.text !== ".") break;
|
||||
index -= 2;
|
||||
if (hasQualifier) {
|
||||
while (index >= 0) {
|
||||
const identifier = before[index];
|
||||
if (!tokenIsIdentifier(identifier)) break;
|
||||
qualifierParts.unshift(identifierPart(identifier, dialect).name);
|
||||
if (before[index - 1]?.text !== ".") break;
|
||||
index -= 2;
|
||||
}
|
||||
}
|
||||
|
||||
return { prefix, replacementRange, qualifierParts };
|
||||
@@ -507,7 +514,7 @@ function buildCursorIntent(tokens: readonly SqlSemanticToken[], cursor: number,
|
||||
return { kind: "alias_column", prefix: trailing.prefix, replacementRange: trailing.replacementRange, qualifierParts: trailing.qualifierParts, targetSourceId: targetSource.id, expectedObjectKinds: ["column"], confidence: "high" };
|
||||
}
|
||||
|
||||
if (TABLE_INTRODUCERS.has(previous) || JOIN_MODIFIERS.has(previous) || previous === "from" || previous === "join" || tableListContinuation) {
|
||||
if (TABLE_INTRODUCERS.has(previous) || TABLE_INTRODUCERS.has(wordBeforeReplacement) || previous === "from" || previous === "join" || wordBeforeReplacement === "from" || wordBeforeReplacement === "join" || tableListContinuation) {
|
||||
return { kind: previous === "join" ? "table" : "table", prefix: trailing.prefix, replacementRange: trailing.replacementRange, qualifierParts: trailing.qualifierParts, expectedObjectKinds: ["table", "view"], confidence: "high" };
|
||||
}
|
||||
|
||||
|
||||
@@ -521,6 +521,7 @@ const HIGH_FREQUENCY_KEYWORDS = new Set([
|
||||
const TABLE_TRIGGER_KEYWORDS = new Set(["from", "join", "update", "into", "table", "describe", "explain", "apply"]);
|
||||
const EXCLUSIVE_TABLE_TRIGGER_KEYWORDS = new Set(["from", "join", "update", "into", "apply"]);
|
||||
const JOIN_MODIFIERS = new Set(["left", "right", "inner", "outer", "cross", "full", "natural"]);
|
||||
const JOIN_MODIFIER_KEYWORD_PHRASES = ["LEFT JOIN", "RIGHT JOIN", "INNER JOIN", "FULL JOIN", "CROSS JOIN", "NATURAL JOIN", "LEFT OUTER JOIN", "RIGHT OUTER JOIN", "FULL OUTER JOIN"];
|
||||
const MAX_TABLE_COMPLETION_ITEMS = 200;
|
||||
|
||||
// Keywords that only make sense in DDL / statement-start contexts (not inside SELECT/INSERT/UPDATE/DELETE)
|
||||
@@ -1211,13 +1212,14 @@ class SqlCompletionProvider {
|
||||
|
||||
build(): SqlCompletionItem[] {
|
||||
const { context } = this;
|
||||
const pendingJoinKeyword = isPendingJoinKeywordContext(context);
|
||||
|
||||
if (this.databaseType === "mongodb") {
|
||||
return dedupeAndSort(buildMongoCompletionItemsFromContext({ mode: "root", prefix: context.prefix, from: 0 }).map(mongoCompletionItemToSqlCompletionItem));
|
||||
}
|
||||
|
||||
const preferReferencedColumns = hasMatchingReferencedColumnPrefix(context, this.input.columnsByTable);
|
||||
if (!preferReferencedColumns && !context.exclusiveTableSuggestions && !context.exclusiveColumnSuggestions && !context.exclusiveRoutineSuggestions) {
|
||||
if (!pendingJoinKeyword && !preferReferencedColumns && !context.exclusiveTableSuggestions && !context.exclusiveColumnSuggestions && !context.exclusiveRoutineSuggestions) {
|
||||
const snippets = this.databaseType === "manticoresearch" ? [...(this.input.snippets ?? DEFAULT_SQL_SNIPPETS), ...MANTICORESEARCH_SQL_SNIPPETS] : (this.input.snippets ?? DEFAULT_SQL_SNIPPETS);
|
||||
this.items.push(...buildSnippetItems(context.prefix, snippets, this.input.keywordCase));
|
||||
this.items.push(...buildFunctionSnippetItems(context.prefix, getFunctionDescriptions(this.t), this.databaseType));
|
||||
@@ -1249,7 +1251,8 @@ class SqlCompletionProvider {
|
||||
this.items.push(...buildJoinConditionItems(context, this.input.columnsByTable, this.input.foreignKeysByTable, this.dialect, this.input.keywordCase));
|
||||
}
|
||||
|
||||
if (context.suggestKeywords && !context.exclusiveRoutineSuggestions) {
|
||||
if (context.suggestKeywords && !context.exclusiveRoutineSuggestions && !pendingJoinKeyword) {
|
||||
this.items.push(...buildJoinModifierKeywordItems(context.prefix, this.input.keywordCase));
|
||||
this.items.push(...buildKeywordItems(context.prefix, context, this.databaseType, this.input.keywordCase));
|
||||
}
|
||||
|
||||
@@ -1259,7 +1262,7 @@ class SqlCompletionProvider {
|
||||
this.items.push(...buildInsertAllColumnItems(context, this.input.columnsByTable, this.t, this.dialect));
|
||||
}
|
||||
|
||||
if (context.referencedTables.length > 0 && !context.suggestColumns && !context.insertTable) {
|
||||
if (!pendingJoinKeyword && context.referencedTables.length > 0 && !context.suggestColumns && !context.insertTable) {
|
||||
this.items.push(...buildAliasItems(context, this.databaseType));
|
||||
}
|
||||
|
||||
@@ -1296,9 +1299,11 @@ export function shouldAutoOpenSqlCompletion(sql: string, cursor: number): boolea
|
||||
const previousChar = sql[cursor - 1];
|
||||
if (!previousChar) return false;
|
||||
if (/\bon\s+$/i.test(sql.slice(0, cursor))) return true;
|
||||
if (isAfterJoinModifierContext(sql.slice(0, cursor))) return true;
|
||||
if (/\bcall\s+(?:[A-Za-z_][\w$]*\.)?$/i.test(sql.slice(0, cursor))) return true;
|
||||
if (/[,;()[\]]/.test(previousChar)) return false;
|
||||
const context = getSqlCompletionContext(sql, cursor);
|
||||
if (previousChar === "(" && context.insertTable) return true;
|
||||
if (/[,;()[\]]/.test(previousChar)) return false;
|
||||
if (context.exclusiveTableSuggestions || context.exclusiveColumnSuggestions || context.exclusiveRoutineSuggestions || context.suggestTables) {
|
||||
return true;
|
||||
}
|
||||
@@ -1580,7 +1585,7 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet
|
||||
const beforeToken = beforeCursor.slice(0, Math.max(0, bareStart)).trimEnd();
|
||||
const lastWord = /([A-Za-z_][\w$]*)$/.exec(beforeToken)?.[1]?.toLowerCase() ?? "";
|
||||
|
||||
const referencedTables = extractReferencedTables(fullStatement);
|
||||
let referencedTables = extractReferencedTables(fullStatement);
|
||||
|
||||
// Merge CTE definitions into referenced tables
|
||||
const cteDefs = extractCteDefinitions(fullStatement);
|
||||
@@ -1613,6 +1618,10 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet
|
||||
const exclusiveTableSuggestions = EXCLUSIVE_TABLE_TRIGGER_KEYWORDS.has(lastWord) || (JOIN_MODIFIERS.has(lastWord) && isFollowedByJoin(beforeToken)) || isInTableListContext(beforeToken);
|
||||
const autoAliasTableCompletions = lastWord === "from" || lastWord === "join" || (JOIN_MODIFIERS.has(lastWord) && isFollowedByJoin(beforeToken)) || isInTableListContext(beforeToken);
|
||||
const exclusiveColumnSuggestions = !!qualifier && !exclusiveTableSuggestions && !insertInfo;
|
||||
const activePrefixIsCte = cteDefs.some((cte) => normalizeIdentifierPart(cte.name) === normalizeIdentifierPart(prefix));
|
||||
if (exclusiveTableSuggestions && prefix && !activePrefixIsCte && referencedTables.length > 1) {
|
||||
referencedTables = removeActiveTableCompletionReference(referencedTables, prefix, qualifier);
|
||||
}
|
||||
|
||||
// Check if we're in a context where columns are expected
|
||||
const selectListColumnContext = isInSelectListContext(beforeCursor);
|
||||
@@ -1621,7 +1630,7 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet
|
||||
const prioritizeSelectAliases = isInOrderOrGroupByContext(beforeCursor);
|
||||
const inCallRoutineContext = isCallRoutineContext(beforeCursor);
|
||||
const inPotentialPackageMemberContext = !!qualifier && !exclusiveTableSuggestions && !insertInfo && !oracleTableFunctionContext;
|
||||
const suggestColumns = !!qualifier || !!updateInfo?.inSetClause || (inColumnContext && referencedTables.length > 0);
|
||||
const suggestColumns = !!qualifier || !!updateInfo?.inSetClause || !!insertInfo || (inColumnContext && referencedTables.length > 0);
|
||||
const preferColumnsOverGlobalRoutines = suggestColumns && referencedTables.length > 0 && !qualifier;
|
||||
const suggestRoutines = inCallRoutineContext || oracleTableFunctionContext || inPotentialPackageMemberContext || (!preferColumnsOverGlobalRoutines && !exclusiveTableSuggestions && !exclusiveColumnSuggestions && !insertInfo && prefix.length >= 2);
|
||||
|
||||
@@ -1637,6 +1646,7 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet
|
||||
oracleTableFunctionContext,
|
||||
afterTableTrigger,
|
||||
lastWord,
|
||||
statementKind,
|
||||
suggestColumns,
|
||||
suggestRoutines,
|
||||
});
|
||||
@@ -1674,6 +1684,17 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet
|
||||
};
|
||||
}
|
||||
|
||||
function removeActiveTableCompletionReference(referencedTables: SqlCompletionReferencedTable[], prefix: string, qualifier?: string): SqlCompletionReferencedTable[] {
|
||||
const activeName = normalizeIdentifierPart(prefix);
|
||||
const activeQualifier = qualifier ? normalizeIdentifierPart(qualifier) : undefined;
|
||||
return referencedTables.filter((table) => {
|
||||
if (table.alias) return true;
|
||||
if (normalizeIdentifierPart(table.name) !== activeName) return true;
|
||||
if (activeQualifier && table.schema && normalizeIdentifierPart(table.schema) !== activeQualifier) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function detectCompletionContextKind(options: {
|
||||
qualifier?: string;
|
||||
exclusiveTableSuggestions: boolean;
|
||||
@@ -1684,6 +1705,7 @@ function detectCompletionContextKind(options: {
|
||||
oracleTableFunctionContext: boolean;
|
||||
afterTableTrigger: boolean;
|
||||
lastWord: string;
|
||||
statementKind: SqlStatementKind;
|
||||
suggestColumns: boolean;
|
||||
suggestRoutines: boolean;
|
||||
}): SqlCompletionContextKind {
|
||||
@@ -1692,7 +1714,10 @@ function detectCompletionContextKind(options: {
|
||||
if (options.inCallRoutineContext) return "exec";
|
||||
if (options.qualifier && options.exclusiveColumnSuggestions) return "alias_column";
|
||||
if (options.oracleTableFunctionContext || options.suggestRoutines) return "routine";
|
||||
if (options.exclusiveTableSuggestions || options.afterTableTrigger) return options.lastWord === "join" ? "join" : "table";
|
||||
if (options.exclusiveTableSuggestions || options.afterTableTrigger) {
|
||||
if (options.statementKind === "insert" && options.lastWord === "into") return "insert_target";
|
||||
return options.lastWord === "join" ? "join" : "table";
|
||||
}
|
||||
if (options.suggestColumns) return options.qualifier ? "alias_column" : "column";
|
||||
return "keyword";
|
||||
}
|
||||
@@ -1962,7 +1987,7 @@ function detectInsertColumnListContext(beforeCursor: string): { table: string; s
|
||||
// real names instead of placeholder string contents.
|
||||
const cleaned = beforeCursor.replace(/'[^']*'/g, "''");
|
||||
const identifier = '(?:"[^"]+"|`[^`]+`|[A-Za-z_][\\w$]*)';
|
||||
const qualifiedIdentifier = `${identifier}(?:\\.${identifier})?`;
|
||||
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];
|
||||
@@ -2007,6 +2032,7 @@ function detectOracleTableFunctionContext(beforeCursor: string): boolean {
|
||||
function preferredKeywordsForCompletion(beforeCursor: string, beforeToken: string, selectListColumnContext: boolean, exclusiveTableSuggestions: boolean, updateInfo: ReturnType<typeof detectUpdateCompletionContext>, deleteInfo: ReturnType<typeof detectDeleteCompletionContext>): string[] {
|
||||
const keywords: string[] = [];
|
||||
if (selectListColumnContext && hasSelectListExpression(beforeCursor)) keywords.push("FROM");
|
||||
if (isAfterJoinModifierContext(beforeCursor)) keywords.push("JOIN");
|
||||
if (!exclusiveTableSuggestions && isAfterSelectBodyExpression(beforeToken)) keywords.push("LIMIT");
|
||||
if (isAfterConditionExpression(beforeToken)) keywords.push("AND", "OR");
|
||||
if (updateInfo?.afterTarget) keywords.push("SET");
|
||||
@@ -2033,7 +2059,7 @@ function isAfterSelectBodyExpression(beforeToken: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
const SELECT_BODY_INCOMPLETE_TAIL_KEYWORDS = new Set(["where", "and", "or", "not", "having", "group", "order", "by", "on", "is", "in", "like", "between"]);
|
||||
const SELECT_BODY_INCOMPLETE_TAIL_KEYWORDS = new Set(["where", "and", "or", "not", "having", "group", "order", "by", "on", "is", "in", "like", "between", ...JOIN_MODIFIERS]);
|
||||
const CONDITION_INCOMPLETE_TAIL_KEYWORDS = new Set(["where", "and", "or", "not", "having", "on", "is", "in", "like", "between", "exists"]);
|
||||
|
||||
function isAfterConditionExpression(beforeToken: string): boolean {
|
||||
@@ -2044,6 +2070,35 @@ function isAfterConditionExpression(beforeToken: string): boolean {
|
||||
return isExpressionTailComplete(cleaned);
|
||||
}
|
||||
|
||||
function isAfterJoinModifierContext(beforeCursor: string): boolean {
|
||||
const cleaned = stripSqlLiterals(beforeCursor).trimEnd();
|
||||
const modifier = /\b([A-Za-z_][\w$]*)\s*$/.exec(cleaned)?.[1]?.toLowerCase();
|
||||
if (!modifier || !JOIN_MODIFIERS.has(modifier)) return false;
|
||||
|
||||
const beforeModifier = cleaned.slice(0, cleaned.length - modifier.length).trimEnd();
|
||||
const lastTableIntro = Math.max(lastTopLevelKeywordIndex(beforeModifier, "from"), lastTopLevelKeywordIndex(beforeModifier, "join"));
|
||||
if (lastTableIntro < 0) return false;
|
||||
|
||||
const lastClauseBoundary = Math.max(
|
||||
lastTopLevelKeywordIndex(beforeModifier, "where"),
|
||||
lastTopLevelKeywordIndex(beforeModifier, "group"),
|
||||
lastTopLevelKeywordIndex(beforeModifier, "order"),
|
||||
lastTopLevelKeywordIndex(beforeModifier, "having"),
|
||||
lastTopLevelKeywordIndex(beforeModifier, "limit"),
|
||||
lastTopLevelKeywordIndex(beforeModifier, "offset"),
|
||||
lastTopLevelKeywordIndex(beforeModifier, "union"),
|
||||
lastTopLevelKeywordIndex(beforeModifier, "intersect"),
|
||||
lastTopLevelKeywordIndex(beforeModifier, "except"),
|
||||
);
|
||||
if (lastClauseBoundary > lastTableIntro) return false;
|
||||
|
||||
const tableSegment = beforeModifier
|
||||
.slice(lastTableIntro)
|
||||
.replace(/^\s*(?:from|join)\b/i, "")
|
||||
.trim();
|
||||
return tableSegment.length > 0;
|
||||
}
|
||||
|
||||
function hasActiveConditionClause(sql: string): boolean {
|
||||
const lower = sql.toLowerCase();
|
||||
const whereIndex = lastTopLevelKeywordIndex(lower, "where");
|
||||
@@ -2195,11 +2250,13 @@ function extractReferencedTables(sql: string): SqlCompletionReferencedTable[] {
|
||||
"respect",
|
||||
]);
|
||||
|
||||
const pattern = /\b(?:from|join|update|into|apply)\s+((?:"[^"]+"|`[^`]+`|[^\s,;()]+)(?:\.(?:"[^"]+"|`[^`]+`|[^\s,;()]+))?)(?:\s+(?:as\s+)?([A-Za-z_][\w$]*))?/gi;
|
||||
const pattern = /\b(?:from|join|update|apply)\s+((?:"[^"]+"|`[^`]+`|[^\s,;()]+)(?:\.(?:"[^"]+"|`[^`]+`|[^\s,;()]+))?)(?:\s+(?:as\s+)?([A-Za-z_][\w$]*))?/gi;
|
||||
const referenced: SqlCompletionReferencedTable[] = [];
|
||||
for (const match of sql.matchAll(pattern)) {
|
||||
const rawName = match[1];
|
||||
const alias = match[2];
|
||||
const quotedName = !!rawName && (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;
|
||||
if (isElasticsearchStyleIndexName(rawName)) {
|
||||
@@ -2488,7 +2545,9 @@ function splitQualifiedName(input: string): [string | undefined, string | undefi
|
||||
if (current.trim()) parts.push(current.trim());
|
||||
|
||||
const unquoted = parts.map((p) => unquoteIdentifier(p)).filter(Boolean);
|
||||
if (unquoted.length >= 2) return [unquoted[0], unquoted[1]];
|
||||
// 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];
|
||||
}
|
||||
|
||||
@@ -2589,7 +2648,7 @@ function buildSchemaItems(prefix: string, schemas: string[], dialect?: "mysql" |
|
||||
type: "schema" as const,
|
||||
detail: "schema",
|
||||
apply: `${quoteSqlIdentifier(schema, dialect)}.`,
|
||||
boost: computeBoost(schema, prefix) + 1500,
|
||||
boost: computeBoost(schema, prefix) + 700,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -3633,6 +3692,24 @@ function isOracleLikeDatabase(databaseType?: DatabaseType): boolean {
|
||||
return databaseType === "oracle" || databaseType === "oceanbase-oracle";
|
||||
}
|
||||
|
||||
function buildJoinModifierKeywordItems(prefix: string, keywordCase?: SqlKeywordCase): SqlCompletionItem[] {
|
||||
if (!prefix) return [];
|
||||
return JOIN_MODIFIER_KEYWORD_PHRASES.filter((keyword) => matchesPrefix(keyword, prefix)).map((keyword) => {
|
||||
const label = applySqlKeywordCase(keyword, keywordCase);
|
||||
return {
|
||||
label,
|
||||
type: "keyword" as const,
|
||||
apply: `${label} `,
|
||||
detail: "join keyword",
|
||||
boost: computeBoost(keyword, prefix) + 1300,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function isPendingJoinKeywordContext(context: SqlCompletionContext): boolean {
|
||||
return !context.prefix && context.preferredKeywords.includes("JOIN") && !!context.tableTriggerWord && JOIN_MODIFIERS.has(context.tableTriggerWord);
|
||||
}
|
||||
|
||||
function buildKeywordItems(prefix: string, context: SqlCompletionContext, databaseType?: DatabaseType, keywordCase?: SqlKeywordCase): SqlCompletionItem[] {
|
||||
const isDml = context.statementKind === "select" || context.statementKind === "insert" || context.statementKind === "update" || context.statementKind === "delete";
|
||||
const showDdl = !isDml || context.suggestTables;
|
||||
|
||||
@@ -544,6 +544,35 @@ test("suggests tables after LEFT JOIN", () => {
|
||||
assert.ok(items.some((item) => item.label === "user_profiles" && item.type === "table"));
|
||||
});
|
||||
|
||||
test("suggests compound JOIN keywords while typing a join modifier", () => {
|
||||
const sql = "select * from users le";
|
||||
const items = buildSqlCompletionItems(sql, sql.length, {
|
||||
tables,
|
||||
columnsByTable,
|
||||
});
|
||||
|
||||
const leftJoinIndex = items.findIndex((item) => item.type === "keyword" && item.label === "LEFT JOIN");
|
||||
const leftIndex = items.findIndex((item) => item.type === "keyword" && item.label === "LEFT");
|
||||
|
||||
assert.ok(leftJoinIndex >= 0);
|
||||
assert.ok(leftIndex >= 0);
|
||||
assert.ok(leftJoinIndex < leftIndex, "LEFT JOIN should rank ahead of the single LEFT token");
|
||||
assert.equal(items[leftJoinIndex]?.apply, "LEFT JOIN ");
|
||||
});
|
||||
|
||||
test("suggests JOIN after a join modifier", () => {
|
||||
const sql = "select * from users left ";
|
||||
const items = buildSqlCompletionItems(sql, sql.length, {
|
||||
tables,
|
||||
columnsByTable,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
items.map((item) => [item.label, item.type]),
|
||||
[["JOIN", "keyword"]],
|
||||
);
|
||||
});
|
||||
|
||||
test("suggests tables after comma in FROM clause", () => {
|
||||
const sql = "select * from users, or";
|
||||
const items = buildSqlCompletionItems(sql, sql.length, {
|
||||
@@ -829,7 +858,18 @@ test("auto-opens completion after word characters and explicit dot qualifiers",
|
||||
});
|
||||
|
||||
test("auto-opens table completion immediately after FROM context whitespace", () => {
|
||||
for (const sql of ["select * from ", "select * from users join ", "select * from users, "]) {
|
||||
for (const sql of ["select * from ", "select * from users join ", "select * from users, ", "insert into "]) {
|
||||
assert.equal(shouldAutoOpenSqlCompletion(sql, sql.length), true, sql);
|
||||
}
|
||||
});
|
||||
|
||||
test("auto-opens keyword completion after JOIN modifier whitespace", () => {
|
||||
assert.equal(shouldAutoOpenSqlCompletion("select * from users left ", "select * from users left ".length), true);
|
||||
assert.equal(shouldAutoOpenSqlCompletion("select left ", "select left ".length), false);
|
||||
});
|
||||
|
||||
test("auto-opens INSERT target column completion after column-list punctuation", () => {
|
||||
for (const sql of ["insert into users (", "insert into users (id, "]) {
|
||||
assert.equal(shouldAutoOpenSqlCompletion(sql, sql.length), true, sql);
|
||||
}
|
||||
});
|
||||
@@ -851,6 +891,25 @@ test("suggests table names for empty FROM context prefix", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("suggests table names for empty INSERT INTO target prefix", () => {
|
||||
const items = buildSqlCompletionItems("insert into ", "insert into ".length, {
|
||||
tables,
|
||||
columnsByTable,
|
||||
schemas: ["public", "express-vue", "mall"],
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
items.slice(0, 4).map((item) => [item.label, item.type]),
|
||||
[
|
||||
["users", "table"],
|
||||
["user_profiles", "table"],
|
||||
["orders", "table"],
|
||||
["ticket_summary", "table"],
|
||||
],
|
||||
);
|
||||
assert.ok(items.findIndex((item) => item.type === "schema" && item.label === "public") > 3);
|
||||
});
|
||||
|
||||
test("suggests matching table names for partial table input", () => {
|
||||
const items = buildSqlCompletionItems("select * from ihli", "select * from ihli".length, {
|
||||
tables: [{ name: "ihli_data", schema: "public", type: "table" }],
|
||||
@@ -864,6 +923,15 @@ test("suggests matching table names for partial table input", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps completed references while removing active JOIN table prefixes", () => {
|
||||
const context = getSqlCompletionContext("select * from users join ex", "select * from users join ex".length);
|
||||
|
||||
assert.deepEqual(
|
||||
context.referencedTables.map((table) => table.name),
|
||||
["users"],
|
||||
);
|
||||
});
|
||||
|
||||
test("ranks exact table matches above prefix and fuzzy matches", () => {
|
||||
const items = buildSqlCompletionItems("select * from toh", "select * from toh".length, {
|
||||
tables: [
|
||||
@@ -1289,6 +1357,7 @@ test("detects INSERT INTO column list context", () => {
|
||||
const context = getSqlCompletionContext("INSERT INTO users (", "INSERT INTO users (".length);
|
||||
assert.equal(context.insertTable, "users");
|
||||
assert.equal(context.exclusiveColumnSuggestions, true);
|
||||
assert.deepEqual(context.referencedTables, []);
|
||||
});
|
||||
|
||||
test("detects INSERT INTO with schema-qualified table", () => {
|
||||
@@ -1297,6 +1366,14 @@ test("detects INSERT INTO with schema-qualified table", () => {
|
||||
assert.equal(context.insertSchema, "public");
|
||||
});
|
||||
|
||||
test("detects INSERT INTO column list with three-part qualified table", () => {
|
||||
const sql = "INSERT INTO analytics.public.users (";
|
||||
const context = getSqlCompletionContext(sql, sql.length);
|
||||
|
||||
assert.equal(context.insertTable, "users");
|
||||
assert.equal(context.insertSchema, "public");
|
||||
});
|
||||
|
||||
test("detects MySQL backtick-qualified INSERT INTO column list context", () => {
|
||||
const sql = "INSERT INTO `other_db`.`orders` (";
|
||||
const context = getSqlCompletionContext(sql, sql.length);
|
||||
@@ -1304,6 +1381,24 @@ test("detects MySQL backtick-qualified INSERT INTO column list context", () => {
|
||||
assert.equal(context.insertSchema, "other_db");
|
||||
});
|
||||
|
||||
test("does not treat partial INSERT INTO targets as referenced tables", () => {
|
||||
const context = getSqlCompletionContext("INSERT INTO ord", "INSERT INTO ord".length);
|
||||
|
||||
assert.equal(context.contextKind, "insert_target");
|
||||
assert.equal(context.suggestTables, true);
|
||||
assert.equal(context.exclusiveTableSuggestions, true);
|
||||
assert.deepEqual(context.referencedTables, []);
|
||||
});
|
||||
|
||||
test("does not treat following SQL keywords as table references while completing table names", () => {
|
||||
const sql = "SELECT *\nFROM \nLIMIT 100";
|
||||
const cursor = "SELECT *\nFROM ".length;
|
||||
const context = getSqlCompletionContext(sql, cursor);
|
||||
|
||||
assert.equal(context.suggestTables, true);
|
||||
assert.deepEqual(context.referencedTables, []);
|
||||
});
|
||||
|
||||
test("suggests columns for INSERT INTO target table", () => {
|
||||
const items = buildSqlCompletionItems("INSERT INTO users (", "INSERT INTO users (".length, {
|
||||
tables,
|
||||
@@ -1452,6 +1547,11 @@ test("suggests schema names alongside tables in FROM context", () => {
|
||||
schemas: ["public", "private", "audit"],
|
||||
});
|
||||
const schemaItems = items.filter((item) => item.type === "schema");
|
||||
const firstSchemaIndex = items.findIndex((item) => item.type === "schema");
|
||||
const firstTableIndex = items.findIndex((item) => item.type === "table");
|
||||
|
||||
assert.ok(firstTableIndex >= 0);
|
||||
assert.ok(firstSchemaIndex > firstTableIndex);
|
||||
assert.equal(schemaItems.length, 3);
|
||||
assert.ok(schemaItems.some((item) => item.label === "public"));
|
||||
assert.equal(schemaItems[0]?.apply, "public.");
|
||||
|
||||
Reference in New Issue
Block a user