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