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
36 changes: 36 additions & 0 deletions website/public/playground/examples.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,42 @@ FROM orders`,
FROM orders`,
};

// The example chips above the editor, grouped by what SQLQuack does with them. `slug` is the
// example's `?example=` link and `stops` the stage where its run stops (null when all four pass);
// tests/playground.test.mjs checks each `stops` against the engine and DuckDB-WASM.
export const EXAMPLE_GROUPS = [
{
title: 'Runs it',
tagline: 'verified results',
examples: [
{ key: 'valid', slug: 'converts-and-runs', name: 'Converts and runs', stops: null },
{ key: 'decimal', slug: 'decimal-division', name: 'Decimal division', stops: null },
],
},
{
title: 'Catches the mistake',
tagline: 'a clear no',
examples: [
{ key: 'syntax', slug: 'syntax-error', name: 'Syntax error', stops: 'parse' },
{ key: 'rejected', slug: 'snowflake-rejects-it', name: 'Snowflake rejects it', stops: 'checks' },
{ key: 'unknown', slug: 'unknown-column', name: 'Unknown column', stops: 'checks' },
],
},
{
title: 'Says no, clearly',
tagline: 'a clear no',
examples: [{ key: 'refused', slug: 'translation-refused', name: 'Translation refused', stops: 'translate' }],
},
];

export const STOP_LABELS = {
parse: 'stops at Parse',
checks: 'stops at Check',
translate: 'stops at Translate',
run: 'stops at Run',
};
export const stopLabel = (stops) => (stops ? STOP_LABELS[stops] : 'all four pass');

// Shown under the editor while an example is loaded and unedited.
export const CAPTIONS = {
refused:
Expand Down
14 changes: 2 additions & 12 deletions website/public/playground/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -66,20 +66,10 @@ <h1>Playground</h1>
your browser. Each stage passes or fails on its own, so you can see which step stopped and where.
</p>
</div>
<div class="wrap pg-examples" id="pg-examples" role="group" aria-label="Examples"></div>
<div class="wrap playground">
<div class="pg-inputs">
<label class="pg-label" for="pg-sql"
><span>Snowflake SQL</span>
<select id="pg-example" aria-label="Examples">
<option value="">Examples…</option>
<option value="valid">Converts and runs</option>
<option value="decimal">Decimal division</option>
<option value="syntax">Syntax error</option>
<option value="rejected">Snowflake rejects it</option>
<option value="unknown">Unknown column</option>
<option value="refused">Translation refused</option>
</select></label
>
<label class="pg-label" for="pg-sql"><span>Snowflake SQL</span></label>
<div class="pg-editor">
<div class="pg-marks" id="pg-marks" aria-hidden="true"></div>
<textarea id="pg-sql" spellcheck="false" autocomplete="off" autocapitalize="off"></textarea>
Expand Down
62 changes: 54 additions & 8 deletions website/public/playground/playground.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import { DUCKDB_DIR, DUCKDB_WASM_BYTES } from './duckdb-version.js';
import { ENGINE_DIR, ENGINE_WASM_BYTES } from './engine-version.js';
import { CAPTIONS, EXAMPLES, ROWS, SCHEMA } from './examples.js';
import { CAPTIONS, EXAMPLE_GROUPS, EXAMPLES, ROWS, SCHEMA, stopLabel } from './examples.js';
import { escape, rendered, resultTable, underlined } from './spans.js';

const here = (path) => new URL(path, import.meta.url).href;
Expand Down Expand Up @@ -426,6 +426,13 @@ document.addEventListener('click', (event) => {
});
sqlBox.addEventListener('input', () => {
showCaption(null);
// Edited SQL is no longer the example, so neither the chip nor the link names it any more.
if (activeExample) {
setActiveExample(null);
const url = new URL(location.href);
url.searchParams.delete('example');
history.replaceState(null, '', url);
}
schedule();
});
schemaBox.addEventListener('input', schedule);
Expand All @@ -436,11 +443,45 @@ $('#pg-load-policy').addEventListener('click', async () => {
});
rowsBox.addEventListener('input', () => latest && duckdb && schedule());
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 = '';

// The example chips: one click loads an example and runs it, and the link names it
// (`?example=<slug>`) so it can be shared.
const examples = EXAMPLE_GROUPS.flatMap((group) => group.examples);
const examplesBox = $('#pg-examples');
examplesBox.innerHTML = EXAMPLE_GROUPS.map(
(group) =>
`<div class="pg-group"><p class="pg-group-head"><strong>${escape(group.title)}</strong> <span>${escape(group.tagline)}</span></p>` +
'<div class="pg-chips">' +
group.examples
.map(
(example) =>
`<button class="pg-chip" type="button" aria-pressed="false" data-example="${example.key}" data-stops="${example.stops ?? 'none'}">` +
`<span class="pg-chip-name">${escape(example.name)}</span><span class="pg-chip-stop">${escape(stopLabel(example.stops))}</span></button>`,
)
.join('') +
'</div></div>',
).join('');

let activeExample = null;
function setActiveExample(key) {
activeExample = key;
for (const chip of examplesBox.querySelectorAll('.pg-chip')) chip.setAttribute('aria-pressed', String(chip.dataset.example === key));
}

function loadExample(key) {
sqlBox.value = EXAMPLES[key];
showCaption(key);
setActiveExample(key);
}

examplesBox.addEventListener('click', (event) => {
const key = event.target.closest('.pg-chip')?.dataset.example;
if (!key) return;
loadExample(key);
const url = new URL(location.href);
url.searchParams.delete('sql');
url.searchParams.set('example', examples.find((example) => example.key === key).slug);
history.replaceState(null, '', url);
run();
});

Expand All @@ -462,8 +503,13 @@ sqlBox.addEventListener('mousemove', (event) => {
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'));
if (params.get('sql') !== null) sqlBox.value = params.get('sql');
else {
// A slug, or an example's key as earlier links used; anything else opens the default quietly.
const requested = params.get('example');
const key = examples.find((example) => example.slug === requested)?.key ?? (Object.hasOwn(EXAMPLES, requested ?? '') ? requested : 'valid');
loadExample(key);
}
schemaBox.value = SCHEMA;
rowsBox.value = ROWS;
setStage('parse', 'loading', '', 'loading the engine…', 'loading');
Expand Down
89 changes: 87 additions & 2 deletions website/public/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1135,13 +1135,92 @@ footer a {
font-weight: 500;
}

.pg-label select {
padding: 4px 8px;
/* Example chips above the editor, in groups by what SQLQuack does with them. */
.pg-examples {
display: flex;
flex-wrap: wrap;
gap: 16px 32px;
padding-top: 28px;
}

.pg-group-head {
margin: 0 0 8px;
color: var(--muted-foreground);
font-size: 13px;
}

.pg-group-head strong {
color: var(--foreground);
font-weight: 600;
}

.pg-group-head span::before {
content: '· ';
}

.pg-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}

.pg-chip {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 7px 12px;
border: 1px solid var(--inner-line);
border-radius: var(--radius);
background: var(--panel);
color: var(--foreground);
font: inherit;
text-align: left;
cursor: pointer;
}

.pg-chip:hover {
border-color: var(--faint);
}

.pg-chip:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}

.pg-chip[aria-pressed='true'] {
border-color: var(--primary);
background: var(--accent-soft);
box-shadow: inset 0 0 0 1px var(--primary);
}

.pg-chip-name {
font-size: 14px;
font-weight: 550;
}

.pg-chip-stop {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--muted-foreground);
font-size: 12px;
}

.pg-chip-stop::before {
content: '';
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--error);
}

.pg-chip[data-stops='none'] .pg-chip-stop::before {
background: var(--success);
}

.pg-chip[data-stops='translate'] .pg-chip-stop::before {
background: var(--warning);
}

.pg-editor {
Expand Down Expand Up @@ -1477,3 +1556,9 @@ footer a {
grid-template-columns: minmax(0, 1fr);
}
}

@media (max-width: 620px) {
.pg-examples {
flex-direction: column;
}
}
45 changes: 30 additions & 15 deletions website/tests/playground.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
// Needs the engine built into wasm/pkg (scripts/build_engine.sh).
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { test } from 'node:test';

import { initSync, analyze, run_failure } from '../wasm/pkg/sqlquack_wasm.js';
import { initSync, analyze, fixture_tables, run_failure } from '../wasm/pkg/sqlquack_wasm.js';
import { rendered, resultTable, underlined } from '../public/playground/spans.js';

initSync({ module: readFileSync(new URL('../wasm/pkg/sqlquack_wasm_bg.wasm', import.meta.url)) });
Expand Down Expand Up @@ -83,21 +85,34 @@ test('the conversion budget is timed in the browser and refuses with SQ0006 when
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)) {
test('every example chip stops at the stage its label names, the Run stage included', async () => {
const { EXAMPLE_GROUPS, EXAMPLES, CAPTIONS, ROWS, SCHEMA: PAGE_SCHEMA } = await import('../public/playground/examples.js');
const chips = EXAMPLE_GROUPS.flatMap((group) => group.examples);
assert.deepEqual(chips.map((chip) => chip.key).sort(), Object.keys(EXAMPLES).sort());
assert.equal(new Set(chips.map((chip) => chip.slug)).size, chips.length);
// Translated examples run as the page runs them: the page's fixture tables and rows, in the
// DuckDB-WASM build the page serves, with every value read as text.
const require = createRequire(import.meta.url);
const duckdb = require('@duckdb/duckdb-wasm/dist/duckdb-node-blocking.cjs');
const dist = dirname(require.resolve('@duckdb/duckdb-wasm/dist/duckdb-node-blocking.cjs'));
const bundles = { base: { mainModule: join(dist, 'duckdb-base.wasm'), mainWorker: join(dist, 'duckdb-node-base.worker.cjs') } };
const db = await duckdb.createDuckDB(bundles, new duckdb.VoidLogger(), duckdb.NODE_RUNTIME);
await db.instantiate();
const conn = db.connect();
for (const table of JSON.parse(fixture_tables(PAGE_SCHEMA)).tables) conn.query(table);
conn.query(ROWS);
const stops = (sql) => {
const report = JSON.parse(analyze(sql, PAGE_SCHEMA));
assert.deepEqual(['parse', 'checks', 'translate'].map((stage) => report[stage].status), expected[name], name);
}
const failed = ['parse', 'checks', 'translate'].find((stage) => report[stage].status !== 'pass');
if (failed) return failed;
try {
conn.query(`SELECT COLUMNS(*)::VARCHAR FROM (${report.translate.statements[0].sql}) AS result`);
return null;
} catch {
return 'run';
}
};
for (const chip of chips) assert.equal(stops(EXAMPLES[chip.key]), chip.stops, chip.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']);
Expand Down
Loading