Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .changeset/identifier-and-semantic-floors.md
Original file line number Diff line number Diff line change
@@ -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"""`.
Binary file removed docs/screenshots/web-05-delete-refused.png
Binary file not shown.
50 changes: 50 additions & 0 deletions packages/browser-extension/STORE-CERTIFICATION-NOTES.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 6 additions & 16 deletions packages/core/src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string> = 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}`;
Expand Down
Loading