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); +});