Skip to content

Commit d539bbe

Browse files
committed
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: <reason>", 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.
1 parent 49bd4ec commit d539bbe

5 files changed

Lines changed: 147 additions & 51 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// The playground's starting schema, fixture rows and examples. Every example that is meant to
2+
// translate does so cleanly (tests/playground.test.mjs checks each one's stages).
3+
4+
export const SCHEMA = `{
5+
"ORDERS": [
6+
["ORDER_ID", "NUMBER(38,0)"],
7+
["CUSTOMER_ID", "NUMBER(38,0)"],
8+
["STATUS", "VARCHAR"],
9+
["TOTAL", "NUMBER(12,2)"],
10+
["ORDERED_AT", "TIMESTAMP_NTZ"]
11+
]
12+
}`;
13+
14+
export const ROWS = `INSERT INTO "ORDERS" VALUES
15+
(1, 10, 'shipped', 120.50, TIMESTAMP '2026-10-01 09:30:00'),
16+
(2, 10, 'shipped', 80.00, TIMESTAMP '2026-10-03 14:00:00'),
17+
(3, 11, 'pending', 45.25, TIMESTAMP '2026-10-04 11:15:00'),
18+
(4, 12, 'shipped', 310.00, TIMESTAMP '2026-10-05 16:45:00'),
19+
(5, 12, NULL, 12.99, TIMESTAMP '2026-10-06 08:05:00');`;
20+
21+
export const EXAMPLES = {
22+
valid: `SELECT customer_id,
23+
SUM(total) AS revenue,
24+
IFF(COUNT(*) > 1, 'repeat', 'new') AS segment
25+
FROM orders
26+
WHERE status = 'shipped'
27+
GROUP BY customer_id
28+
ORDER BY revenue DESC`,
29+
decimal: `SELECT order_id, total / 3 AS third
30+
FROM orders
31+
ORDER BY order_id`,
32+
syntax: `SELECT customer_id, SUM(total) AS revenue
33+
FROM orders
34+
WHERE status = 'shipped' AND
35+
GROUP BY customer_id`,
36+
rejected: `SELECT customer_id, SUM(total) AS revenue
37+
FROM orders
38+
GROUP BY customer_id
39+
QUALIFY revenue > 100`,
40+
unknown: `SELECT order_id, discount
41+
FROM orders`,
42+
refused: `SELECT order_id,
43+
HASH(customer_id) AS bucket
44+
FROM orders`,
45+
};
46+
47+
// Shown under the editor while an example is loaded and unedited.
48+
export const CAPTIONS = {
49+
refused:
50+
'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.',
51+
};

‎website/public/playground/index.html‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ <h1>Playground</h1>
7979
<textarea id="pg-sql" spellcheck="false" autocomplete="off" autocapitalize="off"></textarea>
8080
</div>
8181
<p class="pg-note">Hover an underline for its diagnostic.</p>
82+
<p class="pg-note pg-caption" id="pg-caption" hidden></p>
8283
<details open>
8384
<summary>Schema: the relations your SQL reads</summary>
8485
<textarea class="pg-small" id="pg-schema" spellcheck="false" aria-label="Schema JSON"></textarea>
@@ -99,6 +100,7 @@ <h1>Playground</h1>
99100
</details>
100101
</div>
101102
<div class="pg-stages" aria-live="polite">
103+
<p class="pg-verdict" id="pg-verdict" hidden></p>
102104
<section class="pg-stage" id="stage-parse">
103105
<header><span class="pg-num">1</span><h2>Parse as Snowflake</h2><span class="pg-sub"></span><span class="pg-status">…</span></header>
104106
<div class="pg-body"></div>

‎website/public/playground/playground.js‎

Lines changed: 32 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// The SQLQuack playground: each edit runs the engine (SQLQuack compiled to WebAssembly, in a
22
// worker) and shows its stages; translated SQL runs in DuckDB-WASM.
33

4+
import { CAPTIONS, EXAMPLES, ROWS, SCHEMA } from './examples.js';
45
import { escape, rendered, underlined } from './spans.js';
56

67
// The engine's worker; `engine(op, fields)` resolves with its reply.
@@ -23,49 +24,6 @@ function engine(op, fields) {
2324
}
2425

2526

26-
const SCHEMA = `{
27-
"ORDERS": [
28-
["ORDER_ID", "NUMBER(38,0)"],
29-
["CUSTOMER_ID", "NUMBER(38,0)"],
30-
["STATUS", "VARCHAR"],
31-
["TOTAL", "NUMBER(12,2)"],
32-
["ORDERED_AT", "TIMESTAMP_NTZ"]
33-
]
34-
}`;
35-
36-
const ROWS = `INSERT INTO "ORDERS" VALUES
37-
(1, 10, 'shipped', 120.50, TIMESTAMP '2026-10-01 09:30:00'),
38-
(2, 10, 'shipped', 80.00, TIMESTAMP '2026-10-03 14:00:00'),
39-
(3, 11, 'pending', 45.25, TIMESTAMP '2026-10-04 11:15:00'),
40-
(4, 12, 'shipped', 310.00, TIMESTAMP '2026-10-05 16:45:00'),
41-
(5, 12, NULL, 12.99, TIMESTAMP '2026-10-06 08:05:00');`;
42-
43-
const EXAMPLES = {
44-
valid: `SELECT customer_id,
45-
SUM(total) AS revenue,
46-
IFF(COUNT(*) > 1, 'repeat', 'new') AS segment
47-
FROM orders
48-
WHERE status = 'shipped'
49-
GROUP BY customer_id
50-
ORDER BY revenue DESC`,
51-
decimal: `SELECT order_id, total / 3 AS third
52-
FROM orders
53-
ORDER BY order_id`,
54-
syntax: `SELECT customer_id, SUM(total) AS revenue
55-
FROM orders
56-
WHERE status = 'shipped' AND
57-
GROUP BY customer_id`,
58-
rejected: `SELECT customer_id, SUM(total) AS revenue
59-
FROM orders
60-
GROUP BY customer_id
61-
QUALIFY revenue > 100`,
62-
unknown: `SELECT order_id, discount
63-
FROM orders`,
64-
refused: `SELECT order_id,
65-
HASH(customer_id) AS bucket
66-
FROM orders`,
67-
};
68-
6927
const $ = (selector) => document.querySelector(selector);
7028
const sqlBox = $('#pg-sql');
7129
const schemaBox = $('#pg-schema');
@@ -76,14 +34,17 @@ const strictBox = $('#pg-strict');
7634
const utcBox = $('#pg-utc');
7735
const settings = () =>
7836
JSON.stringify({ policy: policyBox.value, session: sessionBox.value, strict: strictBox.checked, utc_session: utcBox.checked });
37+
const caption = $('#pg-caption');
38+
const verdict = $('#pg-verdict');
7939
const marks = $('#pg-marks');
8040
const tip = $('#pg-tip');
8141

82-
function setStage(id, status, body, sub) {
42+
// `label` overrides the pill's text, which is otherwise the status.
43+
function setStage(id, status, body, sub, label) {
8344
const stage = $(`#stage-${id}`);
8445
const pill = stage.querySelector('.pg-status');
8546
pill.className = `pg-status ${status}`;
86-
pill.textContent = status;
47+
pill.textContent = label ?? status;
8748
stage.querySelector('.pg-body').innerHTML = body ?? '';
8849
if (sub !== undefined) stage.querySelector('.pg-sub').textContent = sub;
8950
}
@@ -98,6 +59,11 @@ function underline(chars, items) {
9859
marks.scrollTop = sqlBox.scrollTop;
9960
}
10061

62+
function showCaption(example) {
63+
caption.textContent = CAPTIONS[example] ?? '';
64+
caption.hidden = !CAPTIONS[example];
65+
}
66+
10167
let latest = null;
10268
let current = 0;
10369

@@ -110,6 +76,7 @@ async function run() {
11076
const chars = Array.from(sql);
11177
const report = JSON.parse(reply.result);
11278
latest = null;
79+
verdict.hidden = true;
11380
if (report.schema_error || report.settings_error) {
11481
underline(chars, []);
11582
const what = report.schema_error ? 'The schema is not valid' : 'The policy or session is not valid';
@@ -151,13 +118,23 @@ async function run() {
151118
);
152119

153120
const translate = report.translate;
154-
add(translate.diagnostics, 'error');
121+
// Valid SQL that SQLQuack declines to translate is not an error in the SQL: shown in amber, not red.
122+
const refused = parse.status === 'pass' && checks.status === 'pass' && translate.status === 'fail';
123+
const tone = (html) => (refused ? html.replace('class="bad"', 'class="warn"') : html);
124+
for (const diagnostic of translate.diagnostics ?? [])
125+
spans.push({ kind: refused ? 'warn' : 'error', span: diagnostic.span, text: tone(rendered(diagnostic, chars)) });
155126
let body = '';
156127
if (translate.status !== 'skipped') {
157128
body = (translate.statements ?? []).map((s) => terminal(escape(s.sql))).join('');
158-
body += (translate.diagnostics ?? []).map((d) => terminal(rendered(d, chars))).join('');
129+
body += (translate.diagnostics ?? []).map((d) => terminal(tone(rendered(d, chars)))).join('');
159130
}
160-
setStage('translate', translate.status, body, translate.profile ? `policy: ${translate.profile}` : '');
131+
const policy = translate.profile ? `policy: ${translate.profile}` : '';
132+
if (refused) {
133+
const reason = translate.diagnostics?.[0]?.message ?? 'no supported conversion';
134+
verdict.innerHTML = `<strong>Valid Snowflake SQL.</strong> Not translated to DuckDB: ${escape(reason)}`;
135+
verdict.hidden = false;
136+
setStage('translate', 'refused', body, policy, 'not translated');
137+
} else setStage('translate', translate.status, body, policy);
161138
underline(chars, spans);
162139

163140
const statements = translate.statements ?? [];
@@ -167,7 +144,7 @@ async function run() {
167144
else setStage('run', 'ready', '<button class="button primary" type="button" id="pg-run">Run in DuckDB-WASM</button>', 'loads DuckDB-WASM (about 8 MB compressed)');
168145
} else if (translate.status === 'pass') {
169146
setStage('run', 'skipped', terminal('<span class="dim">Runs one statement at a time.</span>'));
170-
} else setStage('run', 'skipped', '');
147+
} else setStage('run', 'skipped', '', refused ? 'nothing was translated' : '');
171148
}
172149

173150
// DuckDB-WASM, loaded on first use.
@@ -228,7 +205,10 @@ const schedule = () => {
228205
document.addEventListener('click', (event) => {
229206
if (event.target.id === 'pg-run') execute();
230207
});
231-
sqlBox.addEventListener('input', schedule);
208+
sqlBox.addEventListener('input', () => {
209+
showCaption(null);
210+
schedule();
211+
});
232212
schemaBox.addEventListener('input', schedule);
233213
for (const box of [policyBox, sessionBox, strictBox, utcBox]) box.addEventListener('input', schedule);
234214
$('#pg-load-policy').addEventListener('click', async () => {
@@ -240,6 +220,7 @@ sqlBox.addEventListener('scroll', () => (marks.scrollTop = sqlBox.scrollTop));
240220
$('#pg-example').addEventListener('change', (event) => {
241221
if (!EXAMPLES[event.target.value]) return;
242222
sqlBox.value = EXAMPLES[event.target.value];
223+
showCaption(event.target.value);
243224
event.target.value = '';
244225
run();
245226
});
@@ -263,6 +244,7 @@ sqlBox.addEventListener('mouseleave', () => (tip.hidden = true));
263244

264245
const params = new URLSearchParams(location.search);
265246
sqlBox.value = params.get('sql') ?? EXAMPLES[params.get('example')] ?? EXAMPLES.valid;
247+
if (!params.get('sql')) showCaption(params.get('example'));
266248
schemaBox.value = SCHEMA;
267249
rowsBox.value = ROWS;
268250
setStage('parse', 'loading', '', 'loading the engine');

‎website/public/styles.css‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1216,6 +1216,18 @@ footer a {
12161216
font-size: 12px;
12171217
}
12181218

1219+
.pg-caption {
1220+
padding: 8px 10px;
1221+
border-left: 2px solid var(--primary);
1222+
background: var(--accent-soft);
1223+
color: var(--muted-foreground);
1224+
font-size: 13px;
1225+
}
1226+
1227+
.pg-caption[hidden] {
1228+
display: none;
1229+
}
1230+
12191231
.pg-note label {
12201232
margin-right: 16px;
12211233
cursor: pointer;
@@ -1269,10 +1281,18 @@ footer a {
12691281

12701282
.pg-stage .pg-sub {
12711283
margin-left: auto;
1272-
color: var(--faint);
1284+
color: var(--muted-foreground);
12731285
font-size: 12px;
12741286
}
12751287

1288+
/* A note under a stage's body, inside the panel, in the same grey as the stage subtitles. */
1289+
.pg-stage .pg-stage-note {
1290+
margin: 0;
1291+
padding: 0 16px 12px;
1292+
color: var(--muted-foreground);
1293+
line-height: 1.5;
1294+
}
1295+
12761296
.pg-status {
12771297
padding: 2px 8px;
12781298
border-radius: var(--radius);
@@ -1295,6 +1315,26 @@ footer a {
12951315
color: var(--error);
12961316
}
12971317

1318+
.pg-status.refused {
1319+
background: rgba(226, 165, 74, 0.16);
1320+
color: var(--warning);
1321+
}
1322+
1323+
.pg-verdict {
1324+
margin: 0;
1325+
padding: 10px 14px;
1326+
border: 1px solid rgba(226, 165, 74, 0.35);
1327+
border-left: 3px solid var(--warning);
1328+
border-radius: var(--radius);
1329+
background: rgba(226, 165, 74, 0.08);
1330+
color: var(--foreground);
1331+
font-size: 13px;
1332+
}
1333+
1334+
.pg-verdict[hidden] {
1335+
display: none;
1336+
}
1337+
12981338
.terminal .warn {
12991339
color: var(--warning);
13001340
}

‎website/tests/playground.test.mjs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,24 @@ test('the conversion budget is timed in the browser and refuses with SQ0006 when
8181
assert.equal(report.translate.diagnostics[0].code, 'SQ0006');
8282
assert.match(report.translate.diagnostics[0].label, /beyond the budget of 0 ms/);
8383
});
84+
85+
test('every example stops at the stage it is meant to, and the default translates cleanly', async () => {
86+
const { EXAMPLES, CAPTIONS, SCHEMA: PAGE_SCHEMA } = await import('../public/playground/examples.js');
87+
const expected = {
88+
valid: ['pass', 'pass', 'pass'],
89+
decimal: ['pass', 'pass', 'pass'],
90+
syntax: ['fail', 'skipped', 'skipped'],
91+
rejected: ['pass', 'fail', 'skipped'],
92+
unknown: ['pass', 'fail', 'fail'],
93+
refused: ['pass', 'pass', 'fail'],
94+
};
95+
assert.deepEqual(Object.keys(EXAMPLES).sort(), Object.keys(expected).sort());
96+
for (const [name, sql] of Object.entries(EXAMPLES)) {
97+
const report = JSON.parse(analyze(sql, PAGE_SCHEMA));
98+
assert.deepEqual(['parse', 'checks', 'translate'].map((stage) => report[stage].status), expected[name], name);
99+
}
100+
// The one deliberate refusal is a known divergence, explained by its caption.
101+
const refused = JSON.parse(analyze(EXAMPLES.refused, PAGE_SCHEMA)).translate;
102+
assert.deepEqual(refused.diagnostics.map((d) => d.code), ['SQ0008']);
103+
assert.ok(CAPTIONS.refused);
104+
});

0 commit comments

Comments
 (0)