From d539bbe0259ab5ad7d742ebaeb9a489f83ec0994 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:04:13 +0000 Subject: [PATCH] Playground: test every example's stages; mark valid-but-untranslated SQL The examples move into examples.js, shared with the engine tests, which now check that the first-load default and the translating examples pass every stage, and that each error example stops where it is meant to. The refused example (HASH, a known divergence) gets a caption saying why SQLQuack refuses rather than guesses. When the SQL parses and passes the Snowflake checks but is not translated, the results open with "Valid Snowflake SQL. Not translated to DuckDB: ", stage 3 reads NOT TRANSLATED in amber instead of a red FAIL, its underline is amber, and stage 4 says nothing was translated. Parse and check failures stay red. --- website/public/playground/examples.js | 51 +++++++++++++++ website/public/playground/index.html | 2 + website/public/playground/playground.js | 82 ++++++++++--------------- website/public/styles.css | 42 ++++++++++++- website/tests/playground.test.mjs | 21 +++++++ 5 files changed, 147 insertions(+), 51 deletions(-) create mode 100644 website/public/playground/examples.js diff --git a/website/public/playground/examples.js b/website/public/playground/examples.js new file mode 100644 index 000000000..df7c4444c --- /dev/null +++ b/website/public/playground/examples.js @@ -0,0 +1,51 @@ +// The playground's starting schema, fixture rows and examples. Every example that is meant to +// translate does so cleanly (tests/playground.test.mjs checks each one's stages). + +export const SCHEMA = `{ + "ORDERS": [ + ["ORDER_ID", "NUMBER(38,0)"], + ["CUSTOMER_ID", "NUMBER(38,0)"], + ["STATUS", "VARCHAR"], + ["TOTAL", "NUMBER(12,2)"], + ["ORDERED_AT", "TIMESTAMP_NTZ"] + ] +}`; + +export const ROWS = `INSERT INTO "ORDERS" VALUES + (1, 10, 'shipped', 120.50, TIMESTAMP '2026-10-01 09:30:00'), + (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');`; + +export const EXAMPLES = { + valid: `SELECT customer_id, + SUM(total) AS revenue, + IFF(COUNT(*) > 1, 'repeat', 'new') AS segment +FROM orders +WHERE status = 'shipped' +GROUP BY customer_id +ORDER BY revenue DESC`, + decimal: `SELECT order_id, total / 3 AS third +FROM orders +ORDER BY order_id`, + syntax: `SELECT customer_id, SUM(total) AS revenue +FROM orders +WHERE status = 'shipped' AND +GROUP BY customer_id`, + rejected: `SELECT customer_id, SUM(total) AS revenue +FROM orders +GROUP BY customer_id +QUALIFY revenue > 100`, + unknown: `SELECT order_id, discount +FROM orders`, + refused: `SELECT order_id, + HASH(customer_id) AS bucket +FROM orders`, +}; + +// Shown under the editor while an example is loaded and unedited. +export const CAPTIONS = { + refused: + 'Valid Snowflake SQL that SQLQuack will not translate. DuckDB computes HASH differently, so translated SQL would run and return other values. SQLQuack refuses and says why rather than guess; a policy file can allow a known divergence explicitly.', +}; diff --git a/website/public/playground/index.html b/website/public/playground/index.html index c23348ffe..9337148ff 100644 --- a/website/public/playground/index.html +++ b/website/public/playground/index.html @@ -79,6 +79,7 @@

Playground

Hover an underline for its diagnostic.

+
Schema: the relations your SQL reads @@ -99,6 +100,7 @@

Playground

+
1

Parse as Snowflake

…
diff --git a/website/public/playground/playground.js b/website/public/playground/playground.js index e2ffced55..12e056fbd 100644 --- a/website/public/playground/playground.js +++ b/website/public/playground/playground.js @@ -1,6 +1,7 @@ // The SQLQuack playground: each edit runs the engine (SQLQuack compiled to WebAssembly, in a // worker) and shows its stages; translated SQL runs in DuckDB-WASM. +import { CAPTIONS, EXAMPLES, ROWS, SCHEMA } from './examples.js'; import { escape, rendered, underlined } from './spans.js'; // The engine's worker; `engine(op, fields)` resolves with its reply. @@ -23,49 +24,6 @@ function engine(op, fields) { } -const SCHEMA = `{ - "ORDERS": [ - ["ORDER_ID", "NUMBER(38,0)"], - ["CUSTOMER_ID", "NUMBER(38,0)"], - ["STATUS", "VARCHAR"], - ["TOTAL", "NUMBER(12,2)"], - ["ORDERED_AT", "TIMESTAMP_NTZ"] - ] -}`; - -const ROWS = `INSERT INTO "ORDERS" VALUES - (1, 10, 'shipped', 120.50, TIMESTAMP '2026-10-01 09:30:00'), - (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');`; - -const EXAMPLES = { - valid: `SELECT customer_id, - SUM(total) AS revenue, - IFF(COUNT(*) > 1, 'repeat', 'new') AS segment -FROM orders -WHERE status = 'shipped' -GROUP BY customer_id -ORDER BY revenue DESC`, - decimal: `SELECT order_id, total / 3 AS third -FROM orders -ORDER BY order_id`, - syntax: `SELECT customer_id, SUM(total) AS revenue -FROM orders -WHERE status = 'shipped' AND -GROUP BY customer_id`, - rejected: `SELECT customer_id, SUM(total) AS revenue -FROM orders -GROUP BY customer_id -QUALIFY revenue > 100`, - unknown: `SELECT order_id, discount -FROM orders`, - refused: `SELECT order_id, - HASH(customer_id) AS bucket -FROM orders`, -}; - const $ = (selector) => document.querySelector(selector); const sqlBox = $('#pg-sql'); const schemaBox = $('#pg-schema'); @@ -76,14 +34,17 @@ const strictBox = $('#pg-strict'); const utcBox = $('#pg-utc'); const settings = () => JSON.stringify({ policy: policyBox.value, session: sessionBox.value, strict: strictBox.checked, utc_session: utcBox.checked }); +const caption = $('#pg-caption'); +const verdict = $('#pg-verdict'); const marks = $('#pg-marks'); const tip = $('#pg-tip'); -function setStage(id, status, body, sub) { +// `label` overrides the pill's text, which is otherwise the status. +function setStage(id, status, body, sub, label) { const stage = $(`#stage-${id}`); const pill = stage.querySelector('.pg-status'); pill.className = `pg-status ${status}`; - pill.textContent = status; + pill.textContent = label ?? status; stage.querySelector('.pg-body').innerHTML = body ?? ''; if (sub !== undefined) stage.querySelector('.pg-sub').textContent = sub; } @@ -98,6 +59,11 @@ function underline(chars, items) { marks.scrollTop = sqlBox.scrollTop; } +function showCaption(example) { + caption.textContent = CAPTIONS[example] ?? ''; + caption.hidden = !CAPTIONS[example]; +} + let latest = null; let current = 0; @@ -110,6 +76,7 @@ async function run() { const chars = Array.from(sql); const report = JSON.parse(reply.result); latest = null; + verdict.hidden = true; if (report.schema_error || report.settings_error) { underline(chars, []); const what = report.schema_error ? 'The schema is not valid' : 'The policy or session is not valid'; @@ -151,13 +118,23 @@ async function run() { ); const translate = report.translate; - add(translate.diagnostics, 'error'); + // Valid SQL that SQLQuack declines to translate is not an error in the SQL: shown in amber, not red. + const refused = parse.status === 'pass' && checks.status === 'pass' && translate.status === 'fail'; + const tone = (html) => (refused ? html.replace('class="bad"', 'class="warn"') : html); + for (const diagnostic of translate.diagnostics ?? []) + spans.push({ kind: refused ? 'warn' : 'error', span: diagnostic.span, text: tone(rendered(diagnostic, chars)) }); let body = ''; if (translate.status !== 'skipped') { body = (translate.statements ?? []).map((s) => terminal(escape(s.sql))).join(''); - body += (translate.diagnostics ?? []).map((d) => terminal(rendered(d, chars))).join(''); + body += (translate.diagnostics ?? []).map((d) => terminal(tone(rendered(d, chars)))).join(''); } - setStage('translate', translate.status, body, translate.profile ? `policy: ${translate.profile}` : ''); + const policy = translate.profile ? `policy: ${translate.profile}` : ''; + if (refused) { + const reason = translate.diagnostics?.[0]?.message ?? 'no supported conversion'; + verdict.innerHTML = `Valid Snowflake SQL. Not translated to DuckDB: ${escape(reason)}`; + verdict.hidden = false; + setStage('translate', 'refused', body, policy, 'not translated'); + } else setStage('translate', translate.status, body, policy); underline(chars, spans); const statements = translate.statements ?? []; @@ -167,7 +144,7 @@ async function run() { else setStage('run', 'ready', '', 'loads DuckDB-WASM (about 8 MB compressed)'); } else if (translate.status === 'pass') { setStage('run', 'skipped', terminal('Runs one statement at a time.')); - } else setStage('run', 'skipped', ''); + } else setStage('run', 'skipped', '', refused ? 'nothing was translated' : ''); } // DuckDB-WASM, loaded on first use. @@ -228,7 +205,10 @@ const schedule = () => { document.addEventListener('click', (event) => { if (event.target.id === 'pg-run') execute(); }); -sqlBox.addEventListener('input', schedule); +sqlBox.addEventListener('input', () => { + showCaption(null); + schedule(); +}); schemaBox.addEventListener('input', schedule); for (const box of [policyBox, sessionBox, strictBox, utcBox]) box.addEventListener('input', schedule); $('#pg-load-policy').addEventListener('click', async () => { @@ -240,6 +220,7 @@ sqlBox.addEventListener('scroll', () => (marks.scrollTop = sqlBox.scrollTop)); $('#pg-example').addEventListener('change', (event) => { if (!EXAMPLES[event.target.value]) return; sqlBox.value = EXAMPLES[event.target.value]; + showCaption(event.target.value); event.target.value = ''; run(); }); @@ -263,6 +244,7 @@ sqlBox.addEventListener('mouseleave', () => (tip.hidden = true)); const params = new URLSearchParams(location.search); sqlBox.value = params.get('sql') ?? EXAMPLES[params.get('example')] ?? EXAMPLES.valid; +if (!params.get('sql')) showCaption(params.get('example')); schemaBox.value = SCHEMA; rowsBox.value = ROWS; setStage('parse', 'loading', '', 'loading the engine'); diff --git a/website/public/styles.css b/website/public/styles.css index 9df4926b2..57c328c95 100644 --- a/website/public/styles.css +++ b/website/public/styles.css @@ -1216,6 +1216,18 @@ footer a { font-size: 12px; } +.pg-caption { + padding: 8px 10px; + border-left: 2px solid var(--primary); + background: var(--accent-soft); + color: var(--muted-foreground); + font-size: 13px; +} + +.pg-caption[hidden] { + display: none; +} + .pg-note label { margin-right: 16px; cursor: pointer; @@ -1269,10 +1281,18 @@ footer a { .pg-stage .pg-sub { margin-left: auto; - color: var(--faint); + color: var(--muted-foreground); font-size: 12px; } +/* A note under a stage's body, inside the panel, in the same grey as the stage subtitles. */ +.pg-stage .pg-stage-note { + margin: 0; + padding: 0 16px 12px; + color: var(--muted-foreground); + line-height: 1.5; +} + .pg-status { padding: 2px 8px; border-radius: var(--radius); @@ -1295,6 +1315,26 @@ footer a { color: var(--error); } +.pg-status.refused { + background: rgba(226, 165, 74, 0.16); + color: var(--warning); +} + +.pg-verdict { + margin: 0; + padding: 10px 14px; + border: 1px solid rgba(226, 165, 74, 0.35); + border-left: 3px solid var(--warning); + border-radius: var(--radius); + background: rgba(226, 165, 74, 0.08); + color: var(--foreground); + font-size: 13px; +} + +.pg-verdict[hidden] { + display: none; +} + .terminal .warn { color: var(--warning); } diff --git a/website/tests/playground.test.mjs b/website/tests/playground.test.mjs index dd03b4156..cd7852c51 100644 --- a/website/tests/playground.test.mjs +++ b/website/tests/playground.test.mjs @@ -81,3 +81,24 @@ test('the conversion budget is timed in the browser and refuses with SQ0006 when assert.equal(report.translate.diagnostics[0].code, 'SQ0006'); assert.match(report.translate.diagnostics[0].label, /beyond the budget of 0 ms/); }); + +test('every example stops at the stage it is meant to, and the default translates cleanly', async () => { + const { EXAMPLES, CAPTIONS, SCHEMA: PAGE_SCHEMA } = await import('../public/playground/examples.js'); + const expected = { + valid: ['pass', 'pass', 'pass'], + decimal: ['pass', 'pass', 'pass'], + syntax: ['fail', 'skipped', 'skipped'], + rejected: ['pass', 'fail', 'skipped'], + unknown: ['pass', 'fail', 'fail'], + refused: ['pass', 'pass', 'fail'], + }; + assert.deepEqual(Object.keys(EXAMPLES).sort(), Object.keys(expected).sort()); + for (const [name, sql] of Object.entries(EXAMPLES)) { + const report = JSON.parse(analyze(sql, PAGE_SCHEMA)); + assert.deepEqual(['parse', 'checks', 'translate'].map((stage) => report[stage].status), expected[name], name); + } + // The one deliberate refusal is a known divergence, explained by its caption. + const refused = JSON.parse(analyze(EXAMPLES.refused, PAGE_SCHEMA)).translate; + assert.deepEqual(refused.diagnostics.map((d) => d.code), ['SQ0008']); + assert.ok(CAPTIONS.refused); +});