diff --git a/website/public/playground/examples.js b/website/public/playground/examples.js index 66c294d93..89a02049e 100644 --- a/website/public/playground/examples.js +++ b/website/public/playground/examples.js @@ -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: diff --git a/website/public/playground/index.html b/website/public/playground/index.html index 387a8e4f1..ba67fd8e8 100644 --- a/website/public/playground/index.html +++ b/website/public/playground/index.html @@ -66,20 +66,10 @@

Playground

your browser. Each stage passes or fails on its own, so you can see which step stopped and where.

+
- +
diff --git a/website/public/playground/playground.js b/website/public/playground/playground.js index d78ceb4d0..1bd592748 100644 --- a/website/public/playground/playground.js +++ b/website/public/playground/playground.js @@ -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; @@ -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); @@ -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=`) so it can be shared. +const examples = EXAMPLE_GROUPS.flatMap((group) => group.examples); +const examplesBox = $('#pg-examples'); +examplesBox.innerHTML = EXAMPLE_GROUPS.map( + (group) => + `

${escape(group.title)} ${escape(group.tagline)}

` + + '
' + + group.examples + .map( + (example) => + ``, + ) + .join('') + + '
', +).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(); }); @@ -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'); diff --git a/website/public/styles.css b/website/public/styles.css index 1207dd87b..857691cb0 100644 --- a/website/public/styles.css +++ b/website/public/styles.css @@ -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 { @@ -1477,3 +1556,9 @@ footer a { grid-template-columns: minmax(0, 1fr); } } + +@media (max-width: 620px) { + .pg-examples { + flex-direction: column; + } +} diff --git a/website/tests/playground.test.mjs b/website/tests/playground.test.mjs index 1f4008ae8..c0a11a151 100644 --- a/website/tests/playground.test.mjs +++ b/website/tests/playground.test.mjs @@ -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)) }); @@ -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']);