From 8582dc9ea4c1270149eb6feae890a8731b01f8a2 Mon Sep 17 00:00:00 2001 From: rahulmahadik Date: Fri, 14 Aug 2026 01:11:24 +0800 Subject: [PATCH 1/4] Quote the identifiers a database will not read back as itself A mixed-case Postgres schema failed every query: an unquoted name folds to lower case and resolves to nothing, Oracle folds the other way, and MySQL on Linux compares table names case-sensitively. Table and column names are now quoted from the catalog before the statement is validated, and a name that is already correct is left alone. Reserved words come from each database rather than one shared guess, read through pg_get_keywords(), information_schema.KEYWORDS, V$RESERVED_WORDS and duckdb_keywords(). MySQL reserves 262 words where the guess had about a hundred. tools/generate-sql-keywords.mjs regenerates both this and the plugin's copy, and refuses to write if an engine is unreachable. Rewriting SQL text can change what a query means, so the rewriter leaves alone anything that is syntax rather than a name: string literals including escaped apostrophes and dollar-quoted bodies, comments, CAST types, EXTRACT fields, typed literals such as TIMESTAMP '2024-01-01', schema qualifiers, and a name spelled two ways across the catalog. Also: nested aggregates are repaired before they run, a set operation no longer trips the unknown-column floor, the prompt names the connected database so system-catalog filters are real, and an unescaped apostrophe is explained instead of looping until the attempts run out. --- .changeset/identifier-and-semantic-floors.md | 39 ++ packages/core/README.md | 9 + packages/core/src/catalog.ts | 22 +- packages/core/src/engine.ts | 114 ++++- packages/core/src/identifier-case.ts | 288 ++++++++++++ packages/core/src/prompt.ts | 24 + packages/core/src/semantics.ts | 47 ++ packages/core/src/sql-keywords.ts | 65 +++ packages/core/test/identifier-case.test.ts | 435 +++++++++++++++++++ packages/core/test/nested-aggregate.test.ts | 37 ++ packages/core/test/sql-keywords.test.ts | 35 ++ packages/core/test/table-case-repair.test.ts | 219 ++++++++++ tests/bundle-size.test.ts | 14 +- tools/generate-sql-keywords.mjs | 206 +++++++++ 14 files changed, 1525 insertions(+), 29 deletions(-) create mode 100644 .changeset/identifier-and-semantic-floors.md create mode 100644 packages/core/src/identifier-case.ts create mode 100644 packages/core/src/sql-keywords.ts create mode 100644 packages/core/test/identifier-case.test.ts create mode 100644 packages/core/test/nested-aggregate.test.ts create mode 100644 packages/core/test/sql-keywords.test.ts create mode 100644 packages/core/test/table-case-repair.test.ts create mode 100644 tools/generate-sql-keywords.mjs diff --git a/.changeset/identifier-and-semantic-floors.md b/.changeset/identifier-and-semantic-floors.md new file mode 100644 index 0000000..6758cbf --- /dev/null +++ b/.changeset/identifier-and-semantic-floors.md @@ -0,0 +1,39 @@ +--- +'@asksql/core': patch +--- + +Quote the identifiers a database would not read back as itself. A mixed-case Postgres schema failed +every query, because an unquoted name folds to lower case and resolves to nothing; Oracle folds the +other way, and MySQL on Linux compares table names case-sensitively. Table and column names are now +quoted from the catalog before the query is validated. A name that is already correct is left +untouched, so MySQL output is unchanged, and a name spelled two ways across the catalog is skipped +rather than guessed. A table named like a parser keyword, such as `order` or `Nulls`, also works now: +the bare form could not be parsed at all and the question failed after three attempts. + +Reserved words now come from each database itself, through `pg_get_keywords()`, +`information_schema.KEYWORDS`, `V$RESERVED_WORDS` and `duckdb_keywords()`, rather than one shared list +that applied MySQL's rules to Postgres and missed most of MySQL's own: MySQL reserves 262 words where +the shared list had about a hundred. Regenerate with `node tools/generate-sql-keywords.mjs`. + +Quoting knows where a word is syntax rather than a name, so `CAST(x AS DATE)` and +`EXTRACT(MONTH FROM d)` are left alone, and a CTE is still recognised once its name is quoted. + +When a database rejects a name, the corrected query is derived from the catalog rather than from a +second model call, and the table repair names the closest match it already knew. + +Tell the model which database and schema it is connected to. Without that it wrote +`table_schema = 'your_database_name'` against `information_schema` and returned nothing at all, which +reads as an empty database rather than an error. Structure questions also get a correct catalog query +for the engine to build on. + +Say what is actually wrong when a statement will not parse. An apostrophe inside a value, as in +`'O'Brien'`, only produced "could not parse", so the model returned the same statement until it ran +out of attempts; it is now told to double the quote. + +Reject `AVG(SUM(x))` before it reaches the database. Nested aggregates are invalid everywhere, so the +query is repaired rather than run and failed. + +Fix two false alarms. A `UNION ALL` of per-table counts was blocked as a hallucinated column, because +each branch's columns were judged against every branch's tables; per-table row counts now work. Index +columns arrived already quoted from introspection and were quoted a second time, so every prompt +carried `"""ColumnName"""`. diff --git a/packages/core/README.md b/packages/core/README.md index 0e8a5b6..fb5424f 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -97,6 +97,15 @@ Beyond ask -> approve -> run, all optional: column (a common small-model slip), it is handed the real column list and re-asked, so the fix happens before the database ever sees the query. The schema is also auto-shrunk and retried once on context overflow. +- **Identifier quoting** - names a database would not read back as themselves are quoted from the + catalog before the query is validated, following each engine's own rule: Postgres folds unquoted + names down, Oracle folds them up, MySQL on Linux compares table names case-sensitively, and every + engine has reserved words. A mixed-case schema therefore works without the model having to + remember quotes, and names that are already correct are left alone. If a database still rejects a + name, the corrected query comes from the catalog rather than a second model call. +- **Semantic floors** - a query that would be rejected or would answer the wrong question is + repaired before it runs: an aggregate beside a bare column with no `GROUP BY`, an aggregate nested + inside another (`AVG(SUM(x))`), and a one-to-many join that inflates a `SUM`. - **Follow-up context** - prior turns are threaded into the prompt so "now break that down by month" works. - **Query history** - `config.history` records every attempt (status, duration), backed by an diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 7ee68dd..992ab64 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -5,6 +5,7 @@ */ import type { PrunerSettings, SchemaCatalog, TableInfo } from './types.js'; +import { reservedWordsFor } from './sql-keywords.js'; import { VALUE_SAMPLE_MAX_DISTINCT } from './types.js'; import { dialectFor } from './dialects.js'; @@ -35,33 +36,22 @@ function sanitizeComment(comment: string | null | undefined): string | null { /** A name that can be written without quotes; anything else is rendered quoted. */ const PLAIN_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/u; -/** Words an engine will not accept as a bare identifier; not exhaustive across every dialect. */ -const RESERVED_WORDS: ReadonlySet = new Set( - ( - 'select from where group by order having limit offset union all distinct join inner outer left ' + - 'right full cross natural on using as into insert update delete set values create drop alter ' + - 'table column view index key primary foreign unique constraint references default check null ' + - 'not and or in is like between case when then else end exists any some cast collate with ' + - 'recursive returning window over partition range rows current session system user grant revoke ' + - 'to begin commit rollback transaction lock database schema trigger procedure function ' + - 'desc asc date time timestamp interval level size type comment position language' - ).split(' '), -); - /** * True when the engine would not read the bare name back as itself; an unquoted identifier folds * case - PostgreSQL to lower, Oracle to upper. */ -function needsQuoting(name: string, engine: string): boolean { +export function needsQuoting(name: string, engine: string): boolean { if (!PLAIN_IDENTIFIER_RE.test(name)) return true; - if (RESERVED_WORDS.has(name.toLowerCase())) return true; + if (reservedWordsFor(engine).has(name.toLowerCase())) return true; if (engine === 'oracle') return name !== name.toUpperCase(); // MySQL, SQLite and DuckDB match identifiers case-insensitively, so folding cannot lose a name. if (engine === 'mysql' || engine === 'sqlite' || engine === 'duckdb') return false; return name !== name.toLowerCase(); } -function promptIdentifier(name: string, quote: string, engine: string): string { +function promptIdentifier(raw: string, quote: string, engine: string): string { + // Index columns arrive already quoted from introspection; quoting twice escapes the quotes into the name. + const name = raw.length > 1 && raw.startsWith(quote) && raw.endsWith(quote) ? raw.slice(1, -1) : raw; if (!needsQuoting(name, engine)) return name; // Doubling is how every supported engine escapes its own quote character inside an identifier. return `${quote}${name.split(quote).join(quote + quote)}${quote}`; diff --git a/packages/core/src/engine.ts b/packages/core/src/engine.ts index 45947b7..3d3a514 100644 --- a/packages/core/src/engine.ts +++ b/packages/core/src/engine.ts @@ -5,11 +5,19 @@ * The guard runs on every SQL string before execution; no DB session is held open across an LLM call. */ -import { joinGraph, pruneCatalog } from './catalog.js'; +import { joinGraph, needsQuoting, pruneCatalog } from './catalog.js'; +import { + correctTableCase, + foldingFor, + looksLikeUnknownTable, + hasUnterminatedLiteral, + quoteCatalogIdentifiers, + withoutLiteralsAndComments, +} from './identifier-case.js'; import { AskSqlError } from './errors.js'; import { extractImpossible, extractSql } from './extract.js'; import { guardSql, resolveGuardPolicy } from './guard.js'; -import { fanOutAggregate, ungroupedAggregate } from './semantics.js'; +import { fanOutAggregate, nestedAggregate, ungroupedAggregate } from './semantics.js'; import { historyId, MemoryHistoryStore } from './history.js'; import { callModel } from './llm.js'; import { @@ -517,6 +525,22 @@ export function createAskSql(config: AskSqlConfig): AskSqlEngine { emit({ type: 'stage', stage: 'catalog' }, opts); const fullCatalog = await getCatalog(conn); + // Names the engine would not read back as themselves: folded case, reserved words, symbols. + // A name spelled two ways across the catalog is skipped: rewriting "status" to "Status" would + // ask one table for another table's column. + const allNames = fullCatalog.tables.flatMap((t) => [t.name, ...t.columns.map((c) => c.name)]); + const spellings = new Map>(); + for (const n of allNames) { + const key = n.toLowerCase(); + const set = spellings.get(key) ?? new Set(); + set.add(n); + spellings.set(key, set); + } + const quotableNames = allNames.filter( + (n) => needsQuoting(n, conn.engine) && spellings.get(n.toLowerCase())?.size === 1, + ); + // Only a table may be quoted before a dot; a schema qualifier that matched a column name broke it. + const quotableTables = fullCatalog.tables.map((t) => t.name).filter((n) => quotableNames.includes(n)); emit({ type: 'stage', stage: 'prune' }, opts); let pruned = pruneCatalog(fullCatalog, q, config.pruner); @@ -538,6 +562,9 @@ export function createAskSql(config: AskSqlConfig): AskSqlEngine { fewShots, glossary: config.glossary, rerunPrevious: isRerunPreviousRequest(q), + database: conn.database, + schemas: fullCatalog.schemas, + catalogHint: isMetadataQuestion(q) ? catalogQueryHint(conn.dialect.engine) : undefined, }); const usageTotal: { input: number; output: number } = { input: 0, output: 0 }; @@ -581,6 +608,8 @@ export function createAskSql(config: AskSqlConfig): AskSqlEngine { maxRows: policy.maxRows, context: opts.context, glossary: config.glossary, + database: conn.database, + schemas: fullCatalog.schemas, }); attempt -= 1; // does not consume a repair attempt continue; @@ -655,7 +684,13 @@ export function createAskSql(config: AskSqlConfig): AskSqlEngine { lastSql = extraction.sql; emit({ type: 'stage', stage: 'guard' }, opts); - const verdict = guardSql({ sql: extraction.sql, dialect: conn.dialect, policy }); + // Quote first: a folding engine resolves a bare name elsewhere, and the parser cannot read a + // bare table named like a keyword. Falls back untouched if quoting makes it unparseable. + const normalised = quoteCatalogIdentifiers(extraction.sql, quotableNames, conn.dialect.quoteChar, quotableTables); + const normalisedVerdict = normalised ? guardSql({ sql: normalised, dialect: conn.dialect, policy }) : null; + const verdict = normalisedVerdict?.allowed + ? normalisedVerdict + : guardSql({ sql: extraction.sql, dialect: conn.dialect, policy }); if (!verdict.allowed) { if (attempt >= MAX_REPAIRS) { await recordHistory({ @@ -673,10 +708,14 @@ export function createAskSql(config: AskSqlConfig): AskSqlEngine { detail: `ruleId=${verdict.ruleId ?? 'unknown'} after ${attempt + 1} attempts`, }); } + // "could not parse" alone leaves the model repeating the same statement; name the real cause. + const quoteHint = hasUnterminatedLiteral(extraction.sql, conn.dialect.quoteChar === '`') + ? " A text value contains an apostrophe that is not escaped: write it doubled, as 'O''Brien'." + : ''; userPrompt = buildRepairUser({ question: q, failedSql: extraction.sql, - failure: `The SQL validator rejected it: ${verdict.reason ?? verdict.ruleId ?? 'not allowed'}. Produce a single read-only SELECT.`, + failure: `The SQL validator rejected it: ${verdict.reason ?? verdict.ruleId ?? 'not allowed'}.${quoteHint} Produce a single read-only SELECT.`, schemaText, dialect: conn.dialect, }); @@ -711,10 +750,14 @@ export function createAskSql(config: AskSqlConfig): AskSqlEngine { retryable: false, }); } + // The column repair already names the real columns; give the table repair the same head start. + const nearest = closestTableName(unknownTable, fullCatalog); userPrompt = buildRepairUser({ question: q, failedSql: verdict.sql, - failure: `Table "${unknownTable}" does not exist in the schema. Use only tables from the block.`, + failure: + `Table "${unknownTable}" does not exist in the schema.${nearest ? ` Did you mean "${nearest}"?` : ''} ` + + 'Use only tables from the block.', schemaText, dialect: conn.dialect, }); @@ -736,6 +779,21 @@ export function createAskSql(config: AskSqlConfig): AskSqlEngine { continue; } + // Semantic floor: AVG(SUM(x)) and friends. Every engine rejects it, so repair before executing. + const nested = nestedAggregate(verdict.sql, conn.dialect.grammar); + if (nested && attempt < MAX_REPAIRS) { + userPrompt = buildRepairUser({ + question: q, + failedSql: verdict.sql, + failure: + `${nested}() contains another aggregate, which no SQL engine allows. ` + + 'Aggregate once over the rows, or aggregate the inner result in a subquery or CTE and then aggregate that.', + schemaText, + dialect: conn.dialect, + }); + continue; + } + // Semantic floor: a one-to-many join multiplies the rows a SUM sees, so the total is inflated. const fanOut = fanOutAggregate(verdict.sql, conn.dialect.grammar, fullCatalog); if (fanOut && attempt < MAX_REPAIRS) { @@ -779,6 +837,7 @@ export function createAskSql(config: AskSqlConfig): AskSqlEngine { } emit({ type: 'stage', stage: 'done' }, opts); + const folding = foldingFor(conn.engine); const finalSql = verdict.sql; const explanation = extraction.explanation; const usage: LlmUsage = { inputTokens: usageTotal.input, outputTokens: usageTotal.output }; @@ -800,7 +859,8 @@ export function createAskSql(config: AskSqlConfig): AskSqlEngine { // Never after a cancel: a repair would fire a fresh provider request the user just declined to wait for. const wasCancelled = (execOpts?.signal?.aborted ?? false) || (opts.signal?.aborted ?? false); if (AskSqlError.is(err) && err.code === 'DB_QUERY_ERROR' && !wasCancelled) { - const suggestion = await tryRepairAfterDbError(err); + // A wrong-cased table is repairable from the catalog alone, so try that before the model. + const suggestion = caseFixFor(err) ?? (await tryRepairAfterDbError(err)); if (suggestion) (err as DbErrorWithSuggestion).suggestedSql = suggestion; } throw err; @@ -808,6 +868,20 @@ export function createAskSql(config: AskSqlConfig): AskSqlEngine { }, }; + function caseFixFor(dbErr: AskSqlError): string | null { + const message = dbErr.detail ?? dbErr.userMessage ?? ''; + if (!looksLikeUnknownTable(message)) return null; + const fixed = correctTableCase( + finalSql, + fullCatalog.tables.map((t) => t.name), + conn.dialect.quoteChar, + folding, + ); + if (!fixed) return null; + const v = guardSql({ sql: fixed, dialect: conn.dialect, policy }); + return v.allowed ? v.sql : null; + } + async function tryRepairAfterDbError(dbErr: AskSqlError): Promise { try { const repairPrompt = buildRepairUser({ @@ -1020,6 +1094,19 @@ export function createAskSql(config: AskSqlConfig): AskSqlEngine { try { const catalog = await getCatalog(conn).catch(() => null); if (!catalog) return null; + // A wrong-cased table is repairable from the catalog alone, the same as ask().run() does. + if (looksLikeUnknownTable(opts.errorDetail ?? '')) { + const cased = correctTableCase( + bad, + catalog.tables.map((t) => t.name), + conn.dialect.quoteChar, + foldingFor(conn.engine), + ); + if (cased) { + const casedVerdict = guardSql({ sql: cased, dialect: conn.dialect, policy }); + if (casedVerdict.allowed) return casedVerdict.sql; + } + } const schemaText = pruneCatalog(catalog, question, config.pruner).schemaText; const repaired = await callModel({ model: config.model, @@ -1037,7 +1124,11 @@ export function createAskSql(config: AskSqlConfig): AskSqlEngine { const ex = extractSql(repaired.text); if (!ex) return null; const v = guardSql({ sql: ex.sql, dialect: conn.dialect, policy }); - return v.allowed && v.sql !== bad ? v.sql : null; + if (!v.allowed || v.sql === bad) return null; + // The same hallucination floors ask() enforces; a fix naming a missing table is not a fix. + if (firstUnknownTable(v.sql, catalog, conn.dialect.grammar, v.tables)) return null; + if (firstUnknownColumn(v.sql, catalog, conn.dialect.grammar)) return null; + return v.sql; } catch { return null; // best-effort; the original error stands } @@ -1078,7 +1169,9 @@ function collectCteNames(sql: string): ReadonlySet { const names = new Set(); if (!/\bwith\b/iu.test(sql)) return names; // Scans the whole statement; over-collecting CTE names only makes the floor more lenient. - for (const m of sql.matchAll(/([A-Za-z_][A-Za-z0-9_]*)\s+as\s*\(/giu)) { + // The quote characters matter: normalisation may have quoted a CTE named like a catalog column, + // and a model can quote one itself. An unrecognised CTE reads as a hallucinated table. + for (const m of sql.matchAll(/["`[]?([A-Za-z_][A-Za-z0-9_]*)["`\]]?\s+as\s*\(/giu)) { names.add(m[1]!.toLowerCase()); } return names; @@ -1123,7 +1216,10 @@ export function firstUnknownColumn(sql: string, catalog: SchemaCatalog, grammar: // The query's base tables that we know; unqualified columns are judged only when all are known. const queryTables: string[] = []; - let attributable = !/\(\s*select\b/iu.test(sql); + // A set operation has one column list per branch, and the parser reports them merged, so a column + // from one branch would be judged against another branch's tables. Not attributable, like a subquery. + const code = withoutLiteralsAndComments(sql); + let attributable = !/\(\s*select\b/iu.test(code) && !/\b(union|intersect|except)\b/iu.test(code); try { for (const t of tableParser.tableList(sql, { database: grammar })) { let name = (t.split('::')[2] ?? '').toLowerCase(); diff --git a/packages/core/src/identifier-case.ts b/packages/core/src/identifier-case.ts new file mode 100644 index 0000000..3f83bd9 --- /dev/null +++ b/packages/core/src/identifier-case.ts @@ -0,0 +1,288 @@ +/** + * Identifiers a database would not read back as itself: a name spelled in the wrong case, a + * mixed-case name left unquoted on an engine that folds, a reserved word, a symbol. MySQL on Linux + * compares table names case-sensitively; Postgres folds unquoted names down and Oracle folds up. + * Quoting from the catalog covers all of it, and the catalog can repair it without a model call. + */ + +import { reservedWordsFor } from './sql-keywords.js'; + +// The union across engines: treating a word as syntax is the conservative side of this decision. +const ANY_RESERVED = reservedWordsFor('*'); + +/** Only these lead-ins put an identifier in table position, which keeps a same-named column alone. */ +const TABLE_POSITION = + /\b(from|join|update|into)(\s+)([`"[]?)([A-Za-z_][\w$]*)[`"\]]?(\s*\.\s*([`"[]?)([A-Za-z_][\w$]*)[`"\]]?)?/gi; + +const CLOSING: Record = { '`': '`', '"': '"', '[': ']' }; + +function quoted(name: string, quoteChar: string): string { + return `${quoteChar}${name}${CLOSING[quoteChar] ?? quoteChar}`; +} + +/** Where a literal or comment ends, so identifiers are never rewritten inside one. */ +function skipTo(sql: string, i: number, doubleQuoteIsLiteral: boolean, backslashEscapes = false): number { + const ch = sql[i]; + const next = sql[i + 1]; + // $$body$$ and $tag$body$tag$ are literals in Postgres and DuckDB, and may contain anything. + if (ch === '$') { + const open = /^\$[A-Za-z_][\w]*\$|^\$\$/.exec(sql.slice(i)); + if (open) { + const close = sql.indexOf(open[0], i + open[0].length); + return close === -1 ? sql.length : close + open[0].length; + } + } + if (ch === '-' && next === '-') { + const nl = sql.indexOf('\n', i); + return nl === -1 ? sql.length : nl; + } + if (ch === '/' && next === '*') { + const close = sql.indexOf('*/', i + 2); + return close === -1 ? sql.length : close + 2; + } + if (ch === "'" || (ch === '"' && doubleQuoteIsLiteral)) { + let j = i + 1; + while (j < sql.length) { + if (backslashEscapes && sql[j] === '\\') j += 2; + else if (sql[j] === ch) { + // A doubled quote is an escaped one, so the literal continues past it. + if (sql[j + 1] === ch) j += 2; + else return j + 1; + } else j++; + } + return sql.length; + } + return -1; +} + +/** How an engine resolves an unquoted identifier: Postgres lower-cases it, Oracle upper-cases it. */ +export type Folding = 'lower' | 'upper' | 'none'; + +export function foldingFor(engine: string): Folding { + if (engine === 'postgres') return 'lower'; + if (engine === 'oracle') return 'upper'; + return 'none'; +} + +function folded(name: string, folding: Folding): string { + if (folding === 'lower') return name.toLowerCase(); + if (folding === 'upper') return name.toUpperCase(); + return name; +} + +/** + * Rewrites table references the database will not resolve to the catalog's table. That covers a name + * spelled in the wrong case, and on a folding engine a mixed-case name left unquoted, which resolves + * to something else entirely. Returns null when nothing changes. + */ +export function correctTableCase( + sql: string, + tableNames: readonly string[], + quoteChar: string, + folding: Folding = 'none', +): string | null { + const byLower = new Map(); + for (const name of tableNames) { + const lower = name.toLowerCase(); + // An ambiguous fold has no single right answer, so leave those names untouched. + if (byLower.has(lower) && byLower.get(lower) !== name) byLower.set(lower, ''); + else if (!byLower.has(lower)) byLower.set(lower, name); + } + + let changed = false; + const fixCode = (code: string): string => + code.replace( + TABLE_POSITION, + ( + whole: string, + keyword: string, + gap: string, + open: string, + first: string, + qualified: string | undefined, + _open2: string | undefined, + second: string | undefined, + offset: number, + ) => { + const target = second ?? first; + const canonical = byLower.get(target.toLowerCase()); + if (!canonical) return whole; + // A third part means what matched is a qualifier: prod.sales.orders names orders, not sales. + if (/^\s*\./.test(sql.slice(offset + whole.length))) return whole; + // An unquoted name is resolved folded, so what matters is what the database will look up. + const wasQuoted = (second === undefined ? open : (_open2 ?? '')) !== ''; + const resolvesTo = wasQuoted ? target : folded(target, folding); + if (resolvesTo === canonical) return whole; + changed = true; + const fixed = quoted(canonical, quoteChar); + if (second === undefined) return `${keyword}${gap}${fixed}`; + return `${keyword}${gap}${open ? quoted(first, open) : first}.${fixed}`; + }, + ); + + // A double quote is a string in MySQL but an identifier in Postgres, so the dialect decides. + const doubleQuoteIsLiteral = quoteChar !== '"'; + // MySQL is the only engine here that escapes with a backslash, and the backtick identifies it. + const backslashEscapes = quoteChar === '`'; + let out = ''; + let start = 0; + let i = 0; + while (i < sql.length) { + const end = skipTo(sql, i, doubleQuoteIsLiteral, backslashEscapes); + if (end >= 0) { + out += fixCode(sql.slice(start, i)) + sql.slice(i, end); + start = end; + i = end; + } else i++; + } + out += fixCode(sql.slice(start)); + return changed ? out : null; +} + +/** A bare identifier, and whatever follows it, so a function call can be told from a column. */ +const BARE_IDENTIFIER = /([A-Za-z_][\w$]*)(\s*[.(]?)/g; + +/** + * A reserved word is only treated as a name where it cannot be syntax: after FROM/JOIN/UPDATE/INTO, + * or qualified by a dot. Accepting AS or "(" quoted the type in CAST(x AS DATE) and the field in + * EXTRACT(MONTH FROM d), both of which are valid SQL that quoting breaks. + */ +const NAME_POSITION = /(?:\bfrom|\bjoin|\bupdate|\binto|\.)\s*$/i; + +/** The first argument of these is a keyword, not a name: EXTRACT(MONTH FROM d), TRIM(BOTH x FROM s). */ +const KEYWORD_ARGUMENT = /\b(?:extract|trim|position|overlay|substring)\s*\(\s*$/i; + +/** + * Quotes every table and column the engine would otherwise fold away. The schema text already shows + * these names quoted and models still drop the quotes, so the query is normalised before it runs + * rather than left to fail. Returns null when nothing needed quoting. + */ +export function quoteCatalogIdentifiers( + sql: string, + names: readonly string[], + quoteChar: string, + tableNames: readonly string[] = names, +): string | null { + const tables = new Set(tableNames.map((n) => n.toLowerCase())); + const byLower = new Map(); + for (const name of names) { + const lower = name.toLowerCase(); + if (byLower.has(lower) && byLower.get(lower) !== name) byLower.set(lower, ''); + else if (!byLower.has(lower)) byLower.set(lower, name); + } + if (byLower.size === 0) return null; + + let changed = false; + const fixCode = (code: string, chunkStart: number): string => + code.replace(BARE_IDENTIFIER, (whole, token: string, tail: string, offset: number) => { + if (tail.trimStart().startsWith('(')) return whole; // a function call, not an identifier + const canonical = byLower.get(token.toLowerCase()); + if (!canonical) return whole; + const before = code.slice(0, offset); + if (KEYWORD_ARGUMENT.test(before)) return whole; + // TIMESTAMP '2024-01-01' and DATE '...' are typed literals: the word is syntax, not a name. + // The literal is its own segment, so this reads the statement rather than the chunk. + if (/^\s*'/.test(sql.slice(chunkStart + offset + token.length))) return whole; + // A token before a dot qualifies what follows; quoting a schema name breaks a working query. + if (tail.trimStart().startsWith('.') && !tables.has(token.toLowerCase())) return whole; + // Rewriting a keyword blindly turns ORDER BY into "order" BY, so one must announce itself. + if (ANY_RESERVED.has(token.toLowerCase()) && !NAME_POSITION.test(before)) return whole; + changed = true; + return `${quoted(canonical, quoteChar)}${tail}`; + }); + + const doubleQuoteIsLiteral = quoteChar !== '"'; + const backslashEscapes = quoteChar === '`'; + let out = ''; + let start = 0; + let i = 0; + while (i < sql.length) { + // An already-quoted identifier is opaque: re-quoting it would double the quote characters. + if (sql[i] === quoteChar) { + const close = sql.indexOf(CLOSING[quoteChar] ?? quoteChar, i + 1); + const end = close === -1 ? sql.length : close + 1; + out += fixCode(sql.slice(start, i), start) + sql.slice(i, end); + start = end; + i = end; + continue; + } + const end = skipTo(sql, i, doubleQuoteIsLiteral, backslashEscapes); + if (end >= 0) { + out += fixCode(sql.slice(start, i), start) + sql.slice(i, end); + start = end; + i = end; + } else i++; + } + out += fixCode(sql.slice(start), start); + return changed ? out : null; +} + +/** + * The statement with string literals and comments blanked out, so a keyword search cannot match a + * value like 'except this' or a table named in a comment. Length and offsets are preserved. + */ +export function withoutLiteralsAndComments(sql: string): string { + let out = ''; + let start = 0; + let i = 0; + while (i < sql.length) { + // Quoted identifiers are code, not literals, so only ' and comments are blanked here. + const end = skipTo(sql, i, false); + if (end >= 0) { + out += sql.slice(start, i) + ' '.repeat(end - i); + start = end; + i = end; + } else i++; + } + return out + sql.slice(start); +} + +/** + * True when a text value opens and never closes, which is what an unescaped apostrophe looks like: + * 'O'Brien' reads as the value 'O', then Brien, then a literal running to the end of the statement. + * The parser only reports "could not parse", so naming the real cause is what makes the repair land. + */ +export function hasUnterminatedLiteral(sql: string, backslashEscapes = false): boolean { + let open = false; + let i = 0; + while (i < sql.length) { + const ch = sql[i]; + if (open) { + if (backslashEscapes && ch === '\\') i += 2; + else if (ch === "'" && sql[i + 1] === "'") + i += 2; // an escaped apostrophe, the value continues + else if (ch === "'") { + open = false; + i++; + } else i++; + continue; + } + // A dollar-quoted body is a literal that needs no escaping, so an apostrophe inside is fine. + if (ch === '$') { + const dollar = skipTo(sql, i, false); + if (dollar > i) { + i = dollar; + continue; + } + } + if (ch === '-' && sql[i + 1] === '-') { + const nl = sql.indexOf('\n', i); + i = nl === -1 ? sql.length : nl; + } else if (ch === '/' && sql[i + 1] === '*') { + const close = sql.indexOf('*/', i + 2); + i = close === -1 ? sql.length : close + 2; + } else if (ch === "'") { + open = true; + i++; + } else i++; + } + return open; +} + +/** Matches the unknown-table wording of every engine AskSQL supports. */ +const UNKNOWN_TABLE = + /\b(doesn't exist|does not exist|not found|unknown table|invalid object name|undefined table|no such table|table or view does not exist)\b/i; + +export function looksLikeUnknownTable(message: string): boolean { + return UNKNOWN_TABLE.test(message); +} diff --git a/packages/core/src/prompt.ts b/packages/core/src/prompt.ts index cbe3a83..bb6eae5 100644 --- a/packages/core/src/prompt.ts +++ b/packages/core/src/prompt.ts @@ -19,6 +19,11 @@ export interface SqlPromptInput { readonly glossary?: readonly { term: string; definition: string }[]; /** The question asks to re-run the previous query rather than for a new one. */ readonly rerunPrevious?: boolean; + /** Named so a question about system catalogs does not invent a placeholder database name. */ + readonly database?: string; + readonly schemas?: readonly string[]; + /** A correct catalog query for this engine, offered when the question is about structure. */ + readonly catalogHint?: string; } export function buildSqlSystem(dialect: DialectInfo, maxRows: number, prompts?: PromptSettings): string { @@ -53,6 +58,25 @@ export function buildSqlUser(input: SqlPromptInput): string { const parts: string[] = []; parts.push('', input.schemaText, ''); + // Without these, a question about information_schema gets a guessed name like 'your_database_name'. + const where: string[] = []; + if (input.database) where.push(`database/catalog is "${input.database}"`); + if (input.schemas && input.schemas.length > 0) where.push(`schema is "${input.schemas[0]}"`); + if (where.length > 0) { + parts.push( + '', + `You are connected to: the ${where.join(', the ')}. Use these exact names when a query filters on system catalogs such as information_schema; never write a placeholder.`, + ); + } + + // System-catalog column names are not in the schema block, so a structure question otherwise guesses them. + if (input.catalogHint) { + parts.push( + '', + `This question is about the database's structure. Build on this correct query for this engine: ${input.catalogHint}`, + ); + } + if (input.glossary && input.glossary.length > 0) { parts.push('', 'Business glossary (use these definitions when the question uses these terms):'); for (const g of input.glossary.slice(0, 40)) parts.push(`- ${g.term}: ${g.definition}`); diff --git a/packages/core/src/semantics.ts b/packages/core/src/semantics.ts index a88957f..15a514b 100644 --- a/packages/core/src/semantics.ts +++ b/packages/core/src/semantics.ts @@ -213,3 +213,50 @@ export function fanOutAggregate(sql: string, grammar: string, catalog: FanOutCat } return null; } + +/** + * An aggregate nested inside another aggregate, like AVG(x + SUM(y)). Every engine rejects it, so + * catching it before execution turns a database error into a repair. Returns the outer function name. + */ +export function nestedAggregate(sql: string, grammar: string): string | null { + let ast: unknown; + try { + ast = parser.parse(sql, { database: grammar }).ast; + } catch { + return null; // the guard already parsed it; never double-block here + } + + let outer: string | null = null; + const walk = (node: unknown, insideAggregate: string | null): void => { + if (outer) return; + if (Array.isArray(node)) { + for (const item of node) walk(item, insideAggregate); + return; + } + if (!isNode(node)) return; + const isAgg = isBareAggregate(node); + if (isAgg && insideAggregate) { + outer = insideAggregate; + return; + } + const within = isAgg ? aggregateName(node) : insideAggregate; + for (const key of Object.keys(node)) { + // A subquery has its own scope, so an aggregate inside one is not nested in the outer call. + if (key === 'ast' || key === 'from') continue; + walk(node[key], within); + } + }; + walk(ast, null); + return outer; +} + +function aggregateName(node: Node): string { + const name = node['name']; + const text = + typeof name === 'string' + ? name + : isNode(name) && Array.isArray(name['name']) + ? String((name['name'][0] as Node)?.['value'] ?? '') + : ''; + return text.toUpperCase(); +} diff --git a/packages/core/src/sql-keywords.ts b/packages/core/src/sql-keywords.ts new file mode 100644 index 0000000..95d02d3 --- /dev/null +++ b/packages/core/src/sql-keywords.ts @@ -0,0 +1,65 @@ +/** + * Reserved words per engine, read from each database itself rather than guessed: + * pg_get_keywords(), information_schema.KEYWORDS, V$RESERVED_WORDS, duckdb_keywords(). + * SQLite publishes a fixed list and has no catalog to query. + * + * Regenerate with: node tools/generate-sql-keywords.mjs + * One word list plus a bit per engine, which costs a few hundred bytes instead of repeating words. + */ + +const WORDS = + 'abort|accessible|action|add|after|all|alter|always|analyse|analyze|and|any|array|as|asc|asensitive|asymmetric|' + + 'attach|authorization|autoincrement|before|begin|between|bigint|binary|blob|both|by|call|cascade|case|cast|chan' + + 'ge|char|character|check|cluster|collate|collation|column|commit|compress|concurrently|condition|conflict|conne' + + 'ct|constraint|continue|convert|create|cross|cube|cume_dist|current|current_catalog|current_date|current_role|c' + + 'urrent_schema|current_time|current_timestamp|current_user|cursor|database|databases|date|day_hour|day_microsec' + + 'ond|day_minute|day_second|dec|decimal|declare|default|deferrable|deferred|delayed|delete|dense_rank|desc|descr' + + 'ibe|detach|deterministic|distinct|distinctrow|div|do|double|drop|dual|each|else|elseif|empty|enclosed|end|esca' + + 'pe|escaped|except|exclude|exclusive|exists|exit|explain|fail|false|fetch|filter|first|first_value|float|float4' + + '|float8|following|for|force|foreign|freeze|from|full|fulltext|function|generated|get|glob|grant|group|grouping' + + '|groups|having|high_priority|hour_microsecond|hour_minute|hour_second|identified|if|ignore|ilike|immediate|in|' + + 'index|indexed|infile|initially|inner|inout|insensitive|insert|instead|int|int1|int2|int3|int4|int8|integer|int' + + 'ersect|interval|into|io_after_gtids|io_before_gtids|is|isnull|iterate|join|json_table|key|keys|kill|lag|lambda' + + '|last|last_value|lateral|lead|leading|leave|left|like|limit|linear|lines|load|localtime|localtimestamp|lock|lo' + + 'ng|longblob|longtext|loop|low_priority|match|materialized|maxvalue|mediumblob|mediumint|mediumtext|middleint|m' + + 'inus|minute_microsecond|minute_second|mod|mode|modifies|natural|no|no_write_to_binlog|nocompress|not|nothing|n' + + 'otnull|nowait|nth_value|ntile|null|nulls|number|numeric|of|offset|on|only|optimize|optimizer_costs|option|opti' + + 'onally|or|order|others|out|outer|outfile|over|overlaps|partition|pctfree|percent_rank|pivot|pivot_longer|pivot' + + '_wider|placing|plan|pragma|preceding|precision|primary|prior|procedure|public|purge|qualify|query|raise|range|' + + 'rank|raw|read|read_write|reads|real|recursive|references|regexp|reindex|release|rename|repeat|replace|require|' + + 'resignal|resource|restrict|return|returning|revoke|right|rlike|rollback|row|row_number|rows|savepoint|schema|s' + + 'chemas|second_microsecond|select|sensitive|separator|session_user|set|share|show|signal|similar|size|smallint|' + + 'some|spatial|specific|sql|sql_big_result|sql_calc_found_rows|sql_small_result|sqlexception|sqlstate|sqlwarning' + + '|ssl|start|starting|stored|straight_join|summarize|symmetric|synonym|system|system_user|table|tablesample|temp' + + '|temporary|terminated|then|ties|tinyblob|tinyint|tinytext|to|trailing|transaction|trigger|true|unbounded|undo|' + + 'union|unique|unlock|unpivot|unsigned|update|usage|use|user|using|utc_date|utc_time|utc_timestamp|vacuum|values' + + '|varbinary|varchar|varchar2|varcharacter|variadic|varying|verbose|view|virtual|when|where|while|window|with|wi' + + 'thout|write|xor|year_month|zerofill'; + +const WORD_LIST = WORDS.split('|'); + +/** One bit per word in WORD_LIST, base64 encoded. */ +const BY_ENGINE: Record = { + sqlite: '/WZ66KhRpkwAV6XG3gxrqsHeDCgrBAfAAJhjDq4CFxz4RbURAQAAaKM1Iga8Aw==', + postgres: 'IH8FxehExh8AQyREAgN6MAHFACgLUMcAAIgiHCaBEAAQAAWQkACAHGEyMICyAQ==', + mysql: 'aubQf6/In/z++d4/c/Ou99+s9//9+f9/36046/cKWLO/3+5+Tf87mn177135PQ==', + oracle: 'YGxACBoiAgBBUYQEGiAiMCEMBCwBAAIDIMKkigYEoEAAIQIQYwAECSExAjQkAQ==', + duckdb: 'IH8BxKhAAgAAwyREAgMqIAFEACgAUgQAAIAgHAbwEAIQAAEQhADACGGyIICwAQ==', +}; + +const cache = new Map>(); + +/** The engine's reserved words; an unknown engine falls back to the union, which is the safe side. */ +export function reservedWordsFor(engine: string): ReadonlySet { + const key = engine.toLowerCase(); + let set = cache.get(key); + if (!set) { + const packed = BY_ENGINE[key]; + if (packed) { + const bytes = atob(packed); + set = new Set(WORD_LIST.filter((_, i) => (bytes.charCodeAt(i >> 3) >> (i & 7)) & 1)); + } else set = new Set(WORD_LIST); + cache.set(key, set); + } + return set; +} diff --git a/packages/core/test/identifier-case.test.ts b/packages/core/test/identifier-case.test.ts new file mode 100644 index 0000000..29cbcc1 --- /dev/null +++ b/packages/core/test/identifier-case.test.ts @@ -0,0 +1,435 @@ +import { describe, expect, it } from 'vitest'; +import { pruneCatalog } from '../src/catalog.js'; +import { buildSqlUser } from '../src/prompt.js'; +import { POSTGRES_DIALECT } from '../src/dialects.js'; +import { + correctTableCase, + hasUnterminatedLiteral, + looksLikeUnknownTable, + quoteCatalogIdentifiers, +} from '../src/identifier-case.js'; + +const TABLES = ['Customers', 'OrderItems']; + +describe('correctTableCase', () => { + it('corrects a lower-cased table name', () => { + expect(correctTableCase('SELECT * FROM customers', TABLES, '`')).toBe('SELECT * FROM `Customers`'); + }); + + it('corrects an upper-cased table name after JOIN', () => { + const sql = 'SELECT * FROM Customers c JOIN ORDERITEMS o ON c.id = o.id'; + expect(correctTableCase(sql, TABLES, '`')).toBe( + 'SELECT * FROM Customers c JOIN `OrderItems` o ON c.id = o.id', + ); + }); + + it('leaves an alias after the table alone', () => { + expect(correctTableCase('SELECT * FROM orderitems oi', TABLES, '`')).toBe( + 'SELECT * FROM `OrderItems` oi', + ); + }); + + it('returns null when every name already matches', () => { + expect(correctTableCase('SELECT * FROM Customers', TABLES, '`')).toBeNull(); + }); + + it('quotes with the dialect character', () => { + expect(correctTableCase('SELECT * FROM customers', TABLES, '"')).toBe('SELECT * FROM "Customers"'); + }); + + it('corrects a name that was already quoted in the wrong case', () => { + expect(correctTableCase('SELECT * FROM `customers`', TABLES, '`')).toBe('SELECT * FROM `Customers`'); + }); + + it('keeps a schema prefix and corrects only the table', () => { + expect(correctTableCase('SELECT * FROM shop.orderitems', TABLES, '`')).toBe( + 'SELECT * FROM shop.`OrderItems`', + ); + }); + + /** A column sharing a table's name must not be rewritten: it is not in table position. */ + it('leaves a same-named column alone', () => { + expect(correctTableCase('SELECT customers FROM Customers', TABLES, '`')).toBeNull(); + }); + + /** Rewriting inside a literal would change the query's meaning, not just its spelling. */ + it('leaves a string literal alone', () => { + expect(correctTableCase("SELECT * FROM Customers WHERE note = 'from customers'", TABLES, '`')).toBeNull(); + }); + + /** Two tables differing only by case have no single right answer. */ + it('leaves an ambiguous fold untouched', () => { + expect(correctTableCase('SELECT * FROM orders', ['Orders', 'ORDERS'], '`')).toBeNull(); + }); + + it('corrects an UPDATE target', () => { + expect(correctTableCase('UPDATE customers SET x = 1', TABLES, '`')).toBe('UPDATE `Customers` SET x = 1'); + }); + + it('leaves an unknown table alone', () => { + expect(correctTableCase('SELECT * FROM invoices', TABLES, '`')).toBeNull(); + }); +}); + +describe('looksLikeUnknownTable', () => { + it.each([ + "Table 'asksql_test.customers' doesn't exist", + 'relation "customers" does not exist', + 'no such table: customers', + 'ORA-00942: table or view does not exist', + 'Invalid object name customers.', + ])('recognises %s', (message) => { + expect(looksLikeUnknownTable(message)).toBe(true); + }); + + it('does not fire on an unrelated failure', () => { + expect(looksLikeUnknownTable('Unknown column x in field list')).toBe(false); + }); +}); + +describe('folding engines', () => { + /** Postgres folds an unquoted name to lower case, so a mixed-case table needs quoting even when spelled right. */ + it('quotes a correctly spelled mixed-case table on Postgres', () => { + expect(correctTableCase('SELECT * FROM Customers', TABLES, '"', 'lower')).toBe('SELECT * FROM "Customers"'); + }); + + it('leaves an all-lower-case table alone on Postgres', () => { + expect(correctTableCase('SELECT * FROM orders', ['orders'], '"', 'lower')).toBeNull(); + }); + + /** Unquoted Orders folds to orders, which is the catalog table, so nothing needs changing. */ + it('leaves a name the fold already resolves alone on Postgres', () => { + expect(correctTableCase('SELECT * FROM Orders', ['orders'], '"', 'lower')).toBeNull(); + }); + + it('leaves an already quoted mixed-case name alone on Postgres', () => { + expect(correctTableCase('SELECT * FROM "Customers"', TABLES, '"', 'lower')).toBeNull(); + }); + + it('quotes a mixed-case table on Oracle, which folds upper', () => { + expect(correctTableCase('SELECT * FROM MixedCase', ['MixedCase'], '"', 'upper')).toBe('SELECT * FROM "MixedCase"'); + }); + + it('leaves an upper-case Oracle table alone', () => { + expect(correctTableCase('SELECT * FROM employees', ['EMPLOYEES'], '"', 'upper')).toBeNull(); + }); +}); + +describe('quoteCatalogIdentifiers', () => { + const NAMES = ['Customers', 'OrderItems', 'FirstName', 'Country', 'CustomerId']; + const q = (sql: string) => quoteCatalogIdentifiers(sql, NAMES, '"'); + + it('quotes both the table and the columns', () => { + expect(q("SELECT FirstName FROM Customers WHERE Country = 'UK'")).toBe( + `SELECT "FirstName" FROM "Customers" WHERE "Country" = 'UK'`, + ); + }); + + it('quotes a qualified column', () => { + expect(q('SELECT c.CustomerId FROM Customers c')).toBe('SELECT c."CustomerId" FROM "Customers" c'); + }); + + /** Doubling the quotes would make the identifier unreadable. */ + it('leaves an already quoted identifier alone', () => { + expect(q('SELECT "FirstName" FROM "Customers"')).toBeNull(); + }); + + /** A reserved word used as a function must not become an identifier. */ + it('leaves a function call alone', () => { + expect(quoteCatalogIdentifiers('SELECT COUNT(*) FROM Customers', ['Customers', 'count'], '"')).toBe( + 'SELECT COUNT(*) FROM "Customers"', + ); + }); + + it('leaves a string literal alone', () => { + expect(q("SELECT 1 WHERE x = 'FirstName'")).toBeNull(); + }); + + it('returns null when nothing needs quoting', () => { + expect(quoteCatalogIdentifiers('SELECT id FROM orders', [], '"')).toBeNull(); + }); + + /** A reserved word is quoted too, not only a folded name. */ + /** A table called "order" once turned ORDER BY into "order" BY, which the guard then rejected. */ + it('does not rewrite a keyword that is not naming the table', () => { + expect( + quoteCatalogIdentifiers('SELECT x FROM Customers ORDER BY x DESC', ['Customers', 'order'], '"'), + ).toBe('SELECT x FROM "Customers" ORDER BY x DESC'); + }); + + it('still quotes GROUP BY and other keyword-adjacent columns', () => { + expect(quoteCatalogIdentifiers('SELECT Country FROM t GROUP BY Country', ['Country'], '"')).toBe( + 'SELECT "Country" FROM t GROUP BY "Country"', + ); + }); + + /** A table called Nulls broke the parser: NULLS is a keyword, so the bare name would not parse. */ + it('quotes a table named like a parser keyword', () => { + expect(quoteCatalogIdentifiers('SELECT Val FROM Nulls', ['Nulls', 'Val'], '"')).toBe( + 'SELECT "Val" FROM "Nulls"', + ); + }); + + it('quotes a table whose name is a reserved word', () => { + expect(quoteCatalogIdentifiers('SELECT * FROM order', ['order'], '"')).toBe('SELECT * FROM "order"'); + }); +}); + +describe('schema text quoting', () => { + /** Index columns arrive already quoted from introspection, and were being quoted a second time. */ + it('does not double-quote a name that is already quoted', () => { + const text = pruneCatalog( + { + engine: 'postgres', + schemas: ['public'], + tables: [ + { + name: 'Customers', + kind: 'table', + columns: [{ name: 'CustomerId', dbType: 'integer', nullable: false }], + primaryKey: ['CustomerId'], + foreignKeys: [], + uniques: [], + checks: [], + indexes: [{ name: 'Customers_pkey', columns: ['"CustomerId"'], unique: true }], + source: 'db', + }, + ], + enums: [], + sequences: [], + triggers: [], + routines: [], + warnings: [], + fetchedAt: 'now', + } as never, + 'customers', + ).schemaText; + + expect(text).not.toContain('"""'); + expect(text).toContain('"CustomerId"'); + }); +}); + +describe('connection identity in the prompt', () => { + /** Without the real name a model writes table_schema = 'your_database_name', which silently returns nothing. */ + it('names the database and schema so system-catalog filters are real', () => { + const text = buildSqlUser({ + question: 'what views exist?', + schemaText: 'TABLE film', + dialect: POSTGRES_DIALECT, + maxRows: 100, + database: 'sakila', + schemas: ['public'], + }); + + expect(text).toContain('"sakila"'); + expect(text).toContain('"public"'); + expect(text).toMatch(/never write a placeholder/i); + }); + + it('says nothing when the connection does not report a database', () => { + const text = buildSqlUser({ question: 'q', schemaText: 's', dialect: POSTGRES_DIALECT, maxRows: 10 }); + expect(text).not.toMatch(/You are connected to/); + }); +}); + +describe('catalog hint for structure questions', () => { + /** System-catalog columns are not in the schema block, so the model used to guess them. */ + it('offers a correct catalog query when one is given', () => { + const text = buildSqlUser({ + question: 'what tables exist?', + schemaText: 'TABLE film', + dialect: POSTGRES_DIALECT, + maxRows: 100, + catalogHint: 'SELECT table_name FROM information_schema.tables', + }); + + expect(text).toContain('SELECT table_name FROM information_schema.tables'); + }); + + it('says nothing about structure for an ordinary data question', () => { + const text = buildSqlUser({ question: 'how many films?', schemaText: 's', dialect: POSTGRES_DIALECT, maxRows: 10 }); + expect(text).not.toMatch(/about the database's structure/); + }); +}); + +describe('escaped quotes inside literals', () => { + const NAMES = ['Notes', 'Body', 'Customers', 'Author']; + + /** A doubled quote is SQL's escaped apostrophe; treating it as the close rewrote text inside the value. */ + it('does not rewrite identifiers inside a literal containing an escaped quote', () => { + const sql = "SELECT * FROM Notes WHERE Body = 'it''s about Customers' AND Author = 'x'"; + + expect(quoteCatalogIdentifiers(sql, NAMES, '"')).toBe( + 'SELECT * FROM "Notes" WHERE "Body" = \'it\'\'s about Customers\' AND "Author" = \'x\'', + ); + }); + + it('keeps scanning as code after the literal really ends', () => { + const sql = "SELECT Body FROM Notes WHERE Author = 'o''brien'"; + expect(quoteCatalogIdentifiers(sql, NAMES, '"')).toBe( + 'SELECT "Body" FROM "Notes" WHERE "Author" = \'o\'\'brien\'', + ); + }); + + /** correctTableCase shares the scanner, so it has the same hazard. */ + it('leaves a table name inside an escaped literal alone when repairing case', () => { + const sql = "SELECT * FROM notes WHERE Body = 'it''s notes'"; + expect(correctTableCase(sql, ['Notes'], '"', 'lower')).toBe( + 'SELECT * FROM "Notes" WHERE Body = \'it\'\'s notes\'', + ); + }); +}); + +describe('keywords that are syntax, not names', () => { + const NAMES = ['Date', 'Status', 'Orders', 'Month', 'Amount', 'order']; + const q = (sql: string) => quoteCatalogIdentifiers(sql, NAMES, '"'); + + /** Quoting the type turns CAST(x AS DATE) into a reference to a column that does not exist. */ + it('leaves the type in a CAST alone', () => { + expect(q('SELECT CAST(x AS Date) FROM t')).toBeNull(); + }); + + /** EXTRACT's first argument is a field keyword; the source after FROM is a real column. */ + it('quotes the source of an EXTRACT but not the field', () => { + expect(q('SELECT EXTRACT(month FROM Date) FROM t')).toBe('SELECT EXTRACT(month FROM "Date") FROM t'); + }); + + it('leaves the leading keyword of a TRIM alone', () => { + expect(q("SELECT TRIM(both 'x' FROM Status) FROM t")).toBe('SELECT TRIM(both \'x\' FROM "Status") FROM t'); + }); + + it('still quotes a reserved word in table position', () => { + expect(q('SELECT * FROM order')).toBe('SELECT * FROM "order"'); + }); + + it('still quotes a reserved word qualified by a dot', () => { + expect(q('SELECT t.order FROM t')).toBe('SELECT t."order" FROM t'); + }); + + it('still quotes an ordinary column inside a function call', () => { + expect(q('SELECT COUNT(Amount) FROM Orders')).toBe('SELECT COUNT("Amount") FROM "Orders"'); + }); +}); + +describe('unterminated text values', () => { + /** 'O'Brien' is what a model writes when it forgets to double the apostrophe. */ + it('spots an unescaped apostrophe', () => { + expect(hasUnterminatedLiteral("SELECT Note FROM Person WHERE Name = 'O'Brien'")).toBe(true); + }); + + it('accepts a correctly doubled apostrophe', () => { + expect(hasUnterminatedLiteral("SELECT Note FROM Person WHERE Name = 'O''Brien'")).toBe(false); + }); + + it.each([ + "SELECT * FROM t WHERE a = 'x' AND b = 'y'", + 'SELECT * FROM t', + "SELECT * FROM t -- it's fine", + "SELECT * FROM t /* it's fine */", + ])('accepts %s', (sql) => { + expect(hasUnterminatedLiteral(sql)).toBe(false); + }); + + it('spots a value that runs off the end', () => { + expect(hasUnterminatedLiteral("SELECT * FROM t WHERE a = 'oops")).toBe(true); + }); +}); + +describe('literals and qualifiers the rewriter must not touch', () => { + const NAMES = ['Users', 'Notes', 'Sales']; + + /** Postgres and DuckDB dollar-quote bodies, which may contain anything at all. */ + it('leaves a dollar-quoted body alone', () => { + expect(quoteCatalogIdentifiers('SELECT * FROM Users WHERE Notes = $$from users now$$', NAMES, '"')).toBe( + 'SELECT * FROM "Users" WHERE "Notes" = $$from users now$$', + ); + }); + + it('leaves a tagged dollar-quoted body alone', () => { + expect(quoteCatalogIdentifiers('SELECT * FROM Users WHERE Notes = $x$from users$x$', NAMES, '"')).toBe( + 'SELECT * FROM "Users" WHERE "Notes" = $x$from users$x$', + ); + }); + + /** Quoting a schema qualifier turns a working query into "schema does not exist". */ + it('does not quote a qualifier that is not a table', () => { + expect(quoteCatalogIdentifiers('SELECT * FROM sales.orders', ['Sales'], '"', [])).toBeNull(); + }); + + it('still quotes a qualifier that is a real table', () => { + expect( + quoteCatalogIdentifiers('SELECT Customers.FirstName FROM Customers', ['Customers', 'FirstName'], '"', [ + 'Customers', + ]), + ).toBe('SELECT "Customers"."FirstName" FROM "Customers"'); + }); + + /** In prod.sales.orders the table is orders; sales is a qualifier. */ + it('does not recase the middle of a three-part name', () => { + expect(correctTableCase('SELECT * FROM prod.sales.orders', ['Sales'], '"', 'lower')).toBeNull(); + }); +}); + +describe('dialect-specific literal rules', () => { + const BS = String.fromCharCode(92); + + /** Backslash escapes a quote in MySQL only; Postgres, Oracle, SQLite and DuckDB read it literally. */ + it('treats a backslash as an escape only for the backtick dialect', () => { + expect(hasUnterminatedLiteral(`SELECT * FROM t WHERE n = 'O${BS}'Brien'`, true)).toBe(false); + expect(hasUnterminatedLiteral(`SELECT * FROM t WHERE n = 'O${BS}'Brien'`)).toBe(true); + }); + + it('still spots a genuinely unescaped apostrophe', () => { + expect(hasUnterminatedLiteral("SELECT * FROM t WHERE n = 'O'Brien'")).toBe(true); + }); + + /** A dollar-quoted body needs no escaping, so an apostrophe inside it is not a defect. */ + it('does not flag an apostrophe inside a dollar-quoted body', () => { + expect(hasUnterminatedLiteral("SELECT * FROM t WHERE n = $$don't$$")).toBe(false); + }); + + /** TIMESTAMP '2024-01-01' is one typed literal; quoting the word makes it a missing column. */ + it.each([ + ["SELECT * FROM t WHERE created > TIMESTAMP '2024-01-01'", 'Timestamp'], + ["SELECT * FROM t WHERE d > DATE '2024-01-01'", 'Date'], + ])('leaves a typed literal alone: %s', (sql, name) => { + expect(quoteCatalogIdentifiers(sql, [name], '"')).toBeNull(); + }); + + it('still quotes the same word used as a real column', () => { + expect(quoteCatalogIdentifiers('SELECT Timestamp FROM t', ['Timestamp'], '"')).toBe('SELECT "Timestamp" FROM t'); + }); +}); + +describe('names split across literal boundaries', () => { + const NAMES = ['Customers', 'FirstName', 'Country', 'City', 'Timestamp']; + + /** + * Literals split the statement into segments, and the typed-literal check reads the whole + * statement rather than one segment. If it read the segment, every name sitting at a segment + * boundary would silently stop being quoted. + */ + it('quotes names before, between and after several literals', () => { + const sql = "SELECT FirstName FROM Customers WHERE Country = 'UK' AND City = 'York' AND FirstName <> 'x'"; + + expect(quoteCatalogIdentifiers(sql, NAMES, '"')).toBe( + 'SELECT "FirstName" FROM "Customers" WHERE "Country" = \'UK\' AND "City" = \'York\' AND "FirstName" <> \'x\'', + ); + }); + + it('quotes a name that ends the statement, with a literal earlier', () => { + expect(quoteCatalogIdentifiers("SELECT * FROM Customers WHERE Country = 'UK' ORDER BY City", NAMES, '"')).toBe( + 'SELECT * FROM "Customers" WHERE "Country" = \'UK\' ORDER BY "City"', + ); + }); + + /** The one word that must not be quoted is the one a literal directly follows. */ + it('skips only the typed literal, quoting every other name in the same statement', () => { + const sql = "SELECT FirstName FROM Customers WHERE created > TIMESTAMP '2024-01-01'"; + + expect(quoteCatalogIdentifiers(sql, NAMES, '"')).toBe( + 'SELECT "FirstName" FROM "Customers" WHERE created > TIMESTAMP \'2024-01-01\'', + ); + }); +}); diff --git a/packages/core/test/nested-aggregate.test.ts b/packages/core/test/nested-aggregate.test.ts new file mode 100644 index 0000000..886cc0e --- /dev/null +++ b/packages/core/test/nested-aggregate.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { nestedAggregate } from '../src/semantics.js'; + +describe('nestedAggregate', () => { + /** Reported by a real question: AVG over a SUM is rejected by every engine. */ + it('flags an aggregate inside another aggregate', () => { + const sql = + 'SELECT c.country, AVG(o.freight + SUM(od.unit_price)) AS v FROM orders o ' + + 'JOIN customers c ON o.customer_id = c.customer_id JOIN order_details od ON o.order_id = od.order_id GROUP BY c.country'; + expect(nestedAggregate(sql, 'Postgresql')).toBe('AVG'); + }); + + it('allows aggregates side by side', () => { + expect(nestedAggregate('SELECT SUM(a), AVG(b) FROM t', 'Postgresql')).toBeNull(); + }); + + it('allows an aggregate over an expression', () => { + expect(nestedAggregate('SELECT SUM(a * b + 1) FROM t', 'Postgresql')).toBeNull(); + }); + + /** A subquery has its own scope, so its aggregate is not nested in the outer call. */ + it('allows an aggregate inside a subquery argument', () => { + expect(nestedAggregate('SELECT SUM((SELECT COUNT(*) FROM u WHERE u.id = t.id)) FROM t', 'Postgresql')).toBeNull(); + }); + + it('returns null for unparsable sql rather than blocking', () => { + expect(nestedAggregate('NOT SQL AT ALL', 'Postgresql')).toBeNull(); + }); + + it('flags nesting in HAVING', () => { + expect(nestedAggregate('SELECT a FROM t GROUP BY a HAVING SUM(COUNT(b)) > 1', 'Postgresql')).toBe('SUM'); + }); + + it('flags nesting in ORDER BY', () => { + expect(nestedAggregate('SELECT a FROM t GROUP BY a ORDER BY AVG(SUM(b))', 'Postgresql')).toBe('AVG'); + }); +}); diff --git a/packages/core/test/sql-keywords.test.ts b/packages/core/test/sql-keywords.test.ts new file mode 100644 index 0000000..ba78e04 --- /dev/null +++ b/packages/core/test/sql-keywords.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { reservedWordsFor } from '../src/sql-keywords.js'; + +/** Read from each engine's own catalog, so the lists differ; a shared guess is what they replaced. */ +describe('reservedWordsFor', () => { + it('gives each engine its own list', () => { + expect(reservedWordsFor('mysql').size).toBeGreaterThan(reservedWordsFor('postgres').size); + }); + + it.each([ + ['postgres', 'select'], + ['mysql', 'select'], + ['oracle', 'select'], + ['sqlite', 'select'], + ['duckdb', 'select'], + ])('%s reserves %s', (engine, word) => { + expect(reservedWordsFor(engine).has(word)).toBe(true); + }); + + /** MySQL reserves it, Postgres does not: proof the lists are not one shared set. */ + it('separates a word that only some engines reserve', () => { + expect(reservedWordsFor('mysql').has('rank')).toBe(true); + expect(reservedWordsFor('postgres').has('rank')).toBe(false); + }); + + /** An unknown engine gets the union, so a name is over-quoted rather than left broken. */ + it('falls back to the union for an unknown engine', () => { + const union = reservedWordsFor('not-an-engine'); + expect(union.size).toBeGreaterThan(reservedWordsFor('mysql').size); + }); + + it('is case-insensitive on the engine name', () => { + expect(reservedWordsFor('PostgreSQL'.toLowerCase().slice(0, 8)).size).toBe(reservedWordsFor('postgres').size); + }); +}); diff --git a/packages/core/test/table-case-repair.test.ts b/packages/core/test/table-case-repair.test.ts new file mode 100644 index 0000000..7c4247d --- /dev/null +++ b/packages/core/test/table-case-repair.test.ts @@ -0,0 +1,219 @@ +/** + * The engine wiring for the catalog-driven case repair: a wrong-cased table must come back with a + * corrected query, and must not spend a model round trip getting there. + */ +import { describe, expect, it, vi } from 'vitest'; +import { createAskSql, firstUnknownColumn, firstUnknownTable } from '../src/engine.js'; +import { AskSqlError } from '../src/errors.js'; +import { MYSQL_DIALECT, POSTGRES_DIALECT } from '../src/dialects.js'; +import type { Connector, CustomModel, ResultSet, SchemaCatalog } from '../src/types.js'; + +const CATALOG: SchemaCatalog = { + engine: 'mysql', + schemas: ['public'], + tables: [ + { + name: 'Users', + kind: 'table', + columns: [ + { name: 'id', dbType: 'bigint', nullable: false }, + { name: 'name', dbType: 'text', nullable: false }, + ], + primaryKey: ['id'], + foreignKeys: [], + uniques: [], + checks: [], + indexes: [], + source: 'db', + }, + ], + enums: [], + sequences: [], + triggers: [], + routines: [], + warnings: [], + fetchedAt: 'now', +}; + +function connThatRejects(detail: string, execute?: () => Promise): Connector { + return { + // MySQL folds nothing, so generation leaves the case alone and the repair path is what runs. + engine: 'mysql', + dialect: MYSQL_DIALECT, + capabilities: { + supportsCancel: true, + supportsExplain: true, + supportsSchemas: true, + readOnlySession: true, + supportsMatViews: true, + supportsTriggers: true, + supportsRoutines: true, + }, + id: 'db', + name: 'DB', + async connect() {}, + async close() {}, + async introspect() { + return CATALOG; + }, + execute: + execute ?? + (async () => { + throw new AskSqlError('DB_QUERY_ERROR', { userMessage: 'query failed', detail }); + }), + } as unknown as Connector; +} + +const modelSaying = + (sql: string): CustomModel => + async () => + `\`\`\`sql\n${sql}\n\`\`\``; + +describe('wrong-cased table repair', () => { + it('suggests the catalog spelling when the database rejects the case', async () => { + const conn = connThatRejects('relation "users" does not exist'); + const engine = createAskSql({ connectors: [conn], model: modelSaying('SELECT * FROM users') }); + + const answer = await engine.ask('list the users'); + const err = await answer.run().catch((e: unknown) => e); + + expect(AskSqlError.is(err)).toBe(true); + expect((err as { suggestedSql?: string }).suggestedSql).toContain('`Users`'); + }); + + it('derives the fix from the catalog rather than a second model call', async () => { + const model = vi.fn(async () => '```sql\nSELECT * FROM users\n```'); + const engine = createAskSql({ + connectors: [connThatRejects('relation "users" does not exist')], + model: model as unknown as CustomModel, + }); + + await engine + .ask('list the users') + .then((a) => a.run()) + .catch(() => undefined); + + expect(model).toHaveBeenCalledTimes(1); // the ask itself, with no repair round trip + }); + + /** A failure the catalog cannot explain still belongs to the model repair. */ + it('leaves an unrelated database error to the model', async () => { + const model = vi.fn(async () => '```sql\nSELECT * FROM `Users`\n```'); + const engine = createAskSql({ + connectors: [connThatRejects('column "nope" does not exist')], + model: model as unknown as CustomModel, + }); + + await engine + .ask('list the users') + .then((a) => a.run()) + .catch(() => undefined); + + expect(model.mock.calls.length).toBeGreaterThan(1); // ask, then the repair attempt + }); + + it('does not suggest anything when the query already matches the catalog', async () => { + let calls = 0; + const conn = connThatRejects('', async () => { + calls++; + throw new AskSqlError('DB_QUERY_ERROR', { userMessage: 'boom', detail: 'deadlock detected' }); + }); + const engine = createAskSql({ connectors: [conn], model: modelSaying('SELECT * FROM `Users`') }); + + const answer = await engine.ask('list the users'); + const err = await answer.run().catch((e: unknown) => e); + + expect(calls).toBe(1); + expect((err as { suggestedSql?: string }).suggestedSql).toBeUndefined(); + }); + + /** On a folding engine the query is corrected before it runs, so nothing has to fail first. */ + it('quotes a folded name at generation time on Postgres', async () => { + const pg = { + ...connThatRejects('unused'), + engine: 'postgres', + dialect: POSTGRES_DIALECT, + async execute() { + return { columns: [], rows: [], rowCount: 0, truncated: false, durationMs: 1, warnings: [] }; + }, + } as unknown as Connector; + const engine = createAskSql({ connectors: [pg], model: modelSaying('SELECT name FROM users') }); + + const answer = await engine.ask('list the users'); + + expect(answer.sql).toContain('"Users"'); + }); +}); + +describe('unknown-column floor on set operations', () => { + const catalog = { + engine: 'sqlite', + schemas: [], + tables: [ + { name: 'Album', kind: 'table', columns: [{ name: 'AlbumId', dbType: 'int', nullable: false }], primaryKey: [], foreignKeys: [], uniques: [], checks: [], indexes: [], source: 'db' }, + { name: 'Artist', kind: 'table', columns: [{ name: 'ArtistId', dbType: 'int', nullable: false }], primaryKey: [], foreignKeys: [], uniques: [], checks: [], indexes: [], source: 'db' }, + ], + enums: [], sequences: [], triggers: [], routines: [], warnings: [], fetchedAt: 'now', + } as unknown as SchemaCatalog; + + /** Per-table row counts are a normal DBA question, and this blocked them outright. */ + it('does not flag a UNION ALL of per-table counts', () => { + const sql = + "SELECT 'Album' AS TableName, COUNT(*) AS RowCount FROM Album " + + "UNION ALL SELECT 'Artist' AS TableName, COUNT(*) AS RowCount FROM Artist"; + + expect(firstUnknownColumn(sql, catalog, 'sqlite')).toBeNull(); + }); + + it('still flags a hallucinated column on a plain select', () => { + expect(firstUnknownColumn('SELECT Nope FROM Album', catalog, 'sqlite')).not.toBeNull(); + }); +}); + +describe('set-operation detection ignores literals', () => { + const catalog = { + engine: 'postgres', + schemas: [], + tables: [ + { name: 'notes', kind: 'table', columns: [{ name: 'body', dbType: 'text', nullable: false }], primaryKey: [], foreignKeys: [], uniques: [], checks: [], indexes: [], source: 'db' }, + ], + enums: [], sequences: [], triggers: [], routines: [], warnings: [], fetchedAt: 'now', + } as unknown as SchemaCatalog; + + /** A value containing "except" once disabled the column floor for an ordinary query. */ + it('still flags a hallucinated column when a literal contains a set-operation word', () => { + const sql = "SELECT nope FROM notes WHERE body = 'except this'"; + expect(firstUnknownColumn(sql, catalog, 'Postgresql')).not.toBeNull(); + }); + + it('still skips attribution for a real set operation', () => { + const sql = "SELECT nope FROM notes UNION ALL SELECT body FROM notes"; + expect(firstUnknownColumn(sql, catalog, 'Postgresql')).toBeNull(); + }); +}); + +describe('catalog-driven guards', () => { + const base = { + engine: 'postgres', + schemas: [], + enums: [], sequences: [], triggers: [], routines: [], warnings: [], fetchedAt: 'now', + }; + const table = (name: string, cols: string[]) => ({ + name, kind: 'table', + columns: cols.map((c) => ({ name: c, dbType: 'text', nullable: true })), + primaryKey: [], foreignKeys: [], uniques: [], checks: [], indexes: [], source: 'db', + }); + + /** A quoted CTE was read as a hallucinated table, rejecting a valid query. */ + it('recognises a CTE whose name is quoted', () => { + const catalog = { ...base, tables: [table('Orders', ['Amount'])] } as unknown as SchemaCatalog; + const sql = 'WITH "Amount" AS (SELECT "Amount" FROM "Orders") SELECT * FROM "Amount"'; + + expect(firstUnknownTable(sql, catalog, 'Postgresql')).toBeNull(); + }); + + it('still reports a genuinely unknown table', () => { + const catalog = { ...base, tables: [table('Orders', ['Amount'])] } as unknown as SchemaCatalog; + expect(firstUnknownTable('SELECT * FROM nosuchtable', catalog, 'Postgresql')).toBe('nosuchtable'); + }); +}); diff --git a/tests/bundle-size.test.ts b/tests/bundle-size.test.ts index 7ac4d4e..7de9f2f 100644 --- a/tests/bundle-size.test.ts +++ b/tests/bundle-size.test.ts @@ -26,8 +26,12 @@ const BUDGETS: Record = { // stops measuring. History: 45->51 scope guard + Mongo, 51->54 grounding vocabularies, // 54->60 question routing + the ungrouped-aggregate lint, 60->63 routing precision + // identifier quoting, 63->65 routing words disambiguated from identifiers ("the archive table", - // "the best selling products", "the prompts table"). - core: 65, + // "the best selling products", "the prompts table"), 65->66 identifier normalisation plus the + // connection identity and catalog hint that keep system-catalog queries from being guessed, + // 67->69 each engine's own reserved words read from its catalog (MySQL reserves 262, the shared + // guess had ~100), stored as one word list plus a bit per engine, 69->70 dollar-quoted literals, + // qualifier handling and the per-dialect backslash rule. + core: 70, // 20 -> 23: copy controls, streamed-token progress, cell tooltips, export feedback, result-grid copy. react: 23, // 12 -> 13: the CSRF/Host gate every adapter inherits, client-path confinement for @@ -64,7 +68,9 @@ describe('bundle-size budgets (gzipped, own code)', () => { const core = gzippedKb('core'); const react = gzippedKb('react'); if (core === null || react === null) return; - // Own code only (React is a peer): must stay well under the 85 KB budget. - expect(core + react).toBeLessThan(85); + // Own code only (React is a peer). 85 -> 91: identifier normalisation, which is what stops a + // mixed-case Postgres schema from failing every query, the prompt's connection identity, and the + // per-engine reserved-word lists, and the literal rules each dialect actually follows. + expect(core + react).toBeLessThan(92); }); }); diff --git a/tools/generate-sql-keywords.mjs b/tools/generate-sql-keywords.mjs new file mode 100644 index 0000000..f509315 --- /dev/null +++ b/tools/generate-sql-keywords.mjs @@ -0,0 +1,206 @@ +/** + * Regenerates packages/core/src/sql-keywords.ts from the databases themselves, so the reserved-word + * lists are the engines' own rather than a hand-maintained guess that drifts. + * + * Needs the engines reachable; see docs for the local containers. Usage: + * node tools/generate-sql-keywords.mjs + */ +import { writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; + +const sh = (cmd, args) => execFileSync(cmd, args, { encoding: 'utf8', maxBuffer: 1 << 24 }); + +/** Each engine's own catalog of reserved words. SQLite has none, so its published list is inlined. */ +const SOURCES = { + postgres: () => + sh('docker', [ + 'exec', + 'asksql-pg', + 'psql', + '-U', + 'postgres', + '-tAc', + "SELECT word FROM pg_get_keywords() WHERE catcode IN ('R','T') ORDER BY word", + ]), + mysql: () => + sh('docker', [ + 'exec', + 'asksql-mysql', + 'mysql', + '-uroot', + '-N', + '-e', + 'SELECT LOWER(WORD) FROM information_schema.KEYWORDS WHERE RESERVED=1 ORDER BY WORD', + ]), + oracle: () => + sh('docker', [ + 'exec', + 'asksql-oracle', + 'bash', + '-lc', + `echo "SET PAGESIZE 0 FEEDBACK OFF +SELECT LOWER(keyword) FROM V\\$RESERVED_WORDS WHERE reserved='Y' ORDER BY keyword; +EXIT;" | sqlplus -s system/oracle@localhost:1521/FREEPDB1`, + ]), + duckdb: async () => { + const { DuckDBInstance } = await import('../packages/duckdb/node_modules/@duckdb/node-api/lib/index.js'); + const instance = await DuckDBInstance.create(':memory:'); + const conn = await instance.connect(); + const result = await conn.runAndReadAll( + "SELECT keyword_name FROM duckdb_keywords() WHERE keyword_category = 'reserved' ORDER BY 1", + ); + return result + .getRows() + .map((r) => String(r[0])) + .join(','); + }, +}; + +const SQLITE = `abort action add after all alter always analyze and as asc attach autoincrement before begin +between by cascade case cast check collate column commit conflict constraint create cross current +current_date current_time current_timestamp database default deferrable deferred delete desc detach +distinct do drop each else end escape except exclude exclusive exists explain fail filter first +following for foreign from full generated glob group groups having if ignore immediate in index +indexed initially inner insert instead intersect into is isnull join key last left like limit match +materialized natural no not nothing notnull null nulls of offset on or order others outer over +partition plan pragma preceding primary query raise range recursive references regexp reindex release +rename replace restrict returning right rollback row rows savepoint select set table temp temporary +then ties to transaction trigger unbounded union unique update using vacuum values view virtual when +where window with without`; + +const clean = (text) => + [ + ...new Set( + text + .split(/[\s,]+/) + .map((w) => w.trim().toLowerCase()) + .filter((w) => /^[a-z_][a-z0-9_]*$/.test(w)), + ), + ].sort(); + +const engines = { sqlite: clean(SQLITE) }; +for (const [name, read] of Object.entries(SOURCES)) { + try { + const words = clean(await read()); + if (words.length > 20) engines[name] = words; + else console.warn(`[skip] ${name}: only ${words.length} words, is the container running?`); + } catch (err) { + console.warn(`[skip] ${name}: ${String(err).split('\n')[0]}`); + } +} + +// Emitting a file that quietly lost an engine would downgrade it to the union without anyone noticing. +const EXPECTED = ['postgres', 'mysql', 'oracle', 'duckdb', 'sqlite']; +const missing = EXPECTED.filter((e) => !engines[e]); +if (missing.length > 0) { + console.error(`refusing to write: no keywords read for ${missing.join(', ')}. Start the databases and retry.`); + process.exit(1); +} + +const all = [...new Set(Object.values(engines).flat())].sort(); +const index = new Map(all.map((w, i) => [w, i])); +const chunks = all.join('|').match(/.{1,110}/g) ?? []; + +/** One bit per word in the shared list, so a set costs 64 characters instead of a few hundred. */ +const bitmap = (words) => { + const bytes = new Uint8Array(Math.ceil(all.length / 8)); + for (const w of words) { + const i = index.get(w); + bytes[i >> 3] |= 1 << (i & 7); + } + return Buffer.from(bytes).toString('base64'); +}; + +const HEADER = `/** + * Reserved words per engine, read from each database itself rather than guessed: + * pg_get_keywords(), information_schema.KEYWORDS, V$RESERVED_WORDS, duckdb_keywords(). + * SQLite publishes a fixed list and has no catalog to query. + * + * Regenerate with: node tools/generate-sql-keywords.mjs + * One word list plus a bit per engine, which costs a few hundred bytes instead of repeating words. + */`; + +writeFileSync( + 'packages/core/src/sql-keywords.ts', + `${HEADER} + +const WORDS = +${chunks.map((c, i) => ` '${c}'${i === chunks.length - 1 ? ';' : ' +'}`).join('\n')} + +const WORD_LIST = WORDS.split('|'); + +/** One bit per word in WORD_LIST, base64 encoded. */ +const BY_ENGINE: Record = { +${Object.entries(engines) + .map(([e, w]) => ` ${e}: '${bitmap(w)}',`) + .join('\n')} +}; + +const cache = new Map>(); + +/** The engine's reserved words; an unknown engine falls back to the union, which is the safe side. */ +export function reservedWordsFor(engine: string): ReadonlySet { + const key = engine.toLowerCase(); + let set = cache.get(key); + if (!set) { + const packed = BY_ENGINE[key]; + if (packed) { + const bytes = atob(packed); + set = new Set(WORD_LIST.filter((_, i) => (bytes.charCodeAt(i >> 3) >> (i & 7)) & 1)); + } else set = new Set(WORD_LIST); + cache.set(key, set); + } + return set; +} +`, +); + +// The plugin mirrors the same data; generating both here is what stops the two from drifting. +const ktChunks = all.join('|').match(/.{1,100}/g) ?? []; +writeFileSync( + 'packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/engine/SqlKeywords.kt', + `package com.rahulmahadik.asksql.ide.engine + +/** + * Reserved words per engine, generated alongside packages/core/src/sql-keywords.ts so the plugin and + * the npm engine cannot drift. Regenerate both with: node tools/generate-sql-keywords.mjs + */ +object SqlKeywords { + + private val WORDS = ( +${ktChunks.map((c, i) => ` "${c}"${i === ktChunks.length - 1 ? '' : ' +'}`).join('\n')} + ).split("|") + + /** One bit per word in WORDS, base64 encoded. */ + private val BY_ENGINE = mapOf( +${Object.entries(engines) + .map(([e, w]) => ` "${e}" to "${bitmap(w)}",`) + .join('\n')} + ) + + private val cache = HashMap>() + + /** The engine's reserved words; an unknown engine falls back to the union, which is the safe side. */ + fun reservedWordsFor(engine: String): Set { + val key = engine.lowercase() + cache[key]?.let { return it } + val packed = BY_ENGINE[key] + val set = if (packed != null) { + val bytes = java.util.Base64.getDecoder().decode(packed) + WORDS.filterIndexed { i, _ -> (bytes[i shr 3].toInt() shr (i and 7)) and 1 == 1 }.toSet() + } else { + WORDS.toSet() + } + cache[key] = set + return set + } +} +`, +); + +console.log( + 'engines:', + Object.fromEntries(Object.entries(engines).map(([e, w]) => [e, w.length])), + 'unique:', + all.length, +); From 7e828d7776a2c7c91151d7ecabea857f81c94c82 Mon Sep 17 00:00:00 2001 From: rahulmahadik Date: Fri, 14 Aug 2026 01:11:40 +0800 Subject: [PATCH 2/4] Mirror the identifier and semantic work in the plugin The plugin carries its own Kotlin engine, so the same rules have to exist twice and stay in step. The tests mirror the core suite case for case, which is what catches a divergence: the nested-aggregate branch here never incremented the repair counter, and the column floor read raw SQL where core reads text with literals blanked, so a value containing the word union disabled it. --- .../asksql/ide/engine/CatalogPruner.kt | 16 +- .../asksql/ide/engine/EnginePipeline.kt | 61 +++- .../asksql/ide/engine/HallucinationChecks.kt | 51 ++- .../asksql/ide/engine/IdentifierCase.kt | 259 ++++++++++++++ .../rahulmahadik/asksql/ide/engine/Prompts.kt | 21 ++ .../asksql/ide/engine/Semantics.kt | 39 +++ .../asksql/ide/engine/SqlKeywords.kt | 66 ++++ .../asksql/ide/engine/IdentifierCaseTest.kt | 325 ++++++++++++++++++ .../asksql/ide/engine/NestedAggregateTest.kt | 42 +++ .../asksql/ide/engine/PromptIdentityTest.kt | 39 +++ .../ide/engine/SetOperationColumnTest.kt | 40 +++ .../asksql/ide/engine/SqlKeywordsTest.kt | 36 ++ 12 files changed, 975 insertions(+), 20 deletions(-) create mode 100644 packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/engine/IdentifierCase.kt create mode 100644 packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/engine/SqlKeywords.kt create mode 100644 packages/jetbrains/src/test/kotlin/com/rahulmahadik/asksql/ide/engine/IdentifierCaseTest.kt create mode 100644 packages/jetbrains/src/test/kotlin/com/rahulmahadik/asksql/ide/engine/NestedAggregateTest.kt create mode 100644 packages/jetbrains/src/test/kotlin/com/rahulmahadik/asksql/ide/engine/PromptIdentityTest.kt create mode 100644 packages/jetbrains/src/test/kotlin/com/rahulmahadik/asksql/ide/engine/SetOperationColumnTest.kt create mode 100644 packages/jetbrains/src/test/kotlin/com/rahulmahadik/asksql/ide/engine/SqlKeywordsTest.kt diff --git a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/engine/CatalogPruner.kt b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/engine/CatalogPruner.kt index a90c3f9..59a2b98 100644 --- a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/engine/CatalogPruner.kt +++ b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/engine/CatalogPruner.kt @@ -68,24 +68,14 @@ object CatalogPruner { */ private fun quoteCharFor(engine: EngineKind): Char = if (engine == EngineKind.MYSQL) '`' else '"' - /** Words an engine will not accept as a bare identifier; the ones that turn up as real column names, not every dialect. */ - private val RESERVED_WORDS = ( - "select from where group by order having limit offset union all distinct join inner outer left " + - "right full cross natural on using as into insert update delete set values create drop alter " + - "table column view index key primary foreign unique constraint references default check null " + - "not and or in is like between case when then else end exists any some cast collate with " + - "recursive returning window over partition range rows current session system user grant revoke " + - "to begin commit rollback transaction lock database schema trigger procedure function " + - "desc asc date time timestamp interval level size type comment position language" - ).split(" ").toSet() - + /** * True when the engine would not read the bare name back as itself: an unquoted identifier * folds case - PostgreSQL to lower, Oracle to upper. */ - private fun needsQuoting(name: String, engine: EngineKind): Boolean { + internal fun needsQuoting(name: String, engine: EngineKind): Boolean { if (!PLAIN_IDENTIFIER_RE.matches(name)) return true - if (name.lowercase() in RESERVED_WORDS) return true + if (name.lowercase() in SqlKeywords.reservedWordsFor(engine.name)) return true return when (engine) { EngineKind.ORACLE -> name != name.uppercase() // MySQL, SQLite and DuckDB match identifiers case-insensitively. diff --git a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/engine/EnginePipeline.kt b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/engine/EnginePipeline.kt index 51b1586..7bc9763 100644 --- a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/engine/EnginePipeline.kt +++ b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/engine/EnginePipeline.kt @@ -335,6 +335,16 @@ class EnginePipeline( onEvent?.onEvent(EngineEvent.StageEvent(Stage.CATALOG)) val fullCatalog = catalog(descriptor, password) + // Names the engine would not read back as themselves: folded case, reserved words, symbols. + // A name spelled two ways across the catalog is skipped: rewriting "status" to "Status" would + // ask one table for another table's column. + val allNames = fullCatalog.tables.flatMap { t -> listOf(t.name) + t.columns.map { it.name } } + val spellings = allNames.groupBy { it.lowercase() } + val quotableNames = allNames.filter { + CatalogPruner.needsQuoting(it, descriptor.engine) && spellings[it.lowercase()]?.distinct()?.size == 1 + } + // Only a table may be quoted before a dot; a schema qualifier that matched a column name broke it. + val quotableTables = fullCatalog.tables.map { it.name }.filter { it in quotableNames } onEvent?.onEvent(EngineEvent.StageEvent(Stage.PRUNE)) val initialPrunerSettings = CatalogPruner.PrunerSettings(maxSchemaTokens = maxSchemaTokens) @@ -351,6 +361,9 @@ class EnginePipeline( glossary = glossary, context = context, rerunPrevious = isRerunPreviousRequest(q), + database = descriptor.database, + schemas = fullCatalog.schemas, + catalogHint = if (isMetadataQuestion(q)) catalogQueryHint(descriptor.engine) else null, ) var lastSql = "" @@ -382,7 +395,10 @@ class EnginePipeline( ) pruned = tighter schemaText = tighter.schemaText - userPrompt = Prompts.buildSqlUser(question = q, schemaText = schemaText, context = context) + userPrompt = Prompts.buildSqlUser( + question = q, schemaText = schemaText, context = context, + database = descriptor.database, schemas = fullCatalog.schemas, + ) continue } throw e @@ -449,7 +465,13 @@ class EnginePipeline( lastSql = extraction.sql onEvent?.onEvent(EngineEvent.StageEvent(Stage.GUARD)) - val verdict = SqlGuard.guard(extraction.sql, dialect, policy) + // Quote first: a folding engine resolves a bare name elsewhere, and the parser cannot read a + // bare table named like a keyword. Falls back untouched if quoting makes it unparseable. + val normalised = + IdentifierCase.quoteCatalogIdentifiers(extraction.sql, quotableNames, dialect.quoteChar, quotableTables) + val normalisedVerdict = normalised?.let { SqlGuard.guard(it, dialect, policy) } + val verdict = if (normalisedVerdict?.allowed == true) normalisedVerdict + else SqlGuard.guard(extraction.sql, dialect, policy) if (!verdict.allowed) { if (attempt >= MAX_REPAIRS) { history.add(auditEntry(descriptor.id, q, extraction.sql, HistoryStatus.BLOCKED, verdict.ruleId)) @@ -459,9 +481,13 @@ class EnginePipeline( detail = "ruleId=${verdict.ruleId} after ${attempt + 1} attempts", ) } + // "could not parse" alone leaves the model repeating the same statement; name the real cause. + val quoteHint = if (IdentifierCase.hasUnterminatedLiteral(extraction.sql, dialect.quoteChar == '`')) { + " A text value contains an apostrophe that is not escaped: write it doubled, as 'O''Brien'." + } else "" userPrompt = Prompts.buildRepairUser( question = q, failedSql = extraction.sql, - failure = "The SQL validator rejected it: ${verdict.reason ?: verdict.ruleId ?: "not allowed"}. Produce a single read-only SELECT.", + failure = "The SQL validator rejected it: ${verdict.reason ?: verdict.ruleId ?: "not allowed"}.$quoteHint Produce a single read-only SELECT.", schemaText = schemaText, dialect = dialect, ) attempt++ @@ -492,9 +518,12 @@ class EnginePipeline( retryable = false, ) } + // The column repair already names the real columns; give the table repair the same head start. + val nearest = SchemaFuzzyMatch.closestTableName(unknownTable, fullCatalog) + val didYouMean = if (nearest != null) " Did you mean \"$nearest\"?" else "" userPrompt = Prompts.buildRepairUser( question = q, failedSql = verdict.sql, - failure = "Table \"$unknownTable\" does not exist in the schema. Use only tables from the block.", + failure = "Table \"$unknownTable\" does not exist in the schema.$didYouMean Use only tables from the block.", schemaText = schemaText, dialect = dialect, ) attempt++ @@ -516,6 +545,19 @@ class EnginePipeline( continue } + // Semantic floor: AVG(SUM(x)) and friends. Every engine rejects it, so repair before executing. + val nested = Semantics.nestedAggregate(verdict.sql) + if (nested != null && attempt < MAX_REPAIRS) { + userPrompt = Prompts.buildRepairUser( + question = q, failedSql = verdict.sql, + failure = "$nested() contains another aggregate, which no SQL engine allows. Aggregate once " + + "over the rows, or aggregate the inner result in a subquery or CTE and then aggregate that.", + schemaText = schemaText, dialect = dialect, + ) + attempt++ + continue + } + val unknownColumn = HallucinationChecks.firstUnknownColumn(verdict.sql, fullCatalog) if (unknownColumn != null) { if (attempt >= MAX_REPAIRS) { @@ -613,6 +655,17 @@ class EnginePipeline( return try { val dialect = Dialects.of(descriptor.engine) val catalog = catalog(descriptor, password) + // A wrong-cased table is repairable from the catalog alone, so try that before the model. + if (errorDetail != null && IdentifierCase.looksLikeUnknownTable(errorDetail)) { + val cased = IdentifierCase.correctTableCase( + bad, catalog.tables.map { it.name }, dialect.quoteChar, + IdentifierCase.foldingFor(descriptor.engine.name), + ) + if (cased != null) { + val casedVerdict = SqlGuard.guard(cased, dialect, policy) + if (casedVerdict.allowed) return casedVerdict.sql + } + } val schemaText = CatalogPruner.pruneCatalog(catalog, q).schemaText val repairPrompt = Prompts.buildRepairUser( question = q, failedSql = bad, diff --git a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/engine/HallucinationChecks.kt b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/engine/HallucinationChecks.kt index 1993f47..4f00893 100644 --- a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/engine/HallucinationChecks.kt +++ b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/engine/HallucinationChecks.kt @@ -19,9 +19,15 @@ object HallucinationChecks { data class UnknownColumn(val table: String, val column: String, val available: List) private val SYSTEM_SCHEMAS = setOf("information_schema", "pg_catalog", "mysql", "performance_schema", "sys") - private val CTE_NAME = Regex("""([A-Za-z_][A-Za-z0-9_]*)\s+as\s*\(""", RegexOption.IGNORE_CASE) + /** + * The quote characters matter: normalisation may have quoted a CTE named like a catalog column, + * and a model can quote one itself. An unrecognised CTE reads as a hallucinated table. + */ + private val CTE_NAME = + Regex("""["`\[]?([A-Za-z_][A-Za-z0-9_]*)["`\]]?\s+as\s*\(""", RegexOption.IGNORE_CASE) private val HAS_WITH = Regex("""\bwith\b""", RegexOption.IGNORE_CASE) private val SELECT_ALIAS_RE = Regex("""\bas\s+["'`]?([A-Za-z_][A-Za-z0-9_]*)["'`]?""", RegexOption.IGNORE_CASE) + private val SET_OPERATION_RE = Regex("""\b(union|intersect|except)\b""", RegexOption.IGNORE_CASE) private val SUBQUERY_OPEN_RE = Regex("""\(\s*select\b""", RegexOption.IGNORE_CASE) /** Scans the whole statement; over-collecting CTE names only makes the floor more lenient. */ @@ -85,8 +91,14 @@ object HallucinationChecks { val aliases = SELECT_ALIAS_RE.findAll(sql).map { it.groupValues[1].lowercase() }.toSet() val tableAliases = collectTableAliases(statement) - val hasSubquery = SUBQUERY_OPEN_RE.containsMatchIn(sql) - var attributable = !hasSubquery + // Both probes read blanked text: a literal or comment must not disable the floor. + val code = withoutLiterals(sql) + val hasSubquery = SUBQUERY_OPEN_RE.containsMatchIn(code) + // A set operation has one column list per branch, and the parser reports them merged, so a column + // from one branch would be judged against another branch's tables. Not attributable, like a subquery. + // Blank literals first: a value like 'except this' would otherwise disable the floor entirely. + val hasSetOperation = SET_OPERATION_RE.containsMatchIn(code) + var attributable = !hasSubquery && !hasSetOperation val queryTables = mutableListOf() val tableNames = try { @@ -198,4 +210,37 @@ object HallucinationChecks { else -> Unit } } + + /** The statement with string literals and line comments blanked out; offsets are preserved. */ + private fun withoutLiterals(sql: String): String { + val out = StringBuilder(sql) + var i = 0 + while (i < sql.length) { + when { + sql[i] == '\'' -> { + var j = i + 1 + while (j < sql.length) { + if (sql[j] == '\'' && j + 1 < sql.length && sql[j + 1] == '\'') j += 2 + else if (sql[j] == '\'') break + else j++ + } + for (k in i..minOf(j, sql.length - 1)) out[k] = ' ' + i = j + 1 + } + sql[i] == '-' && i + 1 < sql.length && sql[i + 1] == '-' -> { + var j = i + while (j < sql.length && sql[j] != '\n') { out[j] = ' '; j++ } + i = j + } + sql[i] == '/' && i + 1 < sql.length && sql[i + 1] == '*' -> { + val close = sql.indexOf("*/", i + 2) + val end = if (close == -1) sql.length else close + 2 + for (k in i until end) out[k] = ' ' + i = end + } + else -> i++ + } + } + return out.toString() + } } diff --git a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/engine/IdentifierCase.kt b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/engine/IdentifierCase.kt new file mode 100644 index 0000000..c0b0f0b --- /dev/null +++ b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/engine/IdentifierCase.kt @@ -0,0 +1,259 @@ +package com.rahulmahadik.asksql.ide.engine + +/** + * A table named in the wrong case is the one database rejection a catalog can repair on its own, + * with no model round trip. MySQL on Linux compares table names case-sensitively, and Postgres and + * Oracle fold unquoted names, so the corrected name is quoted to survive either rule. + */ +object IdentifierCase { + + /** The union across engines: treating a word as syntax is the conservative side of this decision. */ + private val ANY_RESERVED = SqlKeywords.reservedWordsFor("*") + + /** Only these lead-ins put an identifier in table position, which keeps a same-named column alone. */ + private val TABLE_POSITION = + Regex("""\b(from|join|update|into)(\s+)([`"\[]?)([A-Za-z_][\w$]*)[`"\]]?(\s*\.\s*([`"\[]?)([A-Za-z_][\w$]*)[`"\]]?)?""", RegexOption.IGNORE_CASE) + + private val UNKNOWN_TABLE = + Regex("""\b(doesn't exist|does not exist|not found|unknown table|invalid object name|undefined table|no such table|table or view does not exist)\b""", RegexOption.IGNORE_CASE) + + fun looksLikeUnknownTable(message: String): Boolean = UNKNOWN_TABLE.containsMatchIn(message) + + private fun quoted(name: String, quoteChar: Char): String { + val close = if (quoteChar == '[') ']' else quoteChar + return "$quoteChar$name$close" + } + + /** Where a literal or comment ends, or -1 when the position starts neither. */ + /** A dollar-quoted body is a literal in Postgres and DuckDB, and may contain anything. */ + private val DOLLAR_OPEN = Regex("""\$[A-Za-z_]\w*\$|\$\$""") + + private fun skipTo(sql: String, i: Int, doubleQuoteIsLiteral: Boolean, backslashEscapes: Boolean = false): Int { + val ch = sql[i] + val next = if (i + 1 < sql.length) sql[i + 1] else ' ' + if (ch == '$') { + val open = DOLLAR_OPEN.matchAt(sql, i) + if (open != null) { + val close = sql.indexOf(open.value, i + open.value.length) + return if (close == -1) sql.length else close + open.value.length + } + } + if (ch == '-' && next == '-') { + val nl = sql.indexOf('\n', i) + return if (nl == -1) sql.length else nl + } + if (ch == '/' && next == '*') { + val close = sql.indexOf("*/", i + 2) + return if (close == -1) sql.length else close + 2 + } + if (ch == '\'' || (ch == '"' && doubleQuoteIsLiteral)) { + var j = i + 1 + while (j < sql.length) { + when { + backslashEscapes && sql[j] == '\\' -> j += 2 + sql[j] == ch -> if (j + 1 < sql.length && sql[j + 1] == ch) j += 2 else return j + 1 + else -> j++ + } + } + return sql.length + } + return -1 + } + + /** How an engine resolves an unquoted identifier: Postgres lower-cases it, Oracle upper-cases it. */ + enum class Folding { LOWER, UPPER, NONE } + + fun foldingFor(engine: String): Folding = when (engine.lowercase()) { + "postgres" -> Folding.LOWER + "oracle" -> Folding.UPPER + else -> Folding.NONE + } + + private fun folded(name: String, folding: Folding): String = when (folding) { + Folding.LOWER -> name.lowercase() + Folding.UPPER -> name.uppercase() + Folding.NONE -> name + } + + /** Returns the rewritten SQL, or null when no table reference needed correcting. */ + fun correctTableCase( + sql: String, + tableNames: List, + quoteChar: Char, + folding: Folding = Folding.NONE, + ): String? { + val byLower = HashMap() + for (name in tableNames) { + val lower = name.lowercase() + // An ambiguous fold has no single right answer, so leave those names untouched. + if (byLower.containsKey(lower) && byLower[lower] != name) byLower[lower] = "" else byLower.putIfAbsent(lower, name) + } + + var changed = false + fun fixCode(code: String): String = TABLE_POSITION.replace(code) { m -> + val keyword = m.groupValues[1] + val gap = m.groupValues[2] + val open = m.groupValues[3] + val first = m.groupValues[4] + val second = m.groupValues[7] + val target = second.ifEmpty { first } + val open2 = m.groupValues[6] + val canonical = byLower[target.lowercase()] + // A third part means what matched is a qualifier: prod.sales.orders names orders, not sales. + val restStartsWithDot = code.substring(m.range.last + 1).trimStart().startsWith(".") + // An unquoted name is resolved folded, so what matters is what the database will look up. + val wasQuoted = (if (second.isEmpty()) open else open2).isNotEmpty() + val resolvesTo = if (wasQuoted) target else folded(target, folding) + if (canonical.isNullOrEmpty() || restStartsWithDot || resolvesTo == canonical) { + m.value + } else { + changed = true + val fixed = quoted(canonical, quoteChar) + if (second.isEmpty()) "$keyword$gap$fixed" + else "$keyword$gap${if (open.isNotEmpty()) quoted(first, open[0]) else first}.$fixed" + } + } + + // A double quote is a string in MySQL but an identifier in Postgres, so the dialect decides. + val doubleQuoteIsLiteral = quoteChar != '"' + // MySQL is the only engine here that escapes with a backslash, and the backtick identifies it. + val backslashEscapes = quoteChar == '`' + val out = StringBuilder() + var start = 0 + var i = 0 + while (i < sql.length) { + val end = skipTo(sql, i, doubleQuoteIsLiteral, backslashEscapes) + if (end >= 0) { + out.append(fixCode(sql.substring(start, i))).append(sql, i, end) + start = end + i = end + } else i++ + } + out.append(fixCode(sql.substring(start))) + return if (changed) out.toString() else null + } + + /** A bare identifier, and whatever follows it, so a function call can be told from a column. */ + private val BARE_IDENTIFIER = Regex("""([A-Za-z_][\w$]*)(\s*[.(]?)""") + + /** + * A reserved word is only treated as a name where it cannot be syntax: after FROM/JOIN/UPDATE/INTO, + * or qualified by a dot. Accepting AS or "(" quoted the type in CAST(x AS DATE) and the field in + * EXTRACT(MONTH FROM d), both of which are valid SQL that quoting breaks. + */ + private val NAME_POSITION = Regex("""(?:\bfrom|\bjoin|\bupdate|\binto|\.)\s*$""", RegexOption.IGNORE_CASE) + + /** The first argument of these is a keyword, not a name: EXTRACT(MONTH FROM d), TRIM(BOTH x FROM s). */ + private val KEYWORD_ARGUMENT = + Regex("""\b(?:extract|trim|position|overlay|substring)\s*\(\s*$""", RegexOption.IGNORE_CASE) + + /** + * Quotes every table and column the engine would not read back as itself. The schema text already + * shows these names quoted and models still drop the quotes, so the query is normalised before it + * runs rather than left to fail. Returns null when nothing needed quoting. + */ + fun quoteCatalogIdentifiers( + sql: String, + names: List, + quoteChar: Char, + tableNames: List = names, + ): String? { + val tables = tableNames.map { it.lowercase() }.toSet() + val byLower = HashMap() + for (name in names) { + val lower = name.lowercase() + if (byLower.containsKey(lower) && byLower[lower] != name) byLower[lower] = "" else byLower.putIfAbsent(lower, name) + } + if (byLower.isEmpty()) return null + + var changed = false + fun fixCode(code: String, chunkStart: Int): String = BARE_IDENTIFIER.replace(code) { m -> + val token = m.groupValues[1] + val tail = m.groupValues[2] + val canonical = byLower[token.lowercase()] + val before = code.substring(0, m.range.first) + // TIMESTAMP '2024-01-01' is one typed literal; the literal is its own segment, so this + // reads the statement rather than the chunk. + val typedLiteral = sql.drop(chunkStart + m.range.first + token.length).trimStart().startsWith("'") + // Rewriting a keyword blindly turns ORDER BY into "order" BY, so one must announce itself. + val keywordOutOfPlace = token.lowercase() in ANY_RESERVED && + !NAME_POSITION.containsMatchIn(before) + // A token before a dot qualifies what follows; quoting a schema name breaks a working query. + val qualifierNotATable = tail.trimStart().startsWith(".") && token.lowercase() !in tables + if (tail.trimStart().startsWith("(") || canonical.isNullOrEmpty() || keywordOutOfPlace || + KEYWORD_ARGUMENT.containsMatchIn(before) || qualifierNotATable || typedLiteral + ) { + m.value + } else { + changed = true + "${quoted(canonical, quoteChar)}$tail" + } + } + + val doubleQuoteIsLiteral = quoteChar != '"' + val backslashEscapes = quoteChar == '`' + val out = StringBuilder() + var start = 0 + var i = 0 + while (i < sql.length) { + // An already-quoted identifier is opaque: re-quoting it would double the quote characters. + if (sql[i] == quoteChar) { + val closeChar = if (quoteChar == '[') ']' else quoteChar + val close = sql.indexOf(closeChar, i + 1) + val end = if (close == -1) sql.length else close + 1 + out.append(fixCode(sql.substring(start, i), start)).append(sql, i, end) + start = end + i = end + continue + } + val end = skipTo(sql, i, doubleQuoteIsLiteral, backslashEscapes) + if (end >= 0) { + out.append(fixCode(sql.substring(start, i), start)).append(sql, i, end) + start = end + i = end + } else i++ + } + out.append(fixCode(sql.substring(start), start)) + return if (changed) out.toString() else null + } + + /** + * True when a text value opens and never closes, which is what an unescaped apostrophe looks like: + * 'O'Brien' reads as the value 'O', then Brien, then a literal running to the end of the statement. + * The parser only reports "could not parse", so naming the real cause is what makes the repair land. + */ + fun hasUnterminatedLiteral(sql: String, backslashEscapes: Boolean = false): Boolean { + var open = false + var i = 0 + while (i < sql.length) { + val ch = sql[i] + if (open) { + when { + backslashEscapes && ch == '\\' -> i += 2 + ch == '\'' && i + 1 < sql.length && sql[i + 1] == '\'' -> i += 2 + ch == '\'' -> { open = false; i++ } + else -> i++ + } + continue + } + // A dollar-quoted body needs no escaping, so an apostrophe inside it is not a defect. + if (ch == '$') { + val dollar = skipTo(sql, i, false) + if (dollar > i) { i = dollar; continue } + } + when { + ch == '-' && i + 1 < sql.length && sql[i + 1] == '-' -> { + val nl = sql.indexOf('\n', i) + i = if (nl == -1) sql.length else nl + } + ch == '/' && i + 1 < sql.length && sql[i + 1] == '*' -> { + val close = sql.indexOf("*/", i + 2) + i = if (close == -1) sql.length else close + 2 + } + ch == '\'' -> { open = true; i++ } + else -> i++ + } + } + return open + } +} diff --git a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/engine/Prompts.kt b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/engine/Prompts.kt index 8ec304e..0dde33f 100644 --- a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/engine/Prompts.kt +++ b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/engine/Prompts.kt @@ -46,9 +46,30 @@ object Prompts { fewShots: List = emptyList(), context: List = emptyList(), rerunPrevious: Boolean = false, + /** Named so a question about system catalogs does not invent a placeholder database name. */ + database: String? = null, + schemas: List = emptyList(), + /** A correct catalog query for this engine, offered when the question is about structure. */ + catalogHint: String? = null, ): String { val parts = mutableListOf("", schemaText, "") + // Without these, a question about information_schema gets a guessed name like 'your_database_name'. + val where = mutableListOf() + if (!database.isNullOrBlank()) where += "database/catalog is \"$database\"" + if (schemas.isNotEmpty()) where += "schema is \"${schemas.first()}\"" + if (where.isNotEmpty()) { + parts += "" + parts += "You are connected to: the ${where.joinToString(", the ")}. Use these exact names when a " + + "query filters on system catalogs such as information_schema; never write a placeholder." + } + + // System-catalog column names are not in the schema block, so a structure question otherwise guesses them. + if (!catalogHint.isNullOrBlank()) { + parts += "" + parts += "This question is about the database's structure. Build on this correct query for this engine: $catalogHint" + } + if (glossary.isNotEmpty()) { parts += "" parts += "Business glossary (use these definitions when the question uses these terms):" diff --git a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/engine/Semantics.kt b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/engine/Semantics.kt index 1bad6a6..d5e9924 100644 --- a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/engine/Semantics.kt +++ b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/engine/Semantics.kt @@ -120,4 +120,43 @@ object Semantics { } return null } + + /** + * An aggregate nested inside another aggregate, like AVG(x + SUM(y)). Every engine rejects it, so + * catching it before execution turns a database error into a repair. Returns the outer function name. + */ + fun nestedAggregate(sql: String): String? { + val statement = try { + CCJSqlParserUtil.parse(sql) + } catch (e: Exception) { + return null // the guard already fails closed on unparsable SQL + } + val select = statement as? Select ?: return null + for (body in plainSelects(select)) { + // Every clause an aggregate can appear in, matching the TypeScript walk over the statement. + val clauses = (body.selectItems ?: emptyList()).map { it.expression } + + listOfNotNull(body.having, body.where) + + (body.orderByElements ?: emptyList()).map { it.expression } + + (body.groupBy?.groupByExpressionList?.toList() ?: emptyList()) + for (clause in clauses) { + val outer = findNested(clause, null) + if (outer != null) return outer + } + } + return null + } + + private fun findNested(node: Expression?, insideAggregate: String?): String? { + if (node == null) return null + // A subquery has its own scope, so an aggregate inside one is not nested in the outer call. + if (node is Select || node is ParenthesedSelect) return null + val isAgg = node is Function && isBareAggregate(node) + if (isAgg && insideAggregate != null) return insideAggregate + val within = if (isAgg) (node as Function).name?.uppercase() else insideAggregate + for (child in childrenOf(node)) { + val found = findNested(child, within) + if (found != null) return found + } + return null + } } diff --git a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/engine/SqlKeywords.kt b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/engine/SqlKeywords.kt new file mode 100644 index 0000000..4cae0af --- /dev/null +++ b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/engine/SqlKeywords.kt @@ -0,0 +1,66 @@ +package com.rahulmahadik.asksql.ide.engine + +/** + * Reserved words per engine, generated alongside packages/core/src/sql-keywords.ts so the plugin and + * the npm engine cannot drift. Regenerate both with: node tools/generate-sql-keywords.mjs + */ +object SqlKeywords { + + private val WORDS = ( + "abort|accessible|action|add|after|all|alter|always|analyse|analyze|and|any|array|as|asc|asensitive|a" + + "symmetric|attach|authorization|autoincrement|before|begin|between|bigint|binary|blob|both|by|call|ca" + + "scade|case|cast|change|char|character|check|cluster|collate|collation|column|commit|compress|concurr" + + "ently|condition|conflict|connect|constraint|continue|convert|create|cross|cube|cume_dist|current|cur" + + "rent_catalog|current_date|current_role|current_schema|current_time|current_timestamp|current_user|cu" + + "rsor|database|databases|date|day_hour|day_microsecond|day_minute|day_second|dec|decimal|declare|defa" + + "ult|deferrable|deferred|delayed|delete|dense_rank|desc|describe|detach|deterministic|distinct|distin" + + "ctrow|div|do|double|drop|dual|each|else|elseif|empty|enclosed|end|escape|escaped|except|exclude|excl" + + "usive|exists|exit|explain|fail|false|fetch|filter|first|first_value|float|float4|float8|following|fo" + + "r|force|foreign|freeze|from|full|fulltext|function|generated|get|glob|grant|group|grouping|groups|ha" + + "ving|high_priority|hour_microsecond|hour_minute|hour_second|identified|if|ignore|ilike|immediate|in|" + + "index|indexed|infile|initially|inner|inout|insensitive|insert|instead|int|int1|int2|int3|int4|int8|i" + + "nteger|intersect|interval|into|io_after_gtids|io_before_gtids|is|isnull|iterate|join|json_table|key|" + + "keys|kill|lag|lambda|last|last_value|lateral|lead|leading|leave|left|like|limit|linear|lines|load|lo" + + "caltime|localtimestamp|lock|long|longblob|longtext|loop|low_priority|match|materialized|maxvalue|med" + + "iumblob|mediumint|mediumtext|middleint|minus|minute_microsecond|minute_second|mod|mode|modifies|natu" + + "ral|no|no_write_to_binlog|nocompress|not|nothing|notnull|nowait|nth_value|ntile|null|nulls|number|nu" + + "meric|of|offset|on|only|optimize|optimizer_costs|option|optionally|or|order|others|out|outer|outfile" + + "|over|overlaps|partition|pctfree|percent_rank|pivot|pivot_longer|pivot_wider|placing|plan|pragma|pre" + + "ceding|precision|primary|prior|procedure|public|purge|qualify|query|raise|range|rank|raw|read|read_w" + + "rite|reads|real|recursive|references|regexp|reindex|release|rename|repeat|replace|require|resignal|r" + + "esource|restrict|return|returning|revoke|right|rlike|rollback|row|row_number|rows|savepoint|schema|s" + + "chemas|second_microsecond|select|sensitive|separator|session_user|set|share|show|signal|similar|size" + + "|smallint|some|spatial|specific|sql|sql_big_result|sql_calc_found_rows|sql_small_result|sqlexception" + + "|sqlstate|sqlwarning|ssl|start|starting|stored|straight_join|summarize|symmetric|synonym|system|syst" + + "em_user|table|tablesample|temp|temporary|terminated|then|ties|tinyblob|tinyint|tinytext|to|trailing|" + + "transaction|trigger|true|unbounded|undo|union|unique|unlock|unpivot|unsigned|update|usage|use|user|u" + + "sing|utc_date|utc_time|utc_timestamp|vacuum|values|varbinary|varchar|varchar2|varcharacter|variadic|" + + "varying|verbose|view|virtual|when|where|while|window|with|without|write|xor|year_month|zerofill" + ).split("|") + + /** One bit per word in WORDS, base64 encoded. */ + private val BY_ENGINE = mapOf( + "sqlite" to "/WZ66KhRpkwAV6XG3gxrqsHeDCgrBAfAAJhjDq4CFxz4RbURAQAAaKM1Iga8Aw==", + "postgres" to "IH8FxehExh8AQyREAgN6MAHFACgLUMcAAIgiHCaBEAAQAAWQkACAHGEyMICyAQ==", + "mysql" to "aubQf6/In/z++d4/c/Ou99+s9//9+f9/36046/cKWLO/3+5+Tf87mn177135PQ==", + "oracle" to "YGxACBoiAgBBUYQEGiAiMCEMBCwBAAIDIMKkigYEoEAAIQIQYwAECSExAjQkAQ==", + "duckdb" to "IH8BxKhAAgAAwyREAgMqIAFEACgAUgQAAIAgHAbwEAIQAAEQhADACGGyIICwAQ==", + ) + + private val cache = HashMap>() + + /** The engine's reserved words; an unknown engine falls back to the union, which is the safe side. */ + fun reservedWordsFor(engine: String): Set { + val key = engine.lowercase() + cache[key]?.let { return it } + val packed = BY_ENGINE[key] + val set = if (packed != null) { + val bytes = java.util.Base64.getDecoder().decode(packed) + WORDS.filterIndexed { i, _ -> (bytes[i shr 3].toInt() shr (i and 7)) and 1 == 1 }.toSet() + } else { + WORDS.toSet() + } + cache[key] = set + return set + } +} diff --git a/packages/jetbrains/src/test/kotlin/com/rahulmahadik/asksql/ide/engine/IdentifierCaseTest.kt b/packages/jetbrains/src/test/kotlin/com/rahulmahadik/asksql/ide/engine/IdentifierCaseTest.kt new file mode 100644 index 0000000..0a5c8bb --- /dev/null +++ b/packages/jetbrains/src/test/kotlin/com/rahulmahadik/asksql/ide/engine/IdentifierCaseTest.kt @@ -0,0 +1,325 @@ +package com.rahulmahadik.asksql.ide.engine + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Mirrors packages/core/test/identifier-case.test.ts; the two must agree. */ +class IdentifierCaseTest { + + private val tables = listOf("Customers", "OrderItems") + + private fun fix(sql: String, quote: Char = '`') = IdentifierCase.correctTableCase(sql, tables, quote) + + @Test fun `corrects a lower-cased table name`() { + assertEquals("SELECT * FROM `Customers`", fix("SELECT * FROM customers")) + } + + @Test fun `corrects an upper-cased table name after JOIN`() { + assertEquals( + "SELECT * FROM Customers c JOIN `OrderItems` o ON c.id = o.id", + fix("SELECT * FROM Customers c JOIN ORDERITEMS o ON c.id = o.id"), + ) + } + + @Test fun `leaves an alias after the table alone`() { + assertEquals("SELECT * FROM `OrderItems` oi", fix("SELECT * FROM orderitems oi")) + } + + @Test fun `returns null when every name already matches`() { + assertNull(fix("SELECT * FROM Customers")) + } + + @Test fun `quotes with the dialect character`() { + assertEquals("""SELECT * FROM "Customers"""", fix("SELECT * FROM customers", '"')) + } + + @Test fun `corrects a name that was already quoted in the wrong case`() { + assertEquals("SELECT * FROM `Customers`", fix("SELECT * FROM `customers`")) + } + + @Test fun `keeps a schema prefix and corrects only the table`() { + assertEquals("SELECT * FROM shop.`OrderItems`", fix("SELECT * FROM shop.orderitems")) + } + + /** A column sharing a table's name must not be rewritten: it is not in table position. */ + @Test fun `leaves a same-named column alone`() { + assertNull(fix("SELECT customers FROM Customers")) + } + + /** Rewriting inside a literal would change the query's meaning, not just its spelling. */ + @Test fun `leaves a string literal alone`() { + assertNull(fix("SELECT * FROM Customers WHERE note = 'from customers'")) + } + + @Test fun `leaves a comment alone`() { + assertNull(fix("SELECT * FROM Customers -- from customers")) + } + + /** Two tables differing only by case have no single right answer. */ + @Test fun `leaves an ambiguous fold untouched`() { + assertNull(IdentifierCase.correctTableCase("SELECT * FROM orders", listOf("Orders", "ORDERS"), '`')) + } + + @Test fun `corrects an UPDATE target`() { + assertEquals("UPDATE `Customers` SET x = 1", fix("UPDATE customers SET x = 1")) + } + + @Test fun `leaves an unknown table alone`() { + assertNull(fix("SELECT * FROM invoices")) + } + + @Test fun `recognises every engine's unknown-table wording`() { + for (message in listOf( + "Table 'asksql_test.customers' doesn't exist", + """relation "customers" does not exist""", + "no such table: customers", + "ORA-00942: table or view does not exist", + "Invalid object name customers.", + )) { + assertTrue(message, IdentifierCase.looksLikeUnknownTable(message)) + } + assertFalse(IdentifierCase.looksLikeUnknownTable("Unknown column x in field list")) + } + + /** Postgres folds an unquoted name to lower case, so a mixed-case table needs quoting even when spelled right. */ + @Test fun `quotes a correctly spelled mixed-case table on Postgres`() { + assertEquals( + """SELECT * FROM "Customers"""", + IdentifierCase.correctTableCase("SELECT * FROM Customers", tables, '"', IdentifierCase.Folding.LOWER), + ) + } + + @Test fun `leaves a name the fold already resolves alone on Postgres`() { + assertNull(IdentifierCase.correctTableCase("SELECT * FROM Orders", listOf("orders"), '"', IdentifierCase.Folding.LOWER)) + } + + @Test fun `leaves an already quoted mixed-case name alone on Postgres`() { + assertNull(IdentifierCase.correctTableCase("""SELECT * FROM "Customers"""", tables, '"', IdentifierCase.Folding.LOWER)) + } + + @Test fun `quotes a mixed-case table on Oracle, which folds upper`() { + assertEquals( + """SELECT * FROM "MixedCase"""", + IdentifierCase.correctTableCase("SELECT * FROM MixedCase", listOf("MixedCase"), '"', IdentifierCase.Folding.UPPER), + ) + } + + @Test fun `leaves an upper-case Oracle table alone`() { + assertNull(IdentifierCase.correctTableCase("SELECT * FROM employees", listOf("EMPLOYEES"), '"', IdentifierCase.Folding.UPPER)) + } + + private val quotable = listOf("Customers", "OrderItems", "FirstName", "Country", "CustomerId") + private fun q(sql: String) = IdentifierCase.quoteCatalogIdentifiers(sql, quotable, '"') + + @Test fun `quotes both the table and the columns`() { + assertEquals( + """SELECT "FirstName" FROM "Customers" WHERE "Country" = 'UK'""", + q("""SELECT FirstName FROM Customers WHERE Country = 'UK'"""), + ) + } + + @Test fun `quotes a qualified column`() { + assertEquals("""SELECT c."CustomerId" FROM "Customers" c""", q("SELECT c.CustomerId FROM Customers c")) + } + + /** Doubling the quotes would make the identifier unreadable. */ + @Test fun `leaves an already quoted identifier alone when quoting`() { + assertNull(q("""SELECT "FirstName" FROM "Customers"""")) + } + + /** A reserved word used as a function must not become an identifier. */ + @Test fun `leaves a function call alone`() { + assertEquals( + """SELECT COUNT(*) FROM "Customers"""", + IdentifierCase.quoteCatalogIdentifiers("SELECT COUNT(*) FROM Customers", listOf("Customers", "count"), '"'), + ) + } + + @Test fun `leaves a string literal alone when quoting`() { + assertNull(q("""SELECT 1 WHERE x = 'FirstName'""")) + } + + /** A table called "order" once turned ORDER BY into "order" BY, which the guard then rejected. */ + @Test fun `does not rewrite a keyword that is not naming the table`() { + assertEquals( + """SELECT x FROM "Customers" ORDER BY x DESC""", + IdentifierCase.quoteCatalogIdentifiers("SELECT x FROM Customers ORDER BY x DESC", listOf("Customers", "order"), '"'), + ) + } + + @Test fun `still quotes GROUP BY and other keyword-adjacent columns`() { + assertEquals( + """SELECT "Country" FROM t GROUP BY "Country"""", + IdentifierCase.quoteCatalogIdentifiers("SELECT Country FROM t GROUP BY Country", listOf("Country"), '"'), + ) + } + + /** A table called Nulls broke the parser: NULLS is a keyword, so the bare name would not parse. */ + @Test fun `quotes a table named like a parser keyword`() { + assertEquals( + """SELECT "Val" FROM "Nulls"""", + IdentifierCase.quoteCatalogIdentifiers("SELECT Val FROM Nulls", listOf("Nulls", "Val"), '"'), + ) + } + + @Test fun `quotes a table whose name is a reserved word`() { + assertEquals("""SELECT * FROM "order"""", IdentifierCase.quoteCatalogIdentifiers("SELECT * FROM order", listOf("order"), '"')) + } + + @Test fun `returns null when nothing needs quoting`() { + assertNull(IdentifierCase.quoteCatalogIdentifiers("SELECT id FROM orders", emptyList(), '"')) + } + + private val syntaxNames = listOf("Date", "Status", "Orders", "Month", "Amount", "order") + private fun qs(sql: String) = IdentifierCase.quoteCatalogIdentifiers(sql, syntaxNames, '"') + + /** Quoting the type turns CAST(x AS DATE) into a reference to a column that does not exist. */ + @Test fun `leaves the type in a CAST alone`() { + assertNull(qs("SELECT CAST(x AS Date) FROM t")) + } + + /** EXTRACT's first argument is a field keyword; the source after FROM is a real column. */ + @Test fun `quotes the source of an EXTRACT but not the field`() { + assertEquals("""SELECT EXTRACT(month FROM "Date") FROM t""", qs("SELECT EXTRACT(month FROM Date) FROM t")) + } + + @Test fun `leaves the leading keyword of a TRIM alone`() { + assertEquals("""SELECT TRIM(both 'x' FROM "Status") FROM t""", qs("SELECT TRIM(both 'x' FROM Status) FROM t")) + } + + @Test fun `still quotes a reserved word in table position`() { + assertEquals("""SELECT * FROM "order"""", qs("SELECT * FROM order")) + } + + @Test fun `still quotes a reserved word qualified by a dot`() { + assertEquals("""SELECT t."order" FROM t""", qs("SELECT t.order FROM t")) + } + + @Test fun `still quotes an ordinary column inside a function call`() { + assertEquals("""SELECT COUNT("Amount") FROM "Orders"""", qs("SELECT COUNT(Amount) FROM Orders")) + } + + /** A doubled quote is SQL's escaped apostrophe; treating it as the close rewrote text inside the value. */ + @Test fun `does not rewrite identifiers inside a literal containing an escaped quote`() { + assertEquals( + """SELECT * FROM "Orders" WHERE note = 'it''s about Status'""", + IdentifierCase.quoteCatalogIdentifiers( + "SELECT * FROM Orders WHERE note = 'it''s about Status'", listOf("Orders"), '"', + ), + ) + } + + /** 'O'Brien' is what a model writes when it forgets to double the apostrophe. */ + @Test fun `spots an unescaped apostrophe`() { + assertTrue(IdentifierCase.hasUnterminatedLiteral("SELECT Note FROM Person WHERE Name = 'O'Brien'")) + } + + @Test fun `accepts a correctly doubled apostrophe`() { + assertFalse(IdentifierCase.hasUnterminatedLiteral("SELECT Note FROM Person WHERE Name = 'O''Brien'")) + } + + @Test fun `accepts ordinary statements`() { + for (sql in listOf( + "SELECT * FROM t WHERE a = 'x' AND b = 'y'", + "SELECT * FROM t", + "SELECT * FROM t -- it's fine", + "SELECT * FROM t /* it's fine */", + )) { + assertFalse(sql, IdentifierCase.hasUnterminatedLiteral(sql)) + } + } + + @Test fun `spots a value that runs off the end`() { + assertTrue(IdentifierCase.hasUnterminatedLiteral("SELECT * FROM t WHERE a = 'oops")) + } + + private val litNames = listOf("Users", "Notes", "Sales") + + /** Postgres and DuckDB dollar-quote bodies, which may contain anything at all. */ + @Test fun `leaves a dollar-quoted body alone`() { + val d = "\u0024\u0024" // a dollar-quote delimiter, written as escapes so Kotlin sees no template + assertEquals( + "SELECT * FROM \"Users\" WHERE \"Notes\" = ${d}from users now$d", + IdentifierCase.quoteCatalogIdentifiers("SELECT * FROM Users WHERE Notes = ${d}from users now$d", litNames, '"'), + ) + } + + /** Quoting a schema qualifier turns a working query into "schema does not exist". */ + @Test fun `does not quote a qualifier that is not a table`() { + assertNull(IdentifierCase.quoteCatalogIdentifiers("SELECT * FROM sales.orders", listOf("Sales"), '"', emptyList())) + } + + @Test fun `still quotes a qualifier that is a real table`() { + assertEquals( + """SELECT "Customers"."FirstName" FROM "Customers"""", + IdentifierCase.quoteCatalogIdentifiers( + "SELECT Customers.FirstName FROM Customers", listOf("Customers", "FirstName"), '"', listOf("Customers"), + ), + ) + } + + /** In prod.sales.orders the table is orders; sales is a qualifier. */ + @Test fun `does not recase the middle of a three-part name`() { + assertNull(IdentifierCase.correctTableCase("SELECT * FROM prod.sales.orders", listOf("Sales"), '"', IdentifierCase.Folding.LOWER)) + } + + private val BS = "\u005c" // a single backslash + + /** Backslash escapes a quote in MySQL only; the other engines read it literally. */ + @Test fun `treats a backslash as an escape only for the backtick dialect`() { + val sql = "SELECT * FROM t WHERE n = 'O${BS}'Brien'" + assertFalse(IdentifierCase.hasUnterminatedLiteral(sql, true)) + assertTrue(IdentifierCase.hasUnterminatedLiteral(sql)) + } + + /** A dollar-quoted body needs no escaping, so an apostrophe inside it is not a defect. */ + @Test fun `does not flag an apostrophe inside a dollar-quoted body`() { + val d = "\u0024\u0024" + assertFalse(IdentifierCase.hasUnterminatedLiteral("SELECT * FROM t WHERE n = ${d}don't$d")) + } + + /** TIMESTAMP '2024-01-01' is one typed literal; quoting the word makes it a missing column. */ + @Test fun `leaves a typed literal alone`() { + assertNull( + IdentifierCase.quoteCatalogIdentifiers( + "SELECT * FROM t WHERE created > TIMESTAMP '2024-01-01'", listOf("Timestamp"), '"', + ), + ) + } + + @Test fun `still quotes the same word used as a real column`() { + assertEquals( + "SELECT \"Timestamp\" FROM t", + IdentifierCase.quoteCatalogIdentifiers("SELECT Timestamp FROM t", listOf("Timestamp"), '"'), + ) + } + + private val boundaryNames = listOf("Customers", "FirstName", "Country", "City", "Timestamp") + + /** + * Literals split the statement into segments, and the typed-literal check reads the whole + * statement rather than one segment. If it read the segment, every name sitting at a segment + * boundary would silently stop being quoted. + */ + @Test fun `quotes names before, between and after several literals`() { + assertEquals( + "SELECT \"FirstName\" FROM \"Customers\" WHERE \"Country\" = 'UK' AND \"City\" = 'York'", + IdentifierCase.quoteCatalogIdentifiers( + "SELECT FirstName FROM Customers WHERE Country = 'UK' AND City = 'York'", boundaryNames, '"', + ), + ) + } + + /** The one word that must not be quoted is the one a literal directly follows. */ + @Test fun `skips only the typed literal, quoting every other name`() { + assertEquals( + "SELECT \"FirstName\" FROM \"Customers\" WHERE created > TIMESTAMP '2024-01-01'", + IdentifierCase.quoteCatalogIdentifiers( + "SELECT FirstName FROM Customers WHERE created > TIMESTAMP '2024-01-01'", boundaryNames, '"', + ), + ) + } +} diff --git a/packages/jetbrains/src/test/kotlin/com/rahulmahadik/asksql/ide/engine/NestedAggregateTest.kt b/packages/jetbrains/src/test/kotlin/com/rahulmahadik/asksql/ide/engine/NestedAggregateTest.kt new file mode 100644 index 0000000..2f5c288 --- /dev/null +++ b/packages/jetbrains/src/test/kotlin/com/rahulmahadik/asksql/ide/engine/NestedAggregateTest.kt @@ -0,0 +1,42 @@ +package com.rahulmahadik.asksql.ide.engine + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** Mirrors packages/core/test/nested-aggregate.test.ts; the two must agree. */ +class NestedAggregateTest { + + /** Reported by a real question: AVG over a SUM is rejected by every engine. */ + @Test fun `flags an aggregate inside another aggregate`() { + val sql = "SELECT c.country, AVG(o.freight + SUM(od.unit_price)) AS v FROM orders o " + + "JOIN customers c ON o.customer_id = c.customer_id JOIN order_details od ON o.order_id = od.order_id " + + "GROUP BY c.country" + assertEquals("AVG", Semantics.nestedAggregate(sql)) + } + + @Test fun `allows aggregates side by side`() { + assertNull(Semantics.nestedAggregate("SELECT SUM(a), AVG(b) FROM t")) + } + + @Test fun `allows an aggregate over an expression`() { + assertNull(Semantics.nestedAggregate("SELECT SUM(a * b + 1) FROM t")) + } + + @Test fun `returns null for unparsable sql rather than blocking`() { + assertNull(Semantics.nestedAggregate("NOT SQL AT ALL")) + } + + /** A subquery has its own scope, so its aggregate is not nested in the outer call. */ + @Test fun `allows an aggregate inside a subquery argument`() { + assertNull(Semantics.nestedAggregate("SELECT SUM((SELECT COUNT(*) FROM u WHERE u.id = t.id)) FROM t")) + } + + @Test fun `flags nesting in HAVING`() { + assertEquals("SUM", Semantics.nestedAggregate("SELECT a FROM t GROUP BY a HAVING SUM(COUNT(b)) > 1")) + } + + @Test fun `flags nesting in ORDER BY`() { + assertEquals("AVG", Semantics.nestedAggregate("SELECT a FROM t GROUP BY a ORDER BY AVG(SUM(b))")) + } +} diff --git a/packages/jetbrains/src/test/kotlin/com/rahulmahadik/asksql/ide/engine/PromptIdentityTest.kt b/packages/jetbrains/src/test/kotlin/com/rahulmahadik/asksql/ide/engine/PromptIdentityTest.kt new file mode 100644 index 0000000..5528275 --- /dev/null +++ b/packages/jetbrains/src/test/kotlin/com/rahulmahadik/asksql/ide/engine/PromptIdentityTest.kt @@ -0,0 +1,39 @@ +package com.rahulmahadik.asksql.ide.engine + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Mirrors the core prompt test: a missing database name became 'your_database_name' in real queries. */ +class PromptIdentityTest { + + @Test fun `names the database and schema so system-catalog filters are real`() { + val text = Prompts.buildSqlUser( + question = "what views exist?", schemaText = "TABLE film", + database = "sakila", schemas = listOf("public"), + ) + + assertTrue(text, text.contains("\"sakila\"")) + assertTrue(text, text.contains("\"public\"")) + assertTrue(text, text.contains("never write a placeholder")) + } + + @Test fun `says nothing when the connection does not report a database`() { + val text = Prompts.buildSqlUser(question = "q", schemaText = "s") + assertFalse(text, text.contains("You are connected to")) + } + + /** System-catalog columns are not in the schema block, so the model used to guess them. */ + @Test fun `offers a correct catalog query for a structure question`() { + val text = Prompts.buildSqlUser( + question = "what tables exist?", schemaText = "TABLE film", + catalogHint = "SELECT name FROM sqlite_master", + ) + + assertTrue(text, text.contains("SELECT name FROM sqlite_master")) + } + + @Test fun `offers no catalog query for an ordinary data question`() { + assertFalse(Prompts.buildSqlUser(question = "how many films?", schemaText = "s").contains("structure")) + } +} diff --git a/packages/jetbrains/src/test/kotlin/com/rahulmahadik/asksql/ide/engine/SetOperationColumnTest.kt b/packages/jetbrains/src/test/kotlin/com/rahulmahadik/asksql/ide/engine/SetOperationColumnTest.kt new file mode 100644 index 0000000..39854d4 --- /dev/null +++ b/packages/jetbrains/src/test/kotlin/com/rahulmahadik/asksql/ide/engine/SetOperationColumnTest.kt @@ -0,0 +1,40 @@ +package com.rahulmahadik.asksql.ide.engine + +import com.rahulmahadik.asksql.ide.model.ColumnInfo +import com.rahulmahadik.asksql.ide.model.EngineKind +import com.rahulmahadik.asksql.ide.model.SchemaCatalog +import com.rahulmahadik.asksql.ide.model.TableInfo +import com.rahulmahadik.asksql.ide.model.TableKind +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +/** Mirrors the core test: per-table row counts were blocked outright by the column floor. */ +class SetOperationColumnTest { + + private fun table(name: String, column: String) = TableInfo( + name = name, kind = TableKind.TABLE, + columns = listOf(ColumnInfo(name = column, dbType = "int", nullable = false)), + ) + + private val catalog = SchemaCatalog( + engine = EngineKind.SQLITE, + tables = listOf(table("Album", "AlbumId"), table("Artist", "ArtistId")), + ) + + @Test fun `does not flag a UNION ALL of per-table counts`() { + val sql = "SELECT 'Album' AS TableName, COUNT(*) AS RowCount FROM Album " + + "UNION ALL SELECT 'Artist' AS TableName, COUNT(*) AS RowCount FROM Artist" + + assertNull(HallucinationChecks.firstUnknownColumn(sql, catalog)) + } + + @Test fun `still flags a hallucinated column on a plain select`() { + assertNotNull(HallucinationChecks.firstUnknownColumn("SELECT Nope FROM Album", catalog)) + } + + /** A value containing "except" once disabled the floor entirely for an ordinary query. */ + @Test fun `still flags a hallucinated column when a literal contains a set-operation word`() { + assertNotNull(HallucinationChecks.firstUnknownColumn("SELECT Nope FROM Album WHERE AlbumId = 'except this'", catalog)) + } +} diff --git a/packages/jetbrains/src/test/kotlin/com/rahulmahadik/asksql/ide/engine/SqlKeywordsTest.kt b/packages/jetbrains/src/test/kotlin/com/rahulmahadik/asksql/ide/engine/SqlKeywordsTest.kt new file mode 100644 index 0000000..86233d5 --- /dev/null +++ b/packages/jetbrains/src/test/kotlin/com/rahulmahadik/asksql/ide/engine/SqlKeywordsTest.kt @@ -0,0 +1,36 @@ +package com.rahulmahadik.asksql.ide.engine + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Mirrors packages/core/test/sql-keywords.test.ts; both are generated from the same source. */ +class SqlKeywordsTest { + + @Test fun `gives each engine its own list`() { + assertTrue(SqlKeywords.reservedWordsFor("mysql").size > SqlKeywords.reservedWordsFor("postgres").size) + } + + @Test fun `every engine reserves select`() { + for (engine in listOf("postgres", "mysql", "oracle", "sqlite", "duckdb")) { + assertTrue(engine, SqlKeywords.reservedWordsFor(engine).contains("select")) + } + } + + /** MySQL reserves it, Postgres does not: proof the lists are not one shared set. */ + @Test fun `separates a word that only some engines reserve`() { + assertTrue(SqlKeywords.reservedWordsFor("mysql").contains("rank")) + assertFalse(SqlKeywords.reservedWordsFor("postgres").contains("rank")) + } + + /** An unknown engine gets the union, so a name is over-quoted rather than left broken. */ + @Test fun `falls back to the union for an unknown engine`() { + assertTrue(SqlKeywords.reservedWordsFor("not-an-engine").size > SqlKeywords.reservedWordsFor("mysql").size) + } + + @Test fun `matches the core list sizes`() { + assertEquals(101, SqlKeywords.reservedWordsFor("postgres").size) + assertEquals(262, SqlKeywords.reservedWordsFor("mysql").size) + } +} From 9a8a82b2606c628a3af98bb3dc581fe5f324ca60 Mon Sep 17 00:00:00 2001 From: rahulmahadik Date: Fri, 14 Aug 2026 01:11:49 +0800 Subject: [PATCH 3/4] Version JetBrains 0.5.2 and VS Code 0.7.1 --- packages/jetbrains/CHANGELOG.md | 22 +++++++++++++++++++++- packages/jetbrains/gradle.properties | 2 +- packages/vscode/CHANGELOG.md | 23 +++++++++++++++++++++++ packages/vscode/package.json | 2 +- 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/packages/jetbrains/CHANGELOG.md b/packages/jetbrains/CHANGELOG.md index 5ab214d..fa4d318 100644 --- a/packages/jetbrains/CHANGELOG.md +++ b/packages/jetbrains/CHANGELOG.md @@ -3,7 +3,27 @@ All notable changes to the AskSQL JetBrains plugin are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). -## [Unreleased] +## [0.5.2] - 2026-08-14 + +### Fixed +- A mixed-case Postgres schema failed every query. An unquoted name folds to lower case and resolves + to nothing; Oracle folds the other way, and MySQL on Linux compares table names case-sensitively. + Table and column names are now quoted from the catalog before the query is validated, and correct + names are left untouched. +- Reserved words now come from each database itself rather than one shared list that applied MySQL's + rules to Postgres and missed most of MySQL's own. +- Quoting knows where a word is syntax rather than a name, so `CAST(x AS DATE)` and + `EXTRACT(MONTH FROM d)` are left alone. +- A table named like a parser keyword, such as `order` or `Nulls`, could not be parsed in its bare + form, so the question failed after three attempts. +- An apostrophe inside a value, as in `'O'Brien'`, produced only "could not parse", so the model + returned the same statement until it ran out of attempts. It is now told to double the quote. +- Questions about structure no longer invent a database name. Without being told which database it + is connected to, the model wrote `table_schema = 'your_database_name'` and returned nothing, which + reads as an empty database rather than an error. +- `AVG(SUM(x))` is repaired instead of run. Nested aggregates are invalid in every engine. +- Per-table row counts work again. A `UNION ALL` across tables was blocked as a hallucinated column, + because each branch's columns were judged against every branch's tables. ## [0.5.1] - 2026-08-12 diff --git a/packages/jetbrains/gradle.properties b/packages/jetbrains/gradle.properties index 93a7ad8..68f9e15 100644 --- a/packages/jetbrains/gradle.properties +++ b/packages/jetbrains/gradle.properties @@ -4,7 +4,7 @@ pluginGroup = com.rahulmahadik.asksql pluginName = AskSQL -pluginVersion = 0.5.1 +pluginVersion = 0.5.2 # IntelliJ Platform target used to COMPILE and RUN the sandbox. Broad # compatibility is governed by pluginSinceBuild/pluginUntilBuild in diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index b08ad30..221f275 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -4,6 +4,29 @@ All notable changes to the AskSQL VS Code extension are documented here. The for [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.7.1] - 2026-08-14 + +### Fixed +- A mixed-case Postgres schema failed every query. An unquoted name folds to lower case and resolves + to nothing; Oracle folds the other way, and MySQL on Linux compares table names case-sensitively. + Table and column names are now quoted from the catalog before the query is validated, and correct + names are left untouched. +- Reserved words now come from each database itself rather than one shared list that applied MySQL's + rules to Postgres and missed most of MySQL's own. +- Quoting knows where a word is syntax rather than a name, so `CAST(x AS DATE)` and + `EXTRACT(MONTH FROM d)` are left alone. +- A table named like a parser keyword, such as `order` or `Nulls`, could not be parsed in its bare + form, so the question failed after three attempts. +- An apostrophe inside a value, as in `'O'Brien'`, produced only "could not parse", so the model + returned the same statement until it ran out of attempts. It is now told to double the quote. +- Questions about structure no longer invent a database name. Without being told which database it + is connected to, the model wrote `table_schema = 'your_database_name'` and returned nothing, which + reads as an empty database rather than an error. +- `AVG(SUM(x))` is repaired instead of run. Nested aggregates are invalid in every engine. +- Per-table row counts work again. A `UNION ALL` across tables was blocked as a hallucinated column, + because each branch's columns were judged against every branch's tables. +- Index columns are no longer quoted twice in the schema sent to the model. + ## [0.7.0] - 2026-08-09 ### Fixed diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 78b631b..ddd9bb1 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -3,7 +3,7 @@ "private": true, "displayName": "AskSQL", "description": "AI database chat: ask in plain language, review the query, get answers. Read-only by design, bring your own model.", - "version": "0.7.0", + "version": "0.7.1", "publisher": "RahulMahadik", "license": "Apache-2.0", "pricing": "Free", From 9fd840219beaf118722891b525e0643a38065567 Mon Sep 17 00:00:00 2001 From: rahulmahadik Date: Fri, 14 Aug 2026 01:11:49 +0800 Subject: [PATCH 4/4] Drop an unreferenced screenshot, and note why the extension has no test account The screenshot was in no README or doc, so its stale disclaimer was never shown. The certification note answers the Edge policy that asks for test credentials: AskSQL has no accounts to issue, so it explains that and gives a reviewer a free path in with a local model and any spreadsheet. --- docs/screenshots/web-05-delete-refused.png | Bin 90087 -> 0 bytes .../STORE-CERTIFICATION-NOTES.md | 50 ++++++++++++++++++ 2 files changed, 50 insertions(+) delete mode 100644 docs/screenshots/web-05-delete-refused.png create mode 100644 packages/browser-extension/STORE-CERTIFICATION-NOTES.md diff --git a/docs/screenshots/web-05-delete-refused.png b/docs/screenshots/web-05-delete-refused.png deleted file mode 100644 index edde23eea211c3cb044913f49e2270ed113cf8ca..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 90087 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 GpZ^2NL*;S+ diff --git a/packages/browser-extension/STORE-CERTIFICATION-NOTES.md b/packages/browser-extension/STORE-CERTIFICATION-NOTES.md new file mode 100644 index 0000000..9583452 --- /dev/null +++ b/packages/browser-extension/STORE-CERTIFICATION-NOTES.md @@ -0,0 +1,50 @@ +# Notes for Certification + +Paste the section below into **Submission Options > Notes for Certification** when resubmitting to the +Microsoft Edge Add-ons store. It answers policy 1.3.1 (Product is Testable), which the 08/13/2026 +review flagged. Product ID: 248cd48a-7dbe-4cfd-8ec0-df1e07231acd + +--- + +Product ID: 248cd48a-7dbe-4cfd-8ec0-df1e07231acd + +**Why no test account credentials are provided** + +AskSQL has no accounts, no sign-in, and no server of our own. Nothing is hosted by us, so there is no +credential we could issue. The extension stores its settings locally and talks only to two things the +user chooses: their own data files, and their own AI model provider. + +Because of that, testing needs no credentials from us. It needs a model provider and a data file, and +both can be supplied at no cost in a few minutes. + +**Fastest way to test, with no API key and no account (about 5 minutes)** + +1. Install Ollama from https://ollama.com (free, no account required) and run: + `ollama pull qwen2.5-coder:7b` +2. Start Ollama with `OLLAMA_ORIGINS=* ollama serve` so it serves on http://127.0.0.1:11434. + The variable matters: fetching the model list works without it, but asking a question fails with + 403, because Ollama rejects the extension's origin on POST requests. +3. Open the extension's Options page, choose provider **Ollama**, click **Fetch models**, pick the + model, and click **Test provider**. It should report success. +4. Add a connection: click **Add connection**, choose **Data files**, and select any CSV or Excel + file. Any small spreadsheet works; no database server is needed. +5. Open the side panel and ask a question about the file, for example "how many rows are there?" or + "show me the first 10 records". + +**Alternative, if you prefer a hosted provider** + +Any OpenAI, Anthropic or Groq API key works. Enter it in the Options page under the matching provider +and follow steps 3 to 5 above. We cannot include one of our keys in this submission, because the key +would be visible to anyone who reads the listing and would be billed to us. + +**What the extension sends where** + +Questions and database schema go only to the provider the user configures, over a connection they +control. Data files are read in the browser and never uploaded to us. The extension has no analytics +and no backend. Generated SQL is read-only and is checked before it runs, so a query cannot modify +the user's data. + +**If anything blocks the review** + +Please include the Product ID in any reply and we will respond quickly with whatever else is helpful, +including a recorded walkthrough if that is easier than running it locally.