From 3f56fb871d01bbe9dfba5da13e1cb333dc126406 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 22:26:32 +0000 Subject: [PATCH 1/3] playground: a sample schema, help for an undeclared table, and conversion notes The schema box starts with ORDERS, CUSTOMERS, PRODUCTS, ORDER_ITEMS and EVENTS (with a VARIANT payload and timestamps), and the fixture rows fill each, so common queries translate and run without editing the schema. When a statement reads a table the schema does not declare, the Translate stage says SQLQuack needs its columns and Snowflake types and offers to add a stub entry to fill in. The stub's placeholder type is not a type, so the statement stays refused until it is filled in; nothing is inferred. Each known divergence a translation relies on, which the policy allows, is shown as a note with the policy's reason, as convert names it on standard error. The engine reports them per statement, and any notes the report carries are shown the same way. --- website/public/playground/examples.js | 44 ++++++++++++++++- website/public/playground/playground.js | 63 +++++++++++++++++++++++++ website/public/styles.css | 27 +++++++++++ website/tests/playground.test.mjs | 33 +++++++++++++ website/wasm/src/lib.rs | 7 +++ 5 files changed, 173 insertions(+), 1 deletion(-) diff --git a/website/public/playground/examples.js b/website/public/playground/examples.js index df7c4444c..66c294d93 100644 --- a/website/public/playground/examples.js +++ b/website/public/playground/examples.js @@ -8,6 +8,32 @@ export const SCHEMA = `{ ["STATUS", "VARCHAR"], ["TOTAL", "NUMBER(12,2)"], ["ORDERED_AT", "TIMESTAMP_NTZ"] + ], + "CUSTOMERS": [ + ["CUSTOMER_ID", "NUMBER(38,0)"], + ["EMAIL", "VARCHAR"], + ["FIRST_NAME", "VARCHAR"], + ["COUNTRY", "VARCHAR"], + ["SIGNUP_DATE", "DATE"] + ], + "PRODUCTS": [ + ["PRODUCT_ID", "NUMBER(38,0)"], + ["NAME", "VARCHAR"], + ["CATEGORY", "VARCHAR"], + ["PRICE", "NUMBER(10,2)"] + ], + "ORDER_ITEMS": [ + ["ORDER_ID", "NUMBER(38,0)"], + ["PRODUCT_ID", "NUMBER(38,0)"], + ["QUANTITY", "NUMBER(38,0)"], + ["UNIT_PRICE", "NUMBER(10,2)"] + ], + "EVENTS": [ + ["EVENT_ID", "NUMBER(38,0)"], + ["CUSTOMER_ID", "NUMBER(38,0)"], + ["EVENT_TYPE", "VARCHAR"], + ["OCCURRED_AT", "TIMESTAMP_NTZ"], + ["PAYLOAD", "VARIANT"] ] }`; @@ -16,7 +42,23 @@ export const ROWS = `INSERT INTO "ORDERS" VALUES (2, 10, 'shipped', 80.00, TIMESTAMP '2026-10-03 14:00:00'), (3, 11, 'pending', 45.25, TIMESTAMP '2026-10-04 11:15:00'), (4, 12, 'shipped', 310.00, TIMESTAMP '2026-10-05 16:45:00'), - (5, 12, NULL, 12.99, TIMESTAMP '2026-10-06 08:05:00');`; + (5, 12, NULL, 12.99, TIMESTAMP '2026-10-06 08:05:00'); +INSERT INTO "CUSTOMERS" VALUES + (10, 'ada@example.com', 'Ada', 'GB', DATE '2026-01-15'), + (11, 'grace@example.com', 'Grace', 'US', DATE '2026-03-02'), + (12, 'linus@example.com', 'Linus', 'FI', DATE '2026-06-20'); +INSERT INTO "PRODUCTS" VALUES + (100, 'Duck mug', 'kitchen', 12.50), + (101, 'Rubber duck', 'toys', 4.99), + (102, 'Pond map', 'books', 30.00); +INSERT INTO "ORDER_ITEMS" VALUES + (1, 100, 2, 12.50), (1, 101, 1, 4.99), (2, 102, 2, 30.00), + (3, 101, 5, 4.99), (4, 102, 10, 30.00), (5, 101, 1, 4.99); +INSERT INTO "EVENTS" VALUES + (1000, 10, 'view', TIMESTAMP '2026-10-01 09:00:00', '{"page": "/mugs", "ms": 820}'), + (1001, 10, 'purchase', TIMESTAMP '2026-10-01 09:30:00', '{"order_id": 1, "items": [100, 101]}'), + (1002, 11, 'view', TIMESTAMP '2026-10-04 11:00:00', '{"page": "/ducks", "ms": 310}'), + (1003, 12, 'purchase', TIMESTAMP '2026-10-05 16:45:00', '{"order_id": 4, "items": [102]}');`; export const EXAMPLES = { valid: `SELECT customer_id, diff --git a/website/public/playground/playground.js b/website/public/playground/playground.js index c328cb7f8..46a164da1 100644 --- a/website/public/playground/playground.js +++ b/website/public/playground/playground.js @@ -193,6 +193,7 @@ async function run() { if (translate.status !== 'skipped') { // Translated SQL keeps its line layout and scrolls sideways; diagnostics still wrap. body = (translate.statements ?? []).map((s) => `
${escape(s.sql)}
`).join(''); + body = unknownRelationHints(translate.diagnostics) + body + conversionNotes(translate); body += (translate.diagnostics ?? []).map((d) => terminal(tone(rendered(d, chars)))).join(''); } const policy = translate.profile ? `policy: ${translate.profile}` : ''; @@ -214,6 +215,67 @@ async function run() { } else setStage('run', 'skipped', '', refused ? 'nothing was translated' : ''); } +// What the translation relies on that a reader should know: each known divergence the policy +// allows (as `sqlquack convert` prints "relies on known divergence …"), and any other notes. +function conversionNotes(translate) { + const notes = [...(translate.notes ?? [])].map((note) => escape(note)); + for (const statement of translate.statements ?? []) + for (const d of statement.divergences ?? []) { + const at = d.constructs.map((construct) => `${escape(construct)}`).join(', '); + notes.push(`Relies on known divergence ${escape(d.id)} at ${at}${d.reason ? `: ${escape(d.reason)}` : ''}`); + } + return [...new Set(notes)].map((note) => `

Note. ${note}

`).join(''); +} + +// A relation the schema does not declare: SQLQuack needs its columns and Snowflake types, and +// does not guess them. Each one gets a line saying so, with a button that adds a stub to fill in. +const UNKNOWN_RELATION = /^relation `(.+)` has no known columns$/; +const STUB_COLUMN = ['COLUMN_NAME', 'SNOWFLAKE_TYPE']; + +function declaredRelations() { + try { + return Object.keys(JSON.parse(schemaBox.value)); + } catch { + return []; + } +} + +function unknownRelationHints(diagnostics) { + const relations = new Set(); + for (const d of diagnostics ?? []) { + const match = d.code === 'SQ0004' && UNKNOWN_RELATION.exec(d.label ?? ''); + if (match) relations.add(match[1]); + } + const declared = declaredRelations(); + return [...relations] + .map((name) => { + const code = `${escape(name)}`; + return declared.includes(name) + ? `

SQLQuack needs each table's columns and Snowflake types. Fill in ${code}'s in the Schema box.

` + : `

SQLQuack needs each table's columns and Snowflake types. Add ${code} to the Schema box.` + + `

`; + }) + .join(''); +} + +// Adds `name` with one placeholder column to the end of the schema's JSON, keeping the rest of the +// text as typed, and selects the placeholder so typing replaces it. +function addRelation(name) { + const text = schemaBox.value; + const close = text.lastIndexOf('}'); + if (close < 0 || declaredRelations().includes(name)) return; + const before = text.slice(0, close).trimEnd(); + const column = `[${STUB_COLUMN.map((part) => JSON.stringify(part)).join(', ')}]`; + const entry = ` ${JSON.stringify(name)}: [\n ${column}\n ]`; + const separator = before.endsWith('{') ? '\n' : ',\n'; + schemaBox.value = `${before}${separator}${entry}\n${text.slice(close)}`; + schemaBox.closest('details').open = true; + const placeholder = `${before}${separator} ${JSON.stringify(name)}: [\n ["`.length; + schemaBox.focus(); + schemaBox.setSelectionRange(placeholder, placeholder + STUB_COLUMN[0].length); + schedule(); +} + // DuckDB-WASM, downloaded in the background once the page is idle (or on the first run). let duckdb = null; let duckdbLoad = null; @@ -329,6 +391,7 @@ const schedule = () => { document.addEventListener('click', (event) => { if (event.target.id === 'pg-run') execute(); + if (event.target.dataset.addRelation) addRelation(event.target.dataset.addRelation); }); sqlBox.addEventListener('input', () => { showCaption(null); diff --git a/website/public/styles.css b/website/public/styles.css index 2bd7b4d25..6a5d72408 100644 --- a/website/public/styles.css +++ b/website/public/styles.css @@ -1359,6 +1359,33 @@ footer a { display: none; } +.pg-hint code { + display: inline; +} + +.pg-conversion-note { + margin: 10px 0 0; + padding: 8px 10px; + border-left: 2px solid var(--primary); + background: var(--accent-soft); + color: var(--muted-foreground); + font-size: 13px; +} + +.pg-conversion-note code { + display: inline; +} + +.pg-hint { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + margin: 0 0 10px; + color: var(--foreground); + font-size: 13px; +} + .terminal .warn { color: var(--warning); } diff --git a/website/tests/playground.test.mjs b/website/tests/playground.test.mjs index e3886e8d8..80857f154 100644 --- a/website/tests/playground.test.mjs +++ b/website/tests/playground.test.mjs @@ -117,3 +117,36 @@ test('translated SQL is laid out one clause per line unless the compact setting assert.equal(compact, laidOut.replace(/\n */g, ' ')); assert.ok(!compact.includes('\n')); }); + +test('an undeclared relation is refused by name, and the stub the page adds stays refused until filled in', async () => { + const { SCHEMA: PAGE_SCHEMA } = await import('../public/playground/examples.js'); + // The page reads the relation's name from this label to offer "Add T to schema". + const unknown = JSON.parse(analyze('SELECT * FROM t', PAGE_SCHEMA)).translate; + assert.equal(unknown.status, 'fail'); + assert.deepEqual( + unknown.diagnostics.map((d) => [d.code, d.label]), + [['SQ0004', 'relation `T` has no known columns']], + ); + // The stub's placeholder type is not a Snowflake type: no column is assumed, so it still refuses. + const stub = JSON.stringify({ ...JSON.parse(PAGE_SCHEMA), T: [['COLUMN_NAME', 'SNOWFLAKE_TYPE']] }); + assert.deepEqual(JSON.parse(analyze('SELECT * FROM t', stub)).translate.diagnostics.map((d) => d.code), ['SQ0004']); + // Every relation of the sample schema without semi-structured columns translates as declared. + for (const relation of ['ORDERS', 'CUSTOMERS', 'PRODUCTS', 'ORDER_ITEMS']) { + assert.equal(JSON.parse(analyze(`SELECT * FROM ${relation.toLowerCase()}`, PAGE_SCHEMA)).translate.status, 'pass', relation); + } +}); + +test('each known divergence a translation relies on is reported with its constructs and the policy reason', () => { + const repository = readFileSync(new URL('../../sqlquack.toml', import.meta.url), 'utf8'); + const policy = repository.replace('constructs = ["fn:HASH"]\ndefault = "refuse"', 'constructs = ["fn:HASH"]\ndefault = "warn"'); + assert.notEqual(policy, repository); + const translate = (settings) => JSON.parse(analyze('SELECT HASH(order_id) AS h FROM orders', SCHEMA, JSON.stringify(settings))).translate; + const [statement] = translate({ policy }).statements; + assert.deepEqual( + statement.divergences.map((d) => [d.id, d.constructs]), + [['hash', ['fn:HASH']]], + ); + assert.match(statement.divergences[0].reason, /never Snowflake's value/); + // Without a divergence, none is reported. + assert.deepEqual(JSON.parse(analyze('SELECT order_id FROM orders', SCHEMA)).translate.statements[0].divergences, []); +}); diff --git a/website/wasm/src/lib.rs b/website/wasm/src/lib.rs index f6ee9c655..d115b6208 100644 --- a/website/wasm/src/lib.rs +++ b/website/wasm/src/lib.rs @@ -322,6 +322,13 @@ fn report(sql: &str, schema: &str, settings: &str) -> Value { "name": column.name, "type": column.data_type.to_string(), })).collect::>(), + // Each known divergence the policy allows and the conversion relies on, as + // `sqlquack convert` names it on standard error, with the policy's reason. + "divergences": done.provenance.divergences.iter().map(|(id, constructs)| json!({ + "id": id, + "constructs": constructs, + "reason": options.divergences.get(id).map(|allowed| allowed.reason.as_str()), + })).collect::>(), })), Err(refusal) => refusals.push(placed(refusal, statement, &lines)), } From e14187283946934a867ca2e35f5a1659b1ea5b8c Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 22:35:50 +0000 Subject: [PATCH 2/3] playground: show a run's raised error as Snowflake's, where it was raised A building block raises Snowflake's errors as tagged text, which DuckDB reports inside its own message, such as a failed cast of the tag. The engine now lists each statement's raise IDs with their source locations, and run_failure reads a DuckDB error back as the Snowflake error number, SQLSTATE and message. The Run stage shows that error and underlines the expression that raised it, instead of DuckDB's message. --- website/public/playground/playground.js | 29 ++++++++++++++++++++++++- website/public/playground/worker.js | 5 +++-- website/tests/playground.test.mjs | 16 +++++++++++++- website/wasm/src/lib.rs | 29 +++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 4 deletions(-) diff --git a/website/public/playground/playground.js b/website/public/playground/playground.js index 46a164da1..4253d9a21 100644 --- a/website/public/playground/playground.js +++ b/website/public/playground/playground.js @@ -131,6 +131,9 @@ function showCaption(example) { } let latest = null; +let latestRaises = {}; +// The editor's current text and underlines, so a run's error can add its own. +let shown = { chars: [], spans: [] }; let current = 0; async function run() { @@ -204,10 +207,12 @@ async function run() { setStage('translate', 'refused', body, policy, 'not translated'); } else setStage('translate', translate.status, body, policy); underline(chars, spans); + shown = { chars, spans }; const statements = translate.statements ?? []; if (translate.status === 'pass' && statements.length === 1) { latest = statements[0].sql; + latestRaises = statements[0].raises ?? {}; if (duckdb) execute(); else showDuckDBLoading(); } else if (translate.status === 'pass') { @@ -378,8 +383,30 @@ async function execute() { await conn.close(); } } catch (error) { + const message = String(error.message ?? error); + // An error a building block raised as Snowflake would: shown as Snowflake's, where it comes from. + const failure = JSON.parse((await engine('failure', { message })).result); if (ticket !== running) return; - setStage('run', 'fail', terminal(`DuckDB: ${escape(String(error.message ?? error))}`), duckdb ? `DuckDB ${duckdb.version}` : ''); + const version = duckdb ? `DuckDB ${duckdb.version}` : ''; + if (!failure.errno) { + setStage('run', 'fail', terminal(`DuckDB: ${escape(message)}`), version); + return; + } + const { chars, spans } = shown; + const raised = (latestRaises[failure.raise_id] ?? []).map((location) => { + const diagnostic = { code: String(failure.errno), message: failure.message, span: { start: location.start, end: location.end }, label: 'raised here' }; + return { kind: 'error', span: diagnostic.span, text: rendered(diagnostic, chars) }; + }); + if (raised.length) underline(chars, [...spans, ...raised]); + const head = `error ${escape(String(failure.errno))} (${escape(failure.sqlstate)}): ${escape(failure.message)}`; + setStage( + 'run', + 'fail', + terminal(raised[0]?.text ?? head) + + '

Snowflake raises this error for these rows, and so does the translated SQL in DuckDB.

', + version, + 'error', + ); } } diff --git a/website/public/playground/worker.js b/website/public/playground/worker.js index 2c9814284..86b8bda0f 100644 --- a/website/public/playground/worker.js +++ b/website/public/playground/worker.js @@ -16,8 +16,9 @@ self.onmessage = async ({ data }) => { ready.then(() => self.postMessage({ ready: true })); return; } - const { analyze, fixture_tables } = await ready; + const { analyze, fixture_tables, run_failure } = await ready; const started = performance.now(); - const result = data.op === 'fixture' ? fixture_tables(data.schema) : analyze(data.sql, data.schema, data.settings); + const result = + data.op === 'fixture' ? fixture_tables(data.schema) : data.op === 'failure' ? run_failure(data.message) : analyze(data.sql, data.schema, data.settings); self.postMessage({ id: data.id, result, elapsed: performance.now() - started }); }; diff --git a/website/tests/playground.test.mjs b/website/tests/playground.test.mjs index 80857f154..765bdc9ce 100644 --- a/website/tests/playground.test.mjs +++ b/website/tests/playground.test.mjs @@ -4,7 +4,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { test } from 'node:test'; -import { initSync, analyze } from '../wasm/pkg/sqlquack_wasm.js'; +import { initSync, analyze, run_failure } from '../wasm/pkg/sqlquack_wasm.js'; import { rendered, underlined } from '../public/playground/spans.js'; initSync({ module: readFileSync(new URL('../wasm/pkg/sqlquack_wasm_bg.wasm', import.meta.url)) }); @@ -150,3 +150,17 @@ test('each known divergence a translation relies on is reported with its constru // Without a divergence, none is reported. assert.deepEqual(JSON.parse(analyze('SELECT order_id FROM orders', SCHEMA)).translate.statements[0].divergences, []); }); + +test('a run error a building block raised reads back as the Snowflake error, at the expression that raised it', () => { + const sql = 'SELECT CAST(note AS NUMBER(10,2)) AS v FROM orders'; + const [statement] = JSON.parse(analyze(sql, SCHEMA)).translate.statements; + const tag = /sqlquack:100038#[0-9a-f]{12}: [^']*/.exec(statement.sql)[0]; + // As DuckDB reports a failed cast of the raised text. + const failure = JSON.parse(run_failure(`Conversion Error: Could not convert string '${tag}' to INT32\n\nLINE 1: ...`)); + assert.equal(failure.errno, 100038); + assert.equal(failure.message, 'Numeric value is not recognized'); + const [location] = statement.raises[failure.raise_id]; + assert.equal(Array.from(sql).slice(location.start, location.end).join(''), 'CAST(note AS NUMBER(10,2))'); + // DuckDB's own errors are not read as Snowflake's. + assert.deepEqual(JSON.parse(run_failure('Binder Error: Referenced column "x" not found')), {}); +}); diff --git a/website/wasm/src/lib.rs b/website/wasm/src/lib.rs index d115b6208..31b93d720 100644 --- a/website/wasm/src/lib.rs +++ b/website/wasm/src/lib.rs @@ -12,6 +12,9 @@ use sqlquack_core::classification::main::classify::classify; use sqlquack_core::diagnostics::constants::SESSION_PARAMETERS; use sqlquack_core::diagnostics::main::diagnose::diagnose; use sqlquack_core::diagnostics::main::unplaced_diagnostic::unplaced_diagnostic; +use sqlquack_core::failures::constants::{ERROR_TAG_PREFIX, TAG_END}; +use sqlquack_core::failures::main::duckdb_failure::duckdb_failure; +use sqlquack_core::failures::main::raise_id::raise_id; use sqlquack_core::parsing::main::node_location::node_location; use sqlquack_core::parsing::main::parse_source::parse_source; use sqlquack_core::parsing::models::{SourceDialect, SourceLines}; @@ -50,6 +53,30 @@ pub fn analyze(sql: &str, schema: &str, settings: Option) -> String { report(sql, schema, settings.as_deref().unwrap_or_default()).to_string() } +/// A DuckDB error from running converted SQL, read back as the Snowflake error a building block +/// raised, as JSON: `{"errno", "sqlstate", "raise_id", "message"}`, or `{}` when it is DuckDB's own. +#[wasm_bindgen] +pub fn run_failure(message: &str) -> String { + let Some(code) = duckdb_failure(message).and_then(|failure| failure.snowflake) else { + return json!({}).to_string(); + }; + // The tagged text, `sqlquack:#: `, ends at the quote DuckDB wraps it in. + let tagged = message + .split_once(ERROR_TAG_PREFIX) + .map_or("", |(before, after)| match before.ends_with('\'') { + true => after.split('\'').next().unwrap_or(after), + false => after.lines().next().unwrap_or(after), + }); + let text = tagged.split_once(TAG_END).map_or(tagged, |(_, text)| text); + json!({ + "errno": code.errno, + "sqlstate": code.sqlstate, + "raise_id": raise_id(message), + "message": text, + }) + .to_string() +} + /// `CREATE TABLE` for each relation in `schema`, in the DuckDB types converted SQL reads them as, /// as JSON: `{"tables": [...]}`, or `{"error": ...}`. #[wasm_bindgen] @@ -329,6 +356,8 @@ fn report(sql: &str, schema: &str, settings: &str) -> Value { "constructs": constructs, "reason": options.divergences.get(id).map(|allowed| allowed.reason.as_str()), })).collect::>(), + // Where each raise ID in the SQL comes from, so a run's error points at the source. + "raises": done.raises, })), Err(refusal) => refusals.push(placed(refusal, statement, &lines)), } From 8f8b12e3112242fee9e0327435e9e8fcf3ebc482 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 22:42:34 +0000 Subject: [PATCH 3/3] playground: name a relied-on assumption as an assumption An assumption gives a wrong value silently where it does not hold, so the note says "Relies on assumption", as convert does, instead of "known divergence". An id with a suffix, such as declared_offset:REL.COL, takes its reason from the divergence before the colon. The page no longer reads a notes list the engine does not report. --- website/public/playground/playground.js | 10 ++++++---- website/tests/playground.test.mjs | 6 ++++++ website/wasm/src/lib.rs | 20 ++++++++++++++------ 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/website/public/playground/playground.js b/website/public/playground/playground.js index 4253d9a21..54ff12f91 100644 --- a/website/public/playground/playground.js +++ b/website/public/playground/playground.js @@ -220,14 +220,16 @@ async function run() { } else setStage('run', 'skipped', '', refused ? 'nothing was translated' : ''); } -// What the translation relies on that a reader should know: each known divergence the policy -// allows (as `sqlquack convert` prints "relies on known divergence …"), and any other notes. +// What the translation relies on that a reader should know, as `sqlquack convert` prints it: +// each known divergence the policy allows, and each assumption, which gives a wrong value +// silently where it does not hold. function conversionNotes(translate) { - const notes = [...(translate.notes ?? [])].map((note) => escape(note)); + const notes = []; for (const statement of translate.statements ?? []) for (const d of statement.divergences ?? []) { const at = d.constructs.map((construct) => `${escape(construct)}`).join(', '); - notes.push(`Relies on known divergence ${escape(d.id)} at ${at}${d.reason ? `: ${escape(d.reason)}` : ''}`); + const kind = d.assumption ? 'assumption' : 'known divergence'; + notes.push(`Relies on ${kind} ${escape(d.id)} at ${at}${d.reason ? `: ${escape(d.reason)}` : ''}`); } return [...new Set(notes)].map((note) => `

Note. ${note}

`).join(''); } diff --git a/website/tests/playground.test.mjs b/website/tests/playground.test.mjs index 765bdc9ce..890f93e61 100644 --- a/website/tests/playground.test.mjs +++ b/website/tests/playground.test.mjs @@ -147,6 +147,12 @@ test('each known divergence a translation relies on is reported with its constru [['hash', ['fn:HASH']]], ); assert.match(statement.divergences[0].reason, /never Snowflake's value/); + assert.equal(statement.divergences[0].assumption, false); + // An assumption gives a wrong value silently where it does not hold, so it is reported as one. + const today = JSON.parse(analyze('SELECT CURRENT_DATE AS d', SCHEMA, JSON.stringify({ policy: repository, utc_session: true }))); + const assumed = today.translate.statements[0].divergences.find((d) => d.id === 'session_timezone_utc'); + assert.equal(assumed?.assumption, true); + assert.ok(assumed.reason); // Without a divergence, none is reported. assert.deepEqual(JSON.parse(analyze('SELECT order_id FROM orders', SCHEMA)).translate.statements[0].divergences, []); }); diff --git a/website/wasm/src/lib.rs b/website/wasm/src/lib.rs index 31b93d720..2484c1bd6 100644 --- a/website/wasm/src/lib.rs +++ b/website/wasm/src/lib.rs @@ -350,12 +350,20 @@ fn report(sql: &str, schema: &str, settings: &str) -> Value { "type": column.data_type.to_string(), })).collect::>(), // Each known divergence the policy allows and the conversion relies on, as - // `sqlquack convert` names it on standard error, with the policy's reason. - "divergences": done.provenance.divergences.iter().map(|(id, constructs)| json!({ - "id": id, - "constructs": constructs, - "reason": options.divergences.get(id).map(|allowed| allowed.reason.as_str()), - })).collect::>(), + // `sqlquack convert` names it on standard error, with its reason. An id such as + // `declared_offset:REL.COL` is allowed under the part before the colon, as the + // command line's `assumption_id` reads it. + "divergences": done.provenance.divergences.iter().map(|(id, constructs)| { + let allowed = options + .divergences + .get(id.split_once(':').map_or(id.as_str(), |(base, _)| base)); + json!({ + "id": id, + "constructs": constructs, + "reason": allowed.map(|allowed| allowed.reason.as_str()), + "assumption": allowed.is_some_and(|allowed| allowed.assumption), + }) + }).collect::>(), // Where each raise ID in the SQL comes from, so a run's error points at the source. "raises": done.raises, })),