Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions website/public/playground/examples.js
Original file line number Diff line number Diff line change
@@ -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.',
};
2 changes: 2 additions & 0 deletions website/public/playground/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ <h1>Playground</h1>
<textarea id="pg-sql" spellcheck="false" autocomplete="off" autocapitalize="off"></textarea>
</div>
<p class="pg-note">Hover an underline for its diagnostic.</p>
<p class="pg-note pg-caption" id="pg-caption" hidden></p>
<details open>
<summary>Schema: the relations your SQL reads</summary>
<textarea class="pg-small" id="pg-schema" spellcheck="false" aria-label="Schema JSON"></textarea>
Expand All @@ -99,6 +100,7 @@ <h1>Playground</h1>
</details>
</div>
<div class="pg-stages" aria-live="polite">
<p class="pg-verdict" id="pg-verdict" hidden></p>
<section class="pg-stage" id="stage-parse">
<header><span class="pg-num">1</span><h2>Parse as Snowflake</h2><span class="pg-sub"></span><span class="pg-status">…</span></header>
<div class="pg-body"></div>
Expand Down
82 changes: 32 additions & 50 deletions website/public/playground/playground.js
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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');
Expand All @@ -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;
}
Expand All @@ -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;

Expand All @@ -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';
Expand Down Expand Up @@ -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 = `<strong>Valid Snowflake SQL.</strong> 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 ?? [];
Expand All @@ -167,7 +144,7 @@ async function run() {
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)');
} else if (translate.status === 'pass') {
setStage('run', 'skipped', terminal('<span class="dim">Runs one statement at a time.</span>'));
} else setStage('run', 'skipped', '');
} else setStage('run', 'skipped', '', refused ? 'nothing was translated' : '');
}

// DuckDB-WASM, loaded on first use.
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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();
});
Expand All @@ -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');
Expand Down
42 changes: 41 additions & 1 deletion website/public/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
Expand Down
21 changes: 21 additions & 0 deletions website/tests/playground.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading