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
16 changes: 9 additions & 7 deletions website/public/playground/playground.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,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 { escape, rendered, underlined } from './spans.js';
import { escape, rendered, resultTable, underlined } from './spans.js';

const here = (path) => new URL(path, import.meta.url).href;
const ENGINE_WASM = here(`${ENGINE_DIR}sqlquack_wasm_bg.wasm`);
Expand Down Expand Up @@ -374,13 +374,15 @@ async function execute() {
// Every value as DuckDB's own text, so decimals and timestamps show exactly.
const result = await conn.query(`SELECT COLUMNS(*)::VARCHAR FROM (${sql}) AS result`);
const elapsed = performance.now() - started;
// The wrapper renames a repeated output name (ORDER_ID_1); the statement's own names come
// from DESCRIBE, which keeps them as DuckDB and Snowflake return them.
const fields = result.schema.fields.map((field) => field.name);
const described = await conn.query(`DESCRIBE ${sql}`).catch(() => null);
const own = described ? Array.from(described.getChild('column_name').toArray(), String) : [];
if (ticket !== running) return;
const names = result.schema.fields.map((field) => field.name);
const rows = result.toArray().map((row) => row.toJSON());
const table = `<div class="pg-rows"><table><thead><tr>${names.map((n) => `<th>${escape(n)}</th>`).join('')}</tr></thead><tbody>${rows
.map((row) => `<tr>${names.map((n) => `<td>${row[n] === null ? '<span class="dim">NULL</span>' : escape(String(row[n]))}</td>`).join('')}</tr>`)
.join('')}</tbody></table></div>`;
setStage('run', 'pass', table, `${rows.length} row${rows.length === 1 ? '' : 's'} · ${elapsed.toFixed(0)} ms · DuckDB ${duckdb.version}`);
const columns = fields.map((_, index) => Array.from(result.getChildAt(index).toArray()));
const { html: table, rows } = resultTable(own.length === fields.length ? own : fields, columns);
setStage('run', 'pass', table, `${rows} row${rows === 1 ? '' : 's'} · ${elapsed.toFixed(0)} ms · DuckDB ${duckdb.version}`);
} finally {
await conn.close();
}
Expand Down
11 changes: 10 additions & 1 deletion website/public/playground/spans.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Diagnostic spans in the playground. The engine counts Unicode characters (code points), and
// Diagnostic spans and result tables in the playground. The engine counts Unicode characters (code points), and
// JavaScript strings count UTF-16 units, so every span is applied to `Array.from(sql)`.

export const escape = (text) => text.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
Expand Down Expand Up @@ -60,3 +60,12 @@ export function underlined(chars, items) {
return { html, items: sorted };
}

// The Run stage's table: `names` as the header and `columns` (one array of text or null per name)
// read by position, so two columns of the same name keep their own values.
export function resultTable(names, columns) {
const rows = columns[0]?.length ?? 0;
const cell = (value) => (value === null ? '<span class="dim">NULL</span>' : escape(String(value)));
const body = Array.from({ length: rows }, (_, row) => `<tr>${columns.map((column) => `<td>${cell(column[row])}</td>`).join('')}</tr>`);
const head = names.map((name) => `<th>${escape(name)}</th>`).join('');
return { html: `<div class="pg-rows"><table><thead><tr>${head}</tr></thead><tbody>${body.join('')}</tbody></table></div>`, rows };
}
27 changes: 26 additions & 1 deletion website/tests/playground.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { readFileSync } from 'node:fs';
import { test } from 'node:test';

import { initSync, analyze, run_failure } from '../wasm/pkg/sqlquack_wasm.js';
import { rendered, underlined } from '../public/playground/spans.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 @@ -170,3 +170,28 @@ test('a run error a building block raised reads back as the Snowflake error, at
// DuckDB's own errors are not read as Snowflake's.
assert.deepEqual(JSON.parse(run_failure('Binder Error: Referenced column "x" not found')), {});
});

test('a raised message holding a quote reads back whole, as DuckDB quotes it unescaped', () => {
const sql = "SELECT TO_DATE(note, 'YYYY-MM-DD') AS d FROM orders";
// The repository's policy allows the date-text divergence the built-in one refuses.
const policy = readFileSync(new URL('../../sqlquack.toml', import.meta.url), 'utf8');
const [statement] = JSON.parse(analyze(sql, SCHEMA, JSON.stringify({ policy }))).translate.statements;
const [, id] = /sqlquack:100097#([0-9a-f]{12}): /.exec(statement.sql);
const text = "Can't parse the text as date with the format";
// As DuckDB 2.0.0 reports the failed cast of the raised text.
const failure = JSON.parse(run_failure(`Conversion Error: Could not convert string 'sqlquack:100097#${id}: ${text}' to INT32\n\nLINE 1: ...`));
assert.equal(failure.errno, 100097);
assert.equal(failure.message, text);
assert.equal(failure.raise_id, id);
const [location] = statement.raises[id];
assert.equal(Array.from(sql).slice(location.start, location.end).join(''), "TO_DATE(note, 'YYYY-MM-DD')");
});

test('a result with a repeated column name shows each column under its own name, with its own values', () => {
// DESCRIBE's names for `SELECT order_id, order_id`, and each column's values by position.
const { html, rows } = resultTable(['ORDER_ID', 'ORDER_ID'], [['1', '2'], ['1', null]]);
assert.equal(rows, 2);
assert.deepEqual([...html.matchAll(/<th>(.*?)<\/th>/g)].map((match) => match[1]), ['ORDER_ID', 'ORDER_ID']);
const cells = [...html.matchAll(/<tr>(<td>.*?)<\/tr>/g)].map((row) => [...row[1].matchAll(/<td>(.*?)<\/td>/g)].map((cell) => cell[1]));
assert.deepEqual(cells, [['1', '1'], ['2', '<span class="dim">NULL</span>']]);
});
13 changes: 9 additions & 4 deletions website/wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,17 @@ 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.
// The tagged text, `sqlquack:<errno>#<id>: <message>`, runs to the end of its line, or to the
// closing quote DuckDB wraps it in (`... string '<tagged>' to INT32`). The message itself can
// hold quotes, as in "Can't parse ...", so the closing quote is the line's last one.
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),
.map_or("", |(before, after)| {
let line = after.lines().next().unwrap_or(after);
match before.ends_with('\'') {
true => line.rsplit_once('\'').map_or(line, |(text, _)| text),
false => line,
}
});
let text = tagged.split_once(TAG_END).map_or(tagged, |(_, text)| text);
json!({
Expand Down
Loading