diff --git a/apps/desktop/src/lib/sqlCompletion.ts b/apps/desktop/src/lib/sqlCompletion.ts index f09e17edb..1e5b75e5e 100644 --- a/apps/desktop/src/lib/sqlCompletion.ts +++ b/apps/desktop/src/lib/sqlCompletion.ts @@ -258,6 +258,7 @@ const SQL_KEYWORDS = [ ]; 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 MAX_TABLE_COMPLETION_ITEMS = 200; @@ -349,6 +350,8 @@ export interface SqlCompletionContext { suggestColumns: boolean; suggestKeywords: boolean; suggestJoinConditions: boolean; + exclusiveTableSuggestions: boolean; + exclusiveColumnSuggestions: boolean; prioritizeSelectAliases: boolean; selectAliases: string[]; referencedTables: SqlCompletionReferencedTable[]; @@ -382,14 +385,16 @@ export function buildSqlCompletionItemsFromContext( ): SqlCompletionItem[] { const items: SqlCompletionItem[] = []; - items.push(...buildSnippetItems(context.prefix)); - items.push(...buildFunctionSnippetItems(context.prefix)); + if (!context.exclusiveTableSuggestions && !context.exclusiveColumnSuggestions) { + items.push(...buildSnippetItems(context.prefix)); + items.push(...buildFunctionSnippetItems(context.prefix)); + } - if (context.prioritizeSelectAliases) { + if (!context.exclusiveTableSuggestions && !context.exclusiveColumnSuggestions && context.prioritizeSelectAliases) { items.push(...buildSelectAliasItems(context)); } - if (context.suggestJoinConditions) { + if (!context.exclusiveTableSuggestions && !context.exclusiveColumnSuggestions && context.suggestJoinConditions) { items.push(...buildJoinConditionItems(context, input.columnsByTable)); } @@ -398,11 +403,11 @@ export function buildSqlCompletionItemsFromContext( items.push(...buildKeywordItems(context.prefix)); } - if (context.suggestColumns) { + if (!context.exclusiveTableSuggestions && context.suggestColumns) { items.push(...buildColumnItems(context, input.columnsByTable)); } - if (context.suggestTables) { + if (!context.exclusiveColumnSuggestions && context.suggestTables) { items.push(...buildTableItems(context.prefix, input.tables)); } @@ -492,8 +497,8 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet const plainMatch = /([A-Za-z_][\w$]*)$/.exec(beforeCursor); const prefix = dottedMatch?.[2] ?? plainMatch?.[1] ?? ""; const qualifier = dottedMatch?.[1]; - const bareStart = qualifier - ? beforeCursor.length - prefix.length + const bareStart = dottedMatch + ? beforeCursor.length - dottedMatch[0].length : beforeCursor.length - (plainMatch?.[1]?.length ?? 0); const beforeToken = beforeCursor.slice(0, Math.max(0, bareStart)).trimEnd(); const lastWord = /([A-Za-z_][\w$]*)$/.exec(beforeToken)?.[1]?.toLowerCase() ?? ""; @@ -504,6 +509,11 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet TABLE_TRIGGER_KEYWORDS.has(lastWord) || (JOIN_MODIFIERS.has(lastWord) && isFollowedByJoin(beforeToken)) || isInTableListContext(beforeToken); + const exclusiveTableSuggestions = + EXCLUSIVE_TABLE_TRIGGER_KEYWORDS.has(lastWord) || + (JOIN_MODIFIERS.has(lastWord) && isFollowedByJoin(beforeToken)) || + isInTableListContext(beforeToken); + const exclusiveColumnSuggestions = !!qualifier && !exclusiveTableSuggestions; // Check if we're in a context where columns are expected const inColumnContext = isInColumnContext(beforeCursor); @@ -520,8 +530,10 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet // 2. We're in a column context (WHERE, ON, SELECT, etc.) AND there are referenced tables suggestColumns: !!qualifier || (inColumnContext && referencedTables.length > 0), // Always suggest keywords - suggestKeywords: true, + suggestKeywords: !exclusiveTableSuggestions && !exclusiveColumnSuggestions, suggestJoinConditions: inJoinConditionContext && referencedTables.length >= 2, + exclusiveTableSuggestions, + exclusiveColumnSuggestions, prioritizeSelectAliases, selectAliases: prioritizeSelectAliases ? extractSelectAliases(fullStatement) : [], referencedTables, diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index ca97d74e2..2f83b3996 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -12,6 +12,7 @@ const tables: SqlCompletionTable[] = [ { name: "users", schema: "public", type: "table" }, { name: "user_profiles", schema: "public", type: "table" }, { name: "orders", schema: "public", type: "table" }, + { name: "ticket_summary", schema: "public", type: "view" }, ]; const columnsByTable = new Map([ @@ -84,6 +85,33 @@ test("suggests columns for an explicit alias qualifier", () => { ); }); +test("suggests only matching columns for an explicit alias qualifier prefix", () => { + const sql = "select u.na from public.users u join public.orders o on u.id = o.user_id"; + const cursor = "select u.na".length; + const items = buildSqlCompletionItems(sql, cursor, { + tables, + columnsByTable, + }); + + assert.deepEqual( + items.map((item) => [item.label, item.type, item.detail]), + [["name", "column", "public.users"]], + ); +}); + +test("keeps explicit alias column suggestions scoped to the alias table", () => { + const sql = "select * from public.users u join public.orders o on u.id = o.user_id where o.st"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables, + columnsByTable, + }); + + assert.deepEqual( + items.map((item) => [item.label, item.type, item.detail]), + [["status", "column", "public.orders"]], + ); +}); + test("suggests columns from referenced tables in select list", () => { const sql = "select na from public.users u join public.orders o on u.id = o.user_id"; const cursor = "select na".length; @@ -127,15 +155,62 @@ test("suggests keywords when typing without context", () => { assert.ok(items.some((item) => item.type === "keyword" && item.label === "USING")); }); -test("always includes keywords alongside table suggestions", () => { +test("suggests only matching table names after FROM object input", () => { const sql = "select * from us"; const items = buildSqlCompletionItems(sql, sql.length, { tables, columnsByTable, }); - assert.ok(items.some((item) => item.type === "table")); - assert.ok(items.some((item) => item.type === "keyword" && item.label === "USING")); + assert.ok(items.length > 0); + assert.deepEqual([...new Set(items.map((item) => item.type))], ["table"]); + assert.deepEqual( + items.map((item) => item.label), + ["users", "user_profiles"], + ); +}); + +test("keeps schema-qualified FROM object input in table suggestion mode", () => { + const sql = "select * from public.us"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables, + columnsByTable, + }); + + assert.ok(items.length > 0); + assert.deepEqual([...new Set(items.map((item) => item.type))], ["table"]); + assert.deepEqual( + items.map((item) => item.label), + ["users", "user_profiles"], + ); +}); + +test("includes views in exclusive FROM object suggestions", () => { + const sql = "select * from tick"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables, + columnsByTable, + }); + + assert.deepEqual( + items.map((item) => [item.label, item.type, item.detail]), + [["ticket_summary", "table", "public.ticket_summary"]], + ); +}); + +test("suggests only table names after JOIN object input", () => { + const sql = "select * from users join us"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables, + columnsByTable, + }); + + assert.ok(items.length > 0); + assert.deepEqual([...new Set(items.map((item) => item.type))], ["table"]); + assert.deepEqual( + items.map((item) => item.label), + ["users", "user_profiles"], + ); }); test("suggests SQL Server IF keyword for conditional DDL", () => {