diff --git a/apps/desktop/src/lib/__tests__/sqlCompletion.context.spec.ts b/apps/desktop/src/lib/__tests__/sqlCompletion.context.spec.ts new file mode 100644 index 000000000..de973d26f --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sqlCompletion.context.spec.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { buildSqlCompletionItems, getSqlCompletionContext } from "@/lib/sqlCompletion"; + +describe("sqlCompletion quoted schema qualifiers", () => { + it("parses quoted PostgreSQL schema names before a dot", () => { + const sql = 'SELECT *\nFROM "order-management".'; + const context = getSqlCompletionContext(sql, sql.length); + + expect(context.qualifier).toBe("order-management"); + expect(context.prefix).toBe(""); + expect(context.suggestTables).toBe(true); + expect(context.exclusiveColumnSuggestions).toBe(false); + }); + + it("suggests tables after a quoted schema qualifier", () => { + const sql = 'SELECT *\nFROM "order-management".'; + const items = buildSqlCompletionItems(sql, sql.length, { + dialect: "postgres", + tables: [ + { name: "orders", schema: "order-management", type: "table" }, + { name: "shipments", schema: "order-management", type: "table" }, + ], + columnsByTable: new Map(), + }); + + expect(items.some((item) => item.label === "orders" && item.type === "table")).toBe(true); + expect(items.some((item) => item.label === "shipments" && item.type === "table")).toBe(true); + }); +}); diff --git a/apps/desktop/src/lib/sqlCompletion.ts b/apps/desktop/src/lib/sqlCompletion.ts index 1e52d150c..883ab012f 100644 --- a/apps/desktop/src/lib/sqlCompletion.ts +++ b/apps/desktop/src/lib/sqlCompletion.ts @@ -915,13 +915,10 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet const stmtStart = extractStatementStart(sql, cursor); const beforeCursor = sql.slice(stmtStart, cursor); - const dottedMatch = /([A-Za-z_][\w$]*)\.([A-Za-z_][\w$]*)?$/.exec(beforeCursor); - const plainMatch = /([A-Za-z_][\w$]*)$/.exec(beforeCursor); - const prefix = dottedMatch?.[2] ?? plainMatch?.[1] ?? ""; - const qualifier = dottedMatch?.[1]; - const bareStart = dottedMatch - ? beforeCursor.length - dottedMatch[0].length - : beforeCursor.length - (plainMatch?.[1]?.length ?? 0); + const trailingIdentifier = parseTrailingIdentifierContext(beforeCursor); + const prefix = trailingIdentifier?.prefix ?? ""; + const qualifier = trailingIdentifier?.qualifier; + const bareStart = trailingIdentifier?.start ?? beforeCursor.length; const beforeToken = beforeCursor.slice(0, Math.max(0, bareStart)).trimEnd(); const lastWord = /([A-Za-z_][\w$]*)$/.exec(beforeToken)?.[1]?.toLowerCase() ?? ""; @@ -996,6 +993,84 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet }; } +function parseTrailingIdentifierContext(input: string): { start: number; prefix: string; qualifier?: string } | null { + let i = input.length - 1; + while (i >= 0 && /\s/.test(input[i] ?? "")) i--; + if (i < 0) return null; + + const endsWithDot = input[i] === "."; + const tail = input.slice(0, endsWithDot ? i : i + 1); + if (!tail) { + return endsWithDot ? { start: i, prefix: "" } : null; + } + const parts: string[] = []; + let index = tail.length; + + while (index > 0) { + const parsed = parseTrailingIdentifierPart(tail, index); + if (!parsed) break; + parts.unshift(unquoteIdentifier(parsed.raw)); + index = parsed.start; + if (index <= 0 || tail[index - 1] !== ".") break; + index -= 1; + } + + if (parts.length === 0) return null; + const start = index; + + if (parts.length >= 2 || endsWithDot) { + const qualifierParts = endsWithDot ? parts : parts.slice(0, -1); + const prefixPart = endsWithDot ? "" : (parts[parts.length - 1] ?? ""); + const qualifierValue = qualifierParts.join("."); + return { + start, + prefix: prefixPart, + qualifier: qualifierValue || undefined, + }; + } + + return { + start, + prefix: parts[0] ?? "", + }; +} + +function parseTrailingIdentifierPart(input: string, endExclusive: number): { start: number; raw: string } | null { + if (endExclusive <= 0) return null; + const end = endExclusive - 1; + const tailChar = input[end]; + if (!tailChar) return null; + + if (tailChar === '"') { + let start = end - 1; + while (start >= 0) { + if (input[start] === '"') { + if (start > 0 && input[start - 1] === '"') { + start -= 2; + continue; + } + return { start, raw: input.slice(start, endExclusive) }; + } + start -= 1; + } + return null; + } + + if (tailChar === "`") { + const start = input.lastIndexOf("`", end - 1); + if (start < 0) return null; + return { start, raw: input.slice(start, endExclusive) }; + } + + let start = end; + while (start >= 0 && /[A-Za-z0-9_$]/.test(input[start] ?? "")) start -= 1; + start += 1; + if (start >= endExclusive) return null; + const raw = input.slice(start, endExclusive); + if (!/^[A-Za-z_][\w$]*$/.test(raw)) return null; + return { start, raw }; +} + /** * Check if the content before cursor is in a column-expected context. */