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
44 changes: 43 additions & 1 deletion website/public/playground/examples.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,32 @@ export const SCHEMA = `{
["STATUS", "VARCHAR"],
["TOTAL", "NUMBER(12,2)"],
["ORDERED_AT", "TIMESTAMP_NTZ"]
],
"CUSTOMERS": [
["CUSTOMER_ID", "NUMBER(38,0)"],
["EMAIL", "VARCHAR"],
["FIRST_NAME", "VARCHAR"],
["COUNTRY", "VARCHAR"],
["SIGNUP_DATE", "DATE"]
],
"PRODUCTS": [
["PRODUCT_ID", "NUMBER(38,0)"],
["NAME", "VARCHAR"],
["CATEGORY", "VARCHAR"],
["PRICE", "NUMBER(10,2)"]
],
"ORDER_ITEMS": [
["ORDER_ID", "NUMBER(38,0)"],
["PRODUCT_ID", "NUMBER(38,0)"],
["QUANTITY", "NUMBER(38,0)"],
["UNIT_PRICE", "NUMBER(10,2)"]
],
"EVENTS": [
["EVENT_ID", "NUMBER(38,0)"],
["CUSTOMER_ID", "NUMBER(38,0)"],
["EVENT_TYPE", "VARCHAR"],
["OCCURRED_AT", "TIMESTAMP_NTZ"],
["PAYLOAD", "VARIANT"]
]
}`;

Expand All @@ -16,7 +42,23 @@ export const ROWS = `INSERT INTO "ORDERS" VALUES
(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');`;
(5, 12, NULL, 12.99, TIMESTAMP '2026-10-06 08:05:00');
INSERT INTO "CUSTOMERS" VALUES
(10, 'ada@example.com', 'Ada', 'GB', DATE '2026-01-15'),
(11, 'grace@example.com', 'Grace', 'US', DATE '2026-03-02'),
(12, 'linus@example.com', 'Linus', 'FI', DATE '2026-06-20');
INSERT INTO "PRODUCTS" VALUES
(100, 'Duck mug', 'kitchen', 12.50),
(101, 'Rubber duck', 'toys', 4.99),
(102, 'Pond map', 'books', 30.00);
INSERT INTO "ORDER_ITEMS" VALUES
(1, 100, 2, 12.50), (1, 101, 1, 4.99), (2, 102, 2, 30.00),
(3, 101, 5, 4.99), (4, 102, 10, 30.00), (5, 101, 1, 4.99);
INSERT INTO "EVENTS" VALUES
(1000, 10, 'view', TIMESTAMP '2026-10-01 09:00:00', '{"page": "/mugs", "ms": 820}'),
(1001, 10, 'purchase', TIMESTAMP '2026-10-01 09:30:00', '{"order_id": 1, "items": [100, 101]}'),
(1002, 11, 'view', TIMESTAMP '2026-10-04 11:00:00', '{"page": "/ducks", "ms": 310}'),
(1003, 12, 'purchase', TIMESTAMP '2026-10-05 16:45:00', '{"order_id": 4, "items": [102]}');`;

export const EXAMPLES = {
valid: `SELECT customer_id,
Expand Down
94 changes: 93 additions & 1 deletion website/public/playground/playground.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ function showCaption(example) {
}

let latest = null;
let latestRaises = {};
// The editor's current text and underlines, so a run's error can add its own.
let shown = { chars: [], spans: [] };
let current = 0;

async function run() {
Expand Down Expand Up @@ -193,6 +196,7 @@ async function run() {
if (translate.status !== 'skipped') {
// Translated SQL keeps its line layout and scrolls sideways; diagnostics still wrap.
body = (translate.statements ?? []).map((s) => `<pre class="terminal sql">${escape(s.sql)}</pre>`).join('');
body = unknownRelationHints(translate.diagnostics) + body + conversionNotes(translate);
body += (translate.diagnostics ?? []).map((d) => terminal(tone(rendered(d, chars)))).join('');
}
const policy = translate.profile ? `policy: ${translate.profile}` : '';
Expand All @@ -203,17 +207,82 @@ async function run() {
setStage('translate', 'refused', body, policy, 'not translated');
} else setStage('translate', translate.status, body, policy);
underline(chars, spans);
shown = { chars, spans };

const statements = translate.statements ?? [];
if (translate.status === 'pass' && statements.length === 1) {
latest = statements[0].sql;
latestRaises = statements[0].raises ?? {};
if (duckdb) execute();
else showDuckDBLoading();
} else if (translate.status === 'pass') {
setStage('run', 'skipped', terminal('<span class="dim">Runs one statement at a time.</span>'));
} else setStage('run', 'skipped', '', refused ? 'nothing was translated' : '');
}

// What the translation relies on that a reader should know, as `sqlquack convert` prints it:
// each known divergence the policy allows, and each assumption, which gives a wrong value
// silently where it does not hold.
function conversionNotes(translate) {
const notes = [];
for (const statement of translate.statements ?? [])
for (const d of statement.divergences ?? []) {
const at = d.constructs.map((construct) => `<code>${escape(construct)}</code>`).join(', ');
const kind = d.assumption ? 'assumption' : 'known divergence';
notes.push(`Relies on ${kind} <code>${escape(d.id)}</code> at ${at}${d.reason ? `: ${escape(d.reason)}` : ''}`);
}
return [...new Set(notes)].map((note) => `<p class="pg-conversion-note"><strong>Note.</strong> ${note}</p>`).join('');
}

// A relation the schema does not declare: SQLQuack needs its columns and Snowflake types, and
// does not guess them. Each one gets a line saying so, with a button that adds a stub to fill in.
const UNKNOWN_RELATION = /^relation `(.+)` has no known columns$/;
const STUB_COLUMN = ['COLUMN_NAME', 'SNOWFLAKE_TYPE'];

function declaredRelations() {
try {
return Object.keys(JSON.parse(schemaBox.value));
} catch {
return [];
}
}

function unknownRelationHints(diagnostics) {
const relations = new Set();
for (const d of diagnostics ?? []) {
const match = d.code === 'SQ0004' && UNKNOWN_RELATION.exec(d.label ?? '');
if (match) relations.add(match[1]);
}
const declared = declaredRelations();
return [...relations]
.map((name) => {
const code = `<code>${escape(name)}</code>`;
return declared.includes(name)
? `<p class="pg-hint"><span>SQLQuack needs each table's columns and Snowflake types. Fill in ${code}'s in the Schema box.</span></p>`
: `<p class="pg-hint"><span>SQLQuack needs each table's columns and Snowflake types. Add ${code} to the Schema box.</span>` +
`<button class="button" type="button" data-add-relation="${escape(name)}">Add ${escape(name)} to schema</button></p>`;
})
.join('');
}

// Adds `name` with one placeholder column to the end of the schema's JSON, keeping the rest of the
// text as typed, and selects the placeholder so typing replaces it.
function addRelation(name) {
const text = schemaBox.value;
const close = text.lastIndexOf('}');
if (close < 0 || declaredRelations().includes(name)) return;
const before = text.slice(0, close).trimEnd();
const column = `[${STUB_COLUMN.map((part) => JSON.stringify(part)).join(', ')}]`;
const entry = ` ${JSON.stringify(name)}: [\n ${column}\n ]`;
const separator = before.endsWith('{') ? '\n' : ',\n';
schemaBox.value = `${before}${separator}${entry}\n${text.slice(close)}`;
schemaBox.closest('details').open = true;
const placeholder = `${before}${separator} ${JSON.stringify(name)}: [\n ["`.length;
schemaBox.focus();
schemaBox.setSelectionRange(placeholder, placeholder + STUB_COLUMN[0].length);
schedule();
}

// DuckDB-WASM, downloaded in the background once the page is idle (or on the first run).
let duckdb = null;
let duckdbLoad = null;
Expand Down Expand Up @@ -316,8 +385,30 @@ async function execute() {
await conn.close();
}
} catch (error) {
const message = String(error.message ?? error);
// An error a building block raised as Snowflake would: shown as Snowflake's, where it comes from.
const failure = JSON.parse((await engine('failure', { message })).result);
if (ticket !== running) return;
setStage('run', 'fail', terminal(`<span class="bad">DuckDB:</span> ${escape(String(error.message ?? error))}`), duckdb ? `DuckDB ${duckdb.version}` : '');
const version = duckdb ? `DuckDB ${duckdb.version}` : '';
if (!failure.errno) {
setStage('run', 'fail', terminal(`<span class="bad">DuckDB:</span> ${escape(message)}`), version);
return;
}
const { chars, spans } = shown;
const raised = (latestRaises[failure.raise_id] ?? []).map((location) => {
const diagnostic = { code: String(failure.errno), message: failure.message, span: { start: location.start, end: location.end }, label: 'raised here' };
return { kind: 'error', span: diagnostic.span, text: rendered(diagnostic, chars) };
});
if (raised.length) underline(chars, [...spans, ...raised]);
const head = `<span class="bad">error ${escape(String(failure.errno))} (${escape(failure.sqlstate)})</span>: ${escape(failure.message)}`;
setStage(
'run',
'fail',
terminal(raised[0]?.text ?? head) +
'<p class="pg-conversion-note">Snowflake raises this error for these rows, and so does the translated SQL in DuckDB.</p>',
version,
'error',
);
}
}

Expand All @@ -329,6 +420,7 @@ const schedule = () => {

document.addEventListener('click', (event) => {
if (event.target.id === 'pg-run') execute();
if (event.target.dataset.addRelation) addRelation(event.target.dataset.addRelation);
});
sqlBox.addEventListener('input', () => {
showCaption(null);
Expand Down
5 changes: 3 additions & 2 deletions website/public/playground/worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ self.onmessage = async ({ data }) => {
ready.then(() => self.postMessage({ ready: true }));
return;
}
const { analyze, fixture_tables } = await ready;
const { analyze, fixture_tables, run_failure } = await ready;
const started = performance.now();
const result = data.op === 'fixture' ? fixture_tables(data.schema) : analyze(data.sql, data.schema, data.settings);
const result =
data.op === 'fixture' ? fixture_tables(data.schema) : data.op === 'failure' ? run_failure(data.message) : analyze(data.sql, data.schema, data.settings);
self.postMessage({ id: data.id, result, elapsed: performance.now() - started });
};
27 changes: 27 additions & 0 deletions website/public/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1359,6 +1359,33 @@ footer a {
display: none;
}

.pg-hint code {
display: inline;
}

.pg-conversion-note {
margin: 10px 0 0;
padding: 8px 10px;
border-left: 2px solid var(--primary);
background: var(--accent-soft);
color: var(--muted-foreground);
font-size: 13px;
}

.pg-conversion-note code {
display: inline;
}

.pg-hint {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
margin: 0 0 10px;
color: var(--foreground);
font-size: 13px;
}

.terminal .warn {
color: var(--warning);
}
Expand Down
55 changes: 54 additions & 1 deletion website/tests/playground.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';

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

initSync({ module: readFileSync(new URL('../wasm/pkg/sqlquack_wasm_bg.wasm', import.meta.url)) });
Expand Down Expand Up @@ -117,3 +117,56 @@ test('translated SQL is laid out one clause per line unless the compact setting
assert.equal(compact, laidOut.replace(/\n */g, ' '));
assert.ok(!compact.includes('\n'));
});

test('an undeclared relation is refused by name, and the stub the page adds stays refused until filled in', async () => {
const { SCHEMA: PAGE_SCHEMA } = await import('../public/playground/examples.js');
// The page reads the relation's name from this label to offer "Add T to schema".
const unknown = JSON.parse(analyze('SELECT * FROM t', PAGE_SCHEMA)).translate;
assert.equal(unknown.status, 'fail');
assert.deepEqual(
unknown.diagnostics.map((d) => [d.code, d.label]),
[['SQ0004', 'relation `T` has no known columns']],
);
// The stub's placeholder type is not a Snowflake type: no column is assumed, so it still refuses.
const stub = JSON.stringify({ ...JSON.parse(PAGE_SCHEMA), T: [['COLUMN_NAME', 'SNOWFLAKE_TYPE']] });
assert.deepEqual(JSON.parse(analyze('SELECT * FROM t', stub)).translate.diagnostics.map((d) => d.code), ['SQ0004']);
// Every relation of the sample schema without semi-structured columns translates as declared.
for (const relation of ['ORDERS', 'CUSTOMERS', 'PRODUCTS', 'ORDER_ITEMS']) {
assert.equal(JSON.parse(analyze(`SELECT * FROM ${relation.toLowerCase()}`, PAGE_SCHEMA)).translate.status, 'pass', relation);
}
});

test('each known divergence a translation relies on is reported with its constructs and the policy reason', () => {
const repository = readFileSync(new URL('../../sqlquack.toml', import.meta.url), 'utf8');
const policy = repository.replace('constructs = ["fn:HASH"]\ndefault = "refuse"', 'constructs = ["fn:HASH"]\ndefault = "warn"');
assert.notEqual(policy, repository);
const translate = (settings) => JSON.parse(analyze('SELECT HASH(order_id) AS h FROM orders', SCHEMA, JSON.stringify(settings))).translate;
const [statement] = translate({ policy }).statements;
assert.deepEqual(
statement.divergences.map((d) => [d.id, d.constructs]),
[['hash', ['fn:HASH']]],
);
assert.match(statement.divergences[0].reason, /never Snowflake's value/);
assert.equal(statement.divergences[0].assumption, false);
// An assumption gives a wrong value silently where it does not hold, so it is reported as one.
const today = JSON.parse(analyze('SELECT CURRENT_DATE AS d', SCHEMA, JSON.stringify({ policy: repository, utc_session: true })));
const assumed = today.translate.statements[0].divergences.find((d) => d.id === 'session_timezone_utc');
assert.equal(assumed?.assumption, true);
assert.ok(assumed.reason);
// Without a divergence, none is reported.
assert.deepEqual(JSON.parse(analyze('SELECT order_id FROM orders', SCHEMA)).translate.statements[0].divergences, []);
});

test('a run error a building block raised reads back as the Snowflake error, at the expression that raised it', () => {
const sql = 'SELECT CAST(note AS NUMBER(10,2)) AS v FROM orders';
const [statement] = JSON.parse(analyze(sql, SCHEMA)).translate.statements;
const tag = /sqlquack:100038#[0-9a-f]{12}: [^']*/.exec(statement.sql)[0];
// As DuckDB reports a failed cast of the raised text.
const failure = JSON.parse(run_failure(`Conversion Error: Could not convert string '${tag}' to INT32\n\nLINE 1: ...`));
assert.equal(failure.errno, 100038);
assert.equal(failure.message, 'Numeric value is not recognized');
const [location] = statement.raises[failure.raise_id];
assert.equal(Array.from(sql).slice(location.start, location.end).join(''), 'CAST(note AS NUMBER(10,2))');
// DuckDB's own errors are not read as Snowflake's.
assert.deepEqual(JSON.parse(run_failure('Binder Error: Referenced column "x" not found')), {});
});
44 changes: 44 additions & 0 deletions website/wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ use sqlquack_core::classification::main::classify::classify;
use sqlquack_core::diagnostics::constants::SESSION_PARAMETERS;
use sqlquack_core::diagnostics::main::diagnose::diagnose;
use sqlquack_core::diagnostics::main::unplaced_diagnostic::unplaced_diagnostic;
use sqlquack_core::failures::constants::{ERROR_TAG_PREFIX, TAG_END};
use sqlquack_core::failures::main::duckdb_failure::duckdb_failure;
use sqlquack_core::failures::main::raise_id::raise_id;
use sqlquack_core::parsing::main::node_location::node_location;
use sqlquack_core::parsing::main::parse_source::parse_source;
use sqlquack_core::parsing::models::{SourceDialect, SourceLines};
Expand Down Expand Up @@ -50,6 +53,30 @@ pub fn analyze(sql: &str, schema: &str, settings: Option<String>) -> String {
report(sql, schema, settings.as_deref().unwrap_or_default()).to_string()
}

/// A DuckDB error from running converted SQL, read back as the Snowflake error a building block
/// raised, as JSON: `{"errno", "sqlstate", "raise_id", "message"}`, or `{}` when it is DuckDB's own.
#[wasm_bindgen]
pub fn run_failure(message: &str) -> String {
let Some(code) = duckdb_failure(message).and_then(|failure| failure.snowflake) else {
return json!({}).to_string();
};
// The tagged text, `sqlquack:<errno>#<id>: <message>`, ends at the quote DuckDB wraps it in.
let tagged = message
.split_once(ERROR_TAG_PREFIX)
.map_or("", |(before, after)| match before.ends_with('\'') {
true => after.split('\'').next().unwrap_or(after),
false => after.lines().next().unwrap_or(after),
});
let text = tagged.split_once(TAG_END).map_or(tagged, |(_, text)| text);
json!({
"errno": code.errno,
"sqlstate": code.sqlstate,
"raise_id": raise_id(message),
"message": text,
})
.to_string()
}

/// `CREATE TABLE` for each relation in `schema`, in the DuckDB types converted SQL reads them as,
/// as JSON: `{"tables": [...]}`, or `{"error": ...}`.
#[wasm_bindgen]
Expand Down Expand Up @@ -322,6 +349,23 @@ fn report(sql: &str, schema: &str, settings: &str) -> Value {
"name": column.name,
"type": column.data_type.to_string(),
})).collect::<Vec<_>>(),
// Each known divergence the policy allows and the conversion relies on, as
// `sqlquack convert` names it on standard error, with its reason. An id such as
// `declared_offset:REL.COL` is allowed under the part before the colon, as the
// command line's `assumption_id` reads it.
"divergences": done.provenance.divergences.iter().map(|(id, constructs)| {
let allowed = options
.divergences
.get(id.split_once(':').map_or(id.as_str(), |(base, _)| base));
json!({
"id": id,
"constructs": constructs,
"reason": allowed.map(|allowed| allowed.reason.as_str()),
"assumption": allowed.is_some_and(|allowed| allowed.assumption),
})
}).collect::<Vec<_>>(),
// Where each raise ID in the SQL comes from, so a run's error points at the source.
"raises": done.raises,
})),
Err(refusal) => refusals.push(placed(refusal, statement, &lines)),
}
Expand Down
Loading