diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 4a77acbc5a..c18ddb487d 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -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, 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 {}); } } - 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 {}); } } - 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 table.alias?.toLowerCase() === qualifier || table.name.toLowerCase() === qualifier || (!!qualifiedColumnTarget && completionTablesMatch(table, qualifiedColumnTarget))); } +function isTableNameCompletionContext(completionContext: ReturnType): 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); } } } 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 73715f935d..678eec4212 100644 --- a/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts @@ -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"]); + }); }); diff --git a/apps/desktop/src/lib/sql/semantic/completion.ts b/apps/desktop/src/lib/sql/semantic/completion.ts index f60ea7332d..793a6d8442 100644 --- a/apps/desktop/src/lib/sql/semantic/completion.ts +++ b/apps/desktop/src/lib/sql/semantic/completion.ts @@ -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; diff --git a/apps/desktop/src/lib/sql/semantic/model.ts b/apps/desktop/src/lib/sql/semantic/model.ts index a52ead7877..31fdf8f495 100644 --- a/apps/desktop/src/lib/sql/semantic/model.ts +++ b/apps/desktop/src/lib/sql/semantic/model.ts @@ -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" }; } diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index f0366f3c5c..11ec555954 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -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, deleteInfo: ReturnType): 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; diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index c28e2d4f53..ff59115fbb 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -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.");