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) => `| ${escape(n)} | `).join('')}
${rows
- .map((row) => `${names.map((n) => `| ${row[n] === null ? 'NULL' : escape(String(row[n]))} | `).join('')}
`)
- .join('')}
`;
- 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: ``, 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!({
|