diff --git a/apps/desktop/src/lib/sqlCompletion.ts b/apps/desktop/src/lib/sqlCompletion.ts index 1e9bcd5708..8096b975f4 100644 --- a/apps/desktop/src/lib/sqlCompletion.ts +++ b/apps/desktop/src/lib/sqlCompletion.ts @@ -759,8 +759,9 @@ export function shouldAutoOpenSqlCompletion(sql: string, cursor: number): boolea } export function getSqlCompletionResultValidFor(sql: string, cursor: number): RegExp | undefined { - const context = getSqlCompletionContext(sql, cursor); - return context.suggestTables ? undefined : /^[\w$]*$/; + void sql; + void cursor; + return undefined; } export function getSqlFunctionSignatureHelp(sql: string, cursor: number): SqlFunctionSignatureHelp | null { @@ -1487,13 +1488,14 @@ function unquoteIdentifier(value: string): string { function buildTableItems(prefix: string, tables: SqlCompletionTable[]): SqlCompletionItem[] { return tables .filter((table) => matchesPrefix(table.name, prefix)) - .slice(0, MAX_TABLE_COMPLETION_ITEMS) .map((table) => ({ label: table.name, type: "table" as const, detail: table.schema ? `${table.schema}.${table.name}` : table.type, boost: computeBoost(table.name, prefix) + 1000, - })); + })) + .sort(compareCompletionItems) + .slice(0, MAX_TABLE_COMPLETION_ITEMS); } function buildSchemaItems(prefix: string, schemas: string[]): SqlCompletionItem[] { @@ -1773,7 +1775,8 @@ function buildColumnItems( detail: buildColumnDetail(column), boost: computeBoost(column.displayLabel, context.prefix) + keyBoost, }; - }); + }) + .sort(compareCompletionItems); } function isKeyColumn(name: string): boolean { @@ -2126,18 +2129,18 @@ function getHistoryBoost(label: string, type: string): number { function dedupeAndSort(items: SqlCompletionItem[]): SqlCompletionItem[] { const seen = new Set(); - return items - .sort((left, right) => { - const leftBonus = getHistoryBoost(left.label, left.type); - const rightBonus = getHistoryBoost(right.label, right.type); - return right.boost + rightBonus - (left.boost + leftBonus); - }) - .filter((item) => { - const key = `${item.type}:${item.label}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); + return items.sort(compareCompletionItems).filter((item) => { + const key = `${item.type}:${item.label}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function compareCompletionItems(left: SqlCompletionItem, right: SqlCompletionItem): number { + const leftBonus = getHistoryBoost(left.label, left.type); + const rightBonus = getHistoryBoost(right.label, right.type); + return right.boost + rightBonus - (left.boost + leftBonus); } function findActiveFunctionOpenParen(sqlBeforeCursor: string): number | null { diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 616aeff789..ec58fbc26a 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -1408,6 +1408,7 @@ export const useConnectionStore = defineStore("connection", () => { schema?: string, ): Promise { const normalizedFilter = filter.trim().toLowerCase(); + const relaxedFilter = relaxedCompletionTableFilter(normalizedFilter); const cacheKey = `${connectionId}:${database}:${normalizedFilter}:${limit ?? ""}:${schema ?? ""}`; if (completionTablesCache.value[cacheKey]) { return completionTablesCache.value[cacheKey]; @@ -1440,7 +1441,37 @@ export const useConnectionStore = defineStore("connection", () => { results.push(...group); } } - const limitedTables = limit ? results.slice(0, limit) : results; + if (results.length === 0 && relaxedFilter) { + for (let i = 0; i < schemas.length && results.length < (limit ?? Infinity); i += batchSize) { + const batch = schemas.slice(i, i + batchSize); + const batchResults = await Promise.all( + batch.map(async (s) => { + try { + const tables = await api.listTables( + connectionId, + database, + s, + relaxedFilter, + expandedCompletionLimit(limit), + ); + return tables.map((table) => ({ + name: table.name, + schema: s, + type: table.table_type === "VIEW" ? ("view" as const) : ("table" as const), + })) as SqlCompletionTable[]; + } catch { + return [] as SqlCompletionTable[]; + } + }), + ); + for (const group of batchResults) { + results.push(...group); + } + } + } + const limitedTables = limit + ? dedupeCompletionTables(results).slice(0, expandedCompletionLimit(limit)) + : results; completionTablesCache.value[cacheKey] = limitedTables; evictOldestCacheEntries(completionTablesCache.value, COMPLETION_CACHE_MAX); return completionTablesCache.value[cacheKey]; @@ -1465,7 +1496,10 @@ export const useConnectionStore = defineStore("connection", () => { return completionTablesCache.value[cacheKey]; } - const tables = await api.listTables(connectionId, database, database, normalizedFilter, limit); + let tables = await api.listTables(connectionId, database, database, normalizedFilter, limit); + if (tables.length === 0 && relaxedFilter) { + tables = await api.listTables(connectionId, database, database, relaxedFilter, expandedCompletionLimit(limit)); + } completionTablesCache.value[cacheKey] = tables.map((table) => ({ name: table.name, type: table.table_type === "VIEW" ? ("view" as const) : ("table" as const), @@ -1474,6 +1508,28 @@ export const useConnectionStore = defineStore("connection", () => { return completionTablesCache.value[cacheKey]; } + function relaxedCompletionTableFilter(filter: string): string | undefined { + if (filter.length < 3) return undefined; + return filter.slice(0, 2); + } + + function expandedCompletionLimit(limit?: number): number | undefined { + if (!limit) return limit; + return Math.min(Math.max(limit * 3, limit), 1000); + } + + function dedupeCompletionTables(tables: SqlCompletionTable[]): SqlCompletionTable[] { + const seen = new Set(); + const deduped: SqlCompletionTable[] = []; + for (const table of tables) { + const key = `${table.schema ?? ""}.${table.name}`.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + deduped.push(table); + } + return deduped; + } + async function listCompletionColumns( connectionId: string, database: string, diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index 01747764d2..be605bdeef 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -239,8 +239,14 @@ test("suggests SQL Server IIF and CHOOSE scalar functions", () => { columnsByTable, }); - assert.ok(iifItems.some((item) => item.label === "IIF"), "IIF should appear in completion"); - assert.ok(chooseItems.some((item) => item.label === "CHOOSE"), "CHOOSE should appear in completion"); + assert.ok( + iifItems.some((item) => item.label === "IIF"), + "IIF should appear in completion", + ); + assert.ok( + chooseItems.some((item) => item.label === "CHOOSE"), + "CHOOSE should appear in completion", + ); }); test("suggests SQL Server data types in CREATE TABLE column definitions", () => { @@ -303,12 +309,35 @@ test("suggests matching table names for partial table input", () => { ); }); +test("ranks exact table matches above prefix and fuzzy matches", () => { + const items = buildSqlCompletionItems("select * from toh", "select * from toh".length, { + tables: [ + { name: "to_his_rec", schema: "public", type: "table" }, + { name: "toh", schema: "public", type: "table" }, + { name: "toh_archive", schema: "public", type: "table" }, + ], + columnsByTable, + }); + + const tableItems = items.filter((item) => item.type === "table"); + assert.deepEqual( + tableItems.map((item) => item.label), + ["toh", "toh_archive", "to_his_rec"], + ); +}); + test("does not reuse table completion results across typed prefixes", () => { const validFor = getSqlCompletionResultValidFor("select * from ", "select * from ".length); assert.equal(validFor, undefined); }); +test("does not reuse keyword completion results across typed prefixes", () => { + const validFor = getSqlCompletionResultValidFor("select * f", "select * f".length); + + assert.equal(validFor, undefined); +}); + test("auto-opens completion after ON whitespace for join conditions", () => { const sql = "select * from public.users u join public.orders o on "; @@ -526,10 +555,14 @@ test("suggests columns for INSERT INTO target table", () => { // --- Column data type in detail --- test("shows column data type in detail", () => { - const items = buildSqlCompletionItems("select id from public.users u where u.", "select id from public.users u where u.".length, { - tables, - columnsByTable, - }); + const items = buildSqlCompletionItems( + "select id from public.users u where u.", + "select id from public.users u where u.".length, + { + tables, + columnsByTable, + }, + ); const emailColumn = items.find((item) => item.label === "email"); assert.ok(emailColumn); assert.ok(emailColumn.detail!.includes("[varchar]")); @@ -594,7 +627,10 @@ test("snippet boost uses label when label matches prefix better than snippet pre assert.ok(snippet); // Snippet should appear in top results even when typing full keyword const topFive = items.slice(0, 5); - assert.ok(topFive.some((item) => item.label === "select *"), "select * snippet should be in top 5"); + assert.ok( + topFive.some((item) => item.label === "select *"), + "select * snippet should be in top 5", + ); }); // --- Context-aware keyword filtering --- @@ -608,8 +644,14 @@ test("hides DDL keywords in SELECT statement context", () => { assert.ok(!keywords.some((item) => item.label === "CREATE"), "CREATE should not appear in SELECT context"); assert.ok(!keywords.some((item) => item.label === "ALTER"), "ALTER should not appear in SELECT context"); assert.ok(!keywords.some((item) => item.label === "DROP"), "DROP should not appear in SELECT context"); - assert.ok(keywords.some((item) => item.label === "AND"), "AND should appear in SELECT context"); - assert.ok(keywords.some((item) => item.label === "OR"), "OR should appear in SELECT context"); + assert.ok( + keywords.some((item) => item.label === "AND"), + "AND should appear in SELECT context", + ); + assert.ok( + keywords.some((item) => item.label === "OR"), + "OR should appear in SELECT context", + ); }); test("shows DDL keywords in CREATE TABLE context", () => { @@ -619,7 +661,10 @@ test("shows DDL keywords in CREATE TABLE context", () => { columnsByTable, }); // In CREATE context, data types should appear - assert.ok(items.some((item) => item.label === "INT" || item.label === "BIGINT"), "data types should appear in CREATE"); + assert.ok( + items.some((item) => item.label === "INT" || item.label === "BIGINT"), + "data types should appear in CREATE", + ); }); test("filters data type keywords out of SELECT context", () => { @@ -642,10 +687,22 @@ test("shows qualified column names when multiple tables share column name", () = columnsByTable, }); const columns = items.filter((item) => item.type === "column"); - assert.ok(columns.some((item) => item.label === "users.id"), "should show users.id"); - assert.ok(columns.some((item) => item.label === "orders.id"), "should show orders.id"); - assert.ok(columns.some((item) => item.label === "name"), "unique name should remain unqualified"); - assert.ok(columns.some((item) => item.label === "user_id"), "unique user_id should remain unqualified"); + assert.ok( + columns.some((item) => item.label === "users.id"), + "should show users.id", + ); + assert.ok( + columns.some((item) => item.label === "orders.id"), + "should show orders.id", + ); + assert.ok( + columns.some((item) => item.label === "name"), + "unique name should remain unqualified", + ); + assert.ok( + columns.some((item) => item.label === "user_id"), + "unique user_id should remain unqualified", + ); }); // --- Window function OVER() --- @@ -774,14 +831,20 @@ test("getSqlCompletionContext returns nonAggregatedSelectColumns", () => { test("suggests join condition for same FK column in both tables", () => { const colsWithFk = new Map([ - ["public.authors", [ - { name: "id", table: "authors", schema: "public", dataType: "bigint" }, - { name: "publisher_id", table: "authors", schema: "public", dataType: "bigint" }, - ]], - ["public.books", [ - { name: "id", table: "books", schema: "public", dataType: "bigint" }, - { name: "publisher_id", table: "books", schema: "public", dataType: "bigint" }, - ]], + [ + "public.authors", + [ + { name: "id", table: "authors", schema: "public", dataType: "bigint" }, + { name: "publisher_id", table: "authors", schema: "public", dataType: "bigint" }, + ], + ], + [ + "public.books", + [ + { name: "id", table: "books", schema: "public", dataType: "bigint" }, + { name: "publisher_id", table: "books", schema: "public", dataType: "bigint" }, + ], + ], ]); const sql = "select * from public.authors a join public.books b on "; const items = buildSqlCompletionItems(sql, sql.length, { @@ -797,34 +860,43 @@ test("suggests join condition for same FK column in both tables", () => { test("suggests join condition for parent_id self-reference", () => { const colsWithParent = new Map([ - ["public.categories", [ - { name: "id", table: "categories", schema: "public", dataType: "bigint" }, - { name: "parent_id", table: "categories", schema: "public", dataType: "bigint" }, - { name: "name", table: "categories", schema: "public", dataType: "varchar" }, - ]], + [ + "public.categories", + [ + { name: "id", table: "categories", schema: "public", dataType: "bigint" }, + { name: "parent_id", table: "categories", schema: "public", dataType: "bigint" }, + { name: "name", table: "categories", schema: "public", dataType: "varchar" }, + ], + ], ]); // Self-join const sql = "select * from public.categories c1 join public.categories c2 on "; const items = buildSqlCompletionItems(sql, sql.length, { - tables: [ - { name: "categories", schema: "public", type: "table" }, - ], + tables: [{ name: "categories", schema: "public", type: "table" }], columnsByTable: colsWithParent, }); - const parentJoin = items.find((item) => item.label === "c1.parent_id = c2.id" || item.label === "c2.parent_id = c1.id"); + const parentJoin = items.find( + (item) => item.label === "c1.parent_id = c2.id" || item.label === "c2.parent_id = c1.id", + ); assert.ok(parentJoin, "should suggest parent_id = id for self-reference"); }); test("suggests join condition for created_by → id pattern", () => { const colsWithCreator = new Map([ - ["public.users", [ - { name: "id", table: "users", schema: "public", dataType: "bigint" }, - { name: "name", table: "users", schema: "public", dataType: "varchar" }, - ]], - ["public.documents", [ - { name: "id", table: "documents", schema: "public", dataType: "bigint" }, - { name: "created_by", table: "documents", schema: "public", dataType: "bigint" }, - ]], + [ + "public.users", + [ + { name: "id", table: "users", schema: "public", dataType: "bigint" }, + { name: "name", table: "users", schema: "public", dataType: "varchar" }, + ], + ], + [ + "public.documents", + [ + { name: "id", table: "documents", schema: "public", dataType: "bigint" }, + { name: "created_by", table: "documents", schema: "public", dataType: "bigint" }, + ], + ], ]); const sql = "select * from public.users u join public.documents d on "; const items = buildSqlCompletionItems(sql, sql.length, { @@ -847,7 +919,10 @@ test("fuzzy matches table names with character gaps", () => { }); // "usrs" should fuzzy-match "users" (skip 'e') const tableItems = items.filter((item) => item.type === "table"); - assert.ok(tableItems.some((item) => item.label === "users"), "should fuzzy-match users"); + assert.ok( + tableItems.some((item) => item.label === "users"), + "should fuzzy-match users", + ); }); test("fuzzy matches columns with abbreviation pattern", () => { @@ -857,7 +932,10 @@ test("fuzzy matches columns with abbreviation pattern", () => { columnsByTable, }); // "nm" should fuzzy-match "name" - assert.ok(items.some((item) => item.label === "name" && item.type === "column"), "should fuzzy-match name from 'nm'"); + assert.ok( + items.some((item) => item.label === "name" && item.type === "column"), + "should fuzzy-match name from 'nm'", + ); }); test("prefix matches still rank above fuzzy matches", () => { @@ -879,8 +957,14 @@ test("suggests NULL and IS NULL after comparison operator", () => { tables, columnsByTable, }); - assert.ok(items.some((item) => item.label === "NULL"), "should suggest NULL"); - assert.ok(items.some((item) => item.label === "IS NULL"), "should suggest IS NULL"); + assert.ok( + items.some((item) => item.label === "NULL"), + "should suggest NULL", + ); + assert.ok( + items.some((item) => item.label === "IS NULL"), + "should suggest IS NULL", + ); }); test("suggests string snippet for varchar column after =", () => {