diff --git a/apps/desktop/src/lib/sqlCompletion.ts b/apps/desktop/src/lib/sqlCompletion.ts index 977885944..20e42c369 100644 --- a/apps/desktop/src/lib/sqlCompletion.ts +++ b/apps/desktop/src/lib/sqlCompletion.ts @@ -1547,6 +1547,9 @@ 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 preferColumnsOverGlobalRoutines = suggestColumns && referencedTables.length > 0 && !qualifier; + const suggestRoutines = inCallRoutineContext || oracleTableFunctionContext || inPotentialPackageMemberContext || (!preferColumnsOverGlobalRoutines && !exclusiveTableSuggestions && !exclusiveColumnSuggestions && !insertInfo && prefix.length >= 2); const statementKind = detectStatementKind(beforeCursor || fullStatement); const preferredKeywords = preferredKeywordsForCompletion(updateInfo, deleteInfo); @@ -1556,9 +1559,9 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet qualifier: insertInfo ? undefined : qualifier, qualifierParts: insertInfo ? undefined : qualifierParts, suggestTables: insertInfo ? false : afterTableTrigger, - suggestColumns: !!qualifier || !!updateInfo?.inSetClause || (inColumnContext && referencedTables.length > 0), + suggestColumns, suggestKeywords: !exclusiveTableSuggestions && !exclusiveColumnSuggestions && !insertInfo && !inCallRoutineContext, - suggestRoutines: inCallRoutineContext || oracleTableFunctionContext || inPotentialPackageMemberContext || (!exclusiveTableSuggestions && !exclusiveColumnSuggestions && !insertInfo && prefix.length >= 2), + suggestRoutines, suggestJoinConditions: insertInfo ? false : inJoinConditionContext && referencedTables.length >= 2, exclusiveTableSuggestions: insertInfo ? false : exclusiveTableSuggestions, exclusiveColumnSuggestions: exclusiveColumnSuggestions || !!insertInfo || !!updateInfo?.inSetClause, @@ -1667,6 +1670,8 @@ function parseTrailingIdentifierPart(input: string, endExclusive: number): { sta function isInColumnContext(beforeCursor: string): boolean { if (!beforeCursor) return false; + if (isInSelectListContext(beforeCursor)) return true; + // Strip string literals const cleaned = beforeCursor.replace(/'[^']*'/g, "''").replace(/"[^"]*"/g, "''"); @@ -1692,6 +1697,77 @@ function isInColumnContext(beforeCursor: string): boolean { return false; } +function isInSelectListContext(beforeCursor: string): boolean { + let depth = 0; + let inSingleQuote = false; + let inDoubleQuote = false; + let inBacktick = false; + const selectOpenByDepth = new Map(); + + for (let i = 0; i < beforeCursor.length; i++) { + const ch = beforeCursor[i] ?? ""; + const next = beforeCursor[i + 1] ?? ""; + + if (inSingleQuote) { + if (ch === "\\" && next) { + i++; + } else if (ch === "'" && next === "'") { + i++; + } else if (ch === "'") { + inSingleQuote = false; + } + continue; + } + if (inDoubleQuote) { + if (ch === '"' && next === '"') { + i++; + } else if (ch === '"') { + inDoubleQuote = false; + } + continue; + } + if (inBacktick) { + if (ch === "`") inBacktick = false; + continue; + } + + if (ch === "'") { + inSingleQuote = true; + continue; + } + if (ch === '"') { + inDoubleQuote = true; + continue; + } + if (ch === "`") { + inBacktick = true; + continue; + } + if (ch === "(") { + depth++; + continue; + } + if (ch === ")") { + selectOpenByDepth.delete(depth); + depth = Math.max(0, depth - 1); + continue; + } + if (!/[A-Za-z_]/.test(ch)) continue; + + let end = i + 1; + while (end < beforeCursor.length && /[A-Za-z0-9_$]/.test(beforeCursor[end] ?? "")) end++; + const word = beforeCursor.slice(i, end).toLowerCase(); + if (word === "select") { + selectOpenByDepth.set(depth, true); + } else if (word === "from") { + selectOpenByDepth.set(depth, false); + } + i = end - 1; + } + + return selectOpenByDepth.get(depth) === true; +} + function isInJoinConditionContext(beforeCursor: string): boolean { const cleaned = beforeCursor .replace(/'[^']*'/g, "''") diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index 7a9fcb6b7..fe39d1f5b 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -1841,6 +1841,28 @@ test("prefix matches still rank above fuzzy matches", () => { assert.equal(items[0]?.label, "name"); }); +test("suggests columns after multiple select-list expressions", () => { + const sql = "select project_name, review_accountant, doc from ypmng_archive LIMIT 100"; + const cursor = "select project_name, review_accountant, doc".length; + const items = buildSqlCompletionItems(sql, cursor, { + tables: [{ name: "ypmng_archive", type: "table" }], + objects: [{ name: "proc_get_ypfmm_pd_score_list_with_template_doc_id", schema: "y_jnpf", type: "procedure" }], + columnsByTable: new Map([ + [ + "ypmng_archive", + [ + { name: "doc_id", table: "ypmng_archive", dataType: "bigint" }, + { name: "project_name", table: "ypmng_archive", dataType: "varchar" }, + { name: "review_accountant", table: "ypmng_archive", dataType: "varchar" }, + ], + ], + ]), + }); + + assert.ok(items.some((item) => item.label === "doc_id" && item.type === "column")); + assert.ok(!items.some((item) => item.type === "function" && item.label.startsWith("proc_"))); +}); + // --- Type-aware comparison hints --- test("suggests NULL and IS NULL after comparison operator", () => {