From 17beff1778db9abe3d06c17b5581574df26c4bda Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 23:34:35 +0000 Subject: [PATCH] Show repeated result names as the statement returns them, and whole raised messages The Run stage reads each column's values by position and labels the header with DESCRIBE's names, so `SELECT order_id, order_id` shows two ORDER_ID columns with their own values instead of the wrapper's ORDER_ID_1, or one column when rows were read as objects. run_failure ends a raised message at the last quote on its line, the one DuckDB closes the tagged text with, so messages holding a quote, such as "Can't parse the text as date with the format", read back whole. --- website/public/playground/playground.js | 16 ++++++++------- website/public/playground/spans.js | 11 +++++++++- website/tests/playground.test.mjs | 27 ++++++++++++++++++++++++- website/wasm/src/lib.rs | 13 ++++++++---- 4 files changed, 54 insertions(+), 13 deletions(-) diff --git a/website/public/playground/playground.js b/website/public/playground/playground.js index 54ff12f91..d78ceb4d0 100644 --- a/website/public/playground/playground.js +++ b/website/public/playground/playground.js @@ -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`); @@ -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 = `
${names.map((n) => ``).join('')}${rows - .map((row) => `${names.map((n) => ``).join('')}`) - .join('')}
${escape(n)}
${row[n] === null ? 'NULL' : escape(String(row[n]))}
`; - 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(); } diff --git a/website/public/playground/spans.js b/website/public/playground/spans.js index 46f240576..d0002d0fa 100644 --- a/website/public/playground/spans.js +++ b/website/public/playground/spans.js @@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]); @@ -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 ? 'NULL' : escape(String(value))); + const body = Array.from({ length: rows }, (_, row) => `${columns.map((column) => `${cell(column[row])}`).join('')}`); + const head = names.map((name) => `${escape(name)}`).join(''); + return { html: `
${head}${body.join('')}
`, rows }; +} diff --git a/website/tests/playground.test.mjs b/website/tests/playground.test.mjs index 890f93e61..1f4008ae8 100644 --- a/website/tests/playground.test.mjs +++ b/website/tests/playground.test.mjs @@ -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)) }); @@ -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>/g)].map((match) => match[1]), ['ORDER_ID', 'ORDER_ID']); + const cells = [...html.matchAll(/(.*?)<\/tr>/g)].map((row) => [...row[1].matchAll(/(.*?)<\/td>/g)].map((cell) => cell[1])); + assert.deepEqual(cells, [['1', '1'], ['2', 'NULL']]); +}); diff --git a/website/wasm/src/lib.rs b/website/wasm/src/lib.rs index 2484c1bd6..f542d3b0d 100644 --- a/website/wasm/src/lib.rs +++ b/website/wasm/src/lib.rs @@ -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:#: `, ends at the quote DuckDB wraps it in. + // The tagged text, `sqlquack:#: `, runs to the end of its line, or to the + // closing quote DuckDB wraps it in (`... string '' 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!({