Skip to content

Commit b84f179

Browse files
committed
feat(playground): explain each refused known difference once
A refused known difference is now worded from its plain-English entry, as `sqlquack convert` words it: SQ0008 gives its title, what differs, when relying on it is fine and when it is wrong, and the one fix that works under the page's policy and mode. The banner gives the same title. The example caption that repeated the HASH explanation beside the editor is gone, and conversion notes say "known difference".
1 parent 7c05ff5 commit b84f179

6 files changed

Lines changed: 57 additions & 48 deletions

File tree

‎website/public/playground/examples.js‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -144,9 +144,3 @@ export const STOP_LABELS = {
144144
run: 'stops at Run',
145145
};
146146
export const stopLabel = (stops) => (stops ? STOP_LABELS[stops] : 'all four pass');
147-
148-
// Shown under the editor while an example is loaded and unedited.
149-
export const CAPTIONS = {
150-
refused:
151-
'Valid Snowflake SQL that SQLQuack will not translate. DuckDB computes HASH differently, so translated SQL would run and return other values. SQLQuack refuses and says why rather than guess; a policy file can allow a known divergence explicitly.',
152-
};

‎website/public/playground/index.html‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,6 @@ <h1>Playground</h1>
7575
<textarea id="pg-sql" spellcheck="false" autocomplete="off" autocapitalize="off"></textarea>
7676
</div>
7777
<p class="pg-note">Hover an underline for its diagnostic.</p>
78-
<p class="pg-note pg-caption" id="pg-caption" hidden></p>
7978
<details open>
8079
<summary>Sources: the tables your SQL reads</summary>
8180
<textarea class="pg-small" id="pg-sources" spellcheck="false" aria-label="Source file (YAML)"></textarea>
@@ -88,7 +87,7 @@ <h1>Playground</h1>
8887
</details>
8988
<details>
9089
<summary>Policy and session</summary>
91-
<textarea class="pg-small" id="pg-policy" spellcheck="false" aria-label="Policy file (TOML)" placeholder="Empty: the built-in default, which refuses every known divergence but the clock and checks that only raise"></textarea>
90+
<textarea class="pg-small" id="pg-policy" spellcheck="false" aria-label="Policy file (TOML)" placeholder="Empty: the built-in default, which refuses every known difference but the clock and checks that only raise"></textarea>
9291
<p class="pg-note">A policy file, as for <code>--policy</code>. <button class="pg-link" type="button" id="pg-load-policy">Load the repository's sqlquack.toml</button></p>
9392
<textarea class="pg-small pg-short" id="pg-session" spellcheck="false" aria-label="Session parameters" placeholder="TIMEZONE=UTC"></textarea>
9493
<p class="pg-note">Snowflake session parameters, one <code>NAME=VALUE</code> per line, as for <code>--session</code>.</p>

‎website/public/playground/playground.js‎

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import { DUCKDB_DIR, DUCKDB_EXTENSIONS, DUCKDB_WASM_BYTES } from './duckdb-version.js';
66
import { ENGINE_DIR, ENGINE_WASM_BYTES } from './engine-version.js';
7-
import { CAPTIONS, EXAMPLE_GROUPS, EXAMPLES, ROWS, SOURCES, stopLabel } from './examples.js';
7+
import { EXAMPLE_GROUPS, EXAMPLES, ROWS, SOURCES, stopLabel } from './examples.js';
88
import { escape, previewLimit, rendered, resultTable, underlined } from './spans.js';
99

1010
const here = (path) => new URL(path, import.meta.url).href;
@@ -100,7 +100,6 @@ const strictBox = $('#pg-strict');
100100
const utcBox = $('#pg-utc');
101101
const settings = () =>
102102
JSON.stringify({ policy: policyBox.value, session: sessionBox.value, strict: strictBox.checked, utc_session: utcBox.checked });
103-
const caption = $('#pg-caption');
104103
const verdict = $('#pg-verdict');
105104
const marks = $('#pg-marks');
106105
const tip = $('#pg-tip');
@@ -125,11 +124,6 @@ function underline(chars, items) {
125124
marks.scrollTop = sqlBox.scrollTop;
126125
}
127126

128-
function showCaption(example) {
129-
caption.textContent = CAPTIONS[example] ?? '';
130-
caption.hidden = !CAPTIONS[example];
131-
}
132-
133127
let latest = null;
134128
// The helper macros the translated statement calls, created before it runs.
135129
let latestSetup = [];
@@ -218,7 +212,7 @@ async function run() {
218212
const policy = translate.profile ? `policy: ${translate.profile}` : '';
219213
if (refused) {
220214
const reason = translate.diagnostics?.[0]?.message ?? 'no supported conversion';
221-
verdict.innerHTML = `<strong>Valid Snowflake SQL.</strong> Not translated to DuckDB: ${escape(reason)}`;
215+
verdict.innerHTML = `<strong>Valid Snowflake SQL, not translated:</strong> ${escape(reason)}`;
222216
verdict.hidden = false;
223217
setStage('translate', 'refused', body, policy, 'not translated');
224218
} else setStage('translate', translate.status, body, policy);
@@ -238,14 +232,14 @@ async function run() {
238232
}
239233

240234
// What the translation relies on that a reader should know, as `sqlquack convert` prints it:
241-
// each known divergence the policy allows, and each assumption, which gives a wrong value
235+
// each known difference the policy allows, and each assumption, which gives a wrong value
242236
// silently where it does not hold.
243237
function conversionNotes(translate) {
244238
const notes = [];
245239
for (const statement of translate.statements ?? [])
246240
for (const d of statement.divergences ?? []) {
247241
const at = d.constructs.map((construct) => `<code>${escape(construct)}</code>`).join(', ');
248-
const kind = d.assumption ? 'assumption' : 'known divergence';
242+
const kind = d.assumption ? 'assumption' : 'known difference';
249243
notes.push(`Relies on ${kind} <code>${escape(d.id)}</code> at ${at}${d.reason ? `: ${escape(d.reason)}` : ''}`);
250244
}
251245
return [...new Set(notes)].map((note) => `<p class="pg-conversion-note"><strong>Note.</strong> ${note}</p>`).join('');
@@ -471,7 +465,6 @@ document.addEventListener('click', (event) => {
471465
if (event.target.dataset.addRelation) addRelation(event.target.dataset.addRelation);
472466
});
473467
sqlBox.addEventListener('input', () => {
474-
showCaption(null);
475468
// Edited SQL is no longer the example, so neither the chip nor the link names it any more.
476469
if (activeExample) {
477470
setActiveExample(null);
@@ -516,7 +509,6 @@ function setActiveExample(key) {
516509

517510
function loadExample(key) {
518511
sqlBox.value = EXAMPLES[key];
519-
showCaption(key);
520512
setActiveExample(key);
521513
}
522514

‎website/public/styles.css‎

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1328,18 +1328,6 @@ footer a {
13281328
font-size: 12px;
13291329
}
13301330

1331-
.pg-caption {
1332-
padding: 8px 10px;
1333-
border-left: 2px solid var(--primary);
1334-
background: var(--accent-soft);
1335-
color: var(--muted-foreground);
1336-
font-size: 13px;
1337-
}
1338-
1339-
.pg-caption[hidden] {
1340-
display: none;
1341-
}
1342-
13431331
.pg-note label {
13441332
margin-right: 16px;
13451333
cursor: pointer;

‎website/tests/playground.test.mjs‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ test('the conversion budget is timed in the browser and refuses with SQ0006 when
108108
});
109109

110110
test('every example chip stops at the stage its label names, the Run stage included', async () => {
111-
const { EXAMPLE_GROUPS, EXAMPLES, CAPTIONS, SOURCES: PAGE_SOURCES } = await import('../public/playground/examples.js');
111+
const { EXAMPLE_GROUPS, EXAMPLES, SOURCES: PAGE_SOURCES } = await import('../public/playground/examples.js');
112112
const chips = EXAMPLE_GROUPS.flatMap((group) => group.examples);
113113
assert.deepEqual(chips.map((chip) => chip.key).sort(), Object.keys(EXAMPLES).sort());
114114
assert.equal(new Set(chips.map((chip) => chip.slug)).size, chips.length);
@@ -126,10 +126,26 @@ test('every example chip stops at the stage its label names, the Run stage inclu
126126
}
127127
};
128128
for (const chip of chips) assert.equal(await stops(EXAMPLES[chip.key]), chip.stops, chip.name);
129-
// The one deliberate refusal is a known divergence, explained by its caption.
129+
// The one deliberate refusal is a known difference, explained once by its SQ0008.
130130
const refused = JSON.parse(analyze(EXAMPLES.refused, PAGE_SOURCES)).translate;
131131
assert.deepEqual(refused.diagnostics.map((d) => d.code), ['SQ0008']);
132-
assert.ok(CAPTIONS.refused);
132+
});
133+
134+
test('a refused known difference is worded from its entry, with the one fix that works here, as the CLI words it', () => {
135+
const policy = readFileSync(new URL('../../sqlquack.toml', import.meta.url), 'utf8');
136+
const refused = (settings) => JSON.parse(analyze('SELECT HASH(order_id) AS h FROM orders', SOURCES, JSON.stringify(settings))).translate.diagnostics[0];
137+
const builtin = refused({});
138+
assert.equal(builtin.message, 'HASH gives a different value in DuckDB');
139+
assert.equal(builtin.label, "DuckDB computes its own hash, never Snowflake's");
140+
assert.deepEqual(builtin.notes, [
141+
'Equal inputs still get equal hashes within one run.',
142+
'fine when: Joins, grouping, removing duplicates and spotting changed rows by hash.',
143+
'wrong when: Storing, comparing or sorting by the hash value itself: it differs, with no error.',
144+
]);
145+
assert.match(builtin.help, /^allow it in a sqlquack\.toml with default = "warn" under \[divergences\.hash\]/);
146+
// A policy file that refuses it gets the edit to that file; strict mode the strict setting.
147+
assert.match(refused({ policy }).help, /^allow it with default = "warn" under \[divergences\.hash\] in the policy file/);
148+
assert.match(refused({ policy, strict: true }).help, /^allow it in strict mode with default = "warn" and strict = "warn"/);
133149
});
134150

135151
test('date arithmetic against CURRENT_DATE runs in the in-browser DuckDB, on its first statement', async () => {

‎website/wasm/src/lib.rs‎

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ use sqlquack_blocks::blocks::main::quoted_identifier::quoted_identifier;
1313
use sqlquack_blocks::checking::constants::SEMI_STRUCTURED_HOLDING_TYPE;
1414
use sqlquack_blocks::layout::main::layout_sql::layout_sql;
1515
use sqlquack_blocks::macros::main::helper_statements::helper_statements;
16+
use sqlquack_convert::snowflake::analysis::main::with_divergence_help::with_divergence_help;
17+
use sqlquack_convert::snowflake::analysis::models::ConversionChoices;
1618
use sqlquack_convert::snowflake::catalogue::main::builtin_catalogue::builtin_catalogue;
1719
use sqlquack_convert::snowflake::conversion::main::convert_statement::convert_statement;
1820
use sqlquack_convert::snowflake::conversion::main::quoted_exclusions::quoted_exclusions;
@@ -182,11 +184,10 @@ struct Settings {
182184
compact: bool,
183185
}
184186

185-
/// Conversion options for `settings` as `sqlquack convert` chooses them, and the matched profile;
186-
/// a session no recorded profile covers is a refusal, as on the command line.
187-
fn chosen_options(
187+
/// The policy file and session parameters `settings` give, as `sqlquack convert` reads them.
188+
fn chosen_settings(
188189
settings: &Settings,
189-
) -> Result<Result<(ConversionOptions, String), String>, String> {
190+
) -> Result<(Option<Policy>, BTreeMap<String, String>), String> {
190191
let policy = match settings.policy.trim() {
191192
"" => None,
192193
text => Some(
@@ -201,12 +202,7 @@ fn chosen_options(
201202
.filter(|line| !line.is_empty())
202203
.map(str::to_owned)
203204
.collect();
204-
let parameters = parse_session(&lines)?;
205-
Ok(session_conversion_options(
206-
policy.as_ref(),
207-
(settings.strict, settings.utc_session),
208-
&parameters,
209-
))
205+
Ok((policy, parse_session(&lines)?))
210206
}
211207

212208
/// A stage's verdict and what it found.
@@ -276,10 +272,17 @@ fn stages(sql: &str, catalog: &Catalog, settings: &str) -> Value {
276272
Err(error) => return json!({ "settings_error": error.to_string() }),
277273
},
278274
};
279-
let session = match chosen_options(&settings) {
280-
Ok(session) => session,
275+
let (chosen_policy, parameters) = match chosen_settings(&settings) {
276+
Ok(chosen) => chosen,
281277
Err(error) => return json!({ "settings_error": error }),
282278
};
279+
// Conversion options and the matched profile; a session no recorded profile covers is a
280+
// refusal, as on the command line.
281+
let session = session_conversion_options(
282+
chosen_policy.as_ref(),
283+
(settings.strict, settings.utc_session),
284+
&parameters,
285+
);
283286
let lines = SourceLines::new(sql);
284287

285288
// 1. Parse as Snowflake.
@@ -405,7 +408,24 @@ fn stages(sql: &str, catalog: &Catalog, settings: &str) -> Value {
405408
Err(refusal) => refusals.push(placed(refusal, statement, &lines)),
406409
}
407410
}
408-
let mut translate = Stage::new(refusals.is_empty(), diagnostics(&refusals, sql));
411+
// Each SQ0008 worded from its known difference, with the one fix that works here, as
412+
// `sqlquack convert` words it.
413+
let choices = ConversionChoices {
414+
policy_file: "the policy file".to_owned(),
415+
strict: settings.strict,
416+
utc_session: settings.utc_session,
417+
..ConversionChoices::default()
418+
};
419+
let worded = with_divergence_help(
420+
&refusals,
421+
diagnose(&refusals, sql),
422+
(chosen_policy.as_ref(), &choices, &parameters),
423+
);
424+
let worded = worded
425+
.iter()
426+
.filter_map(|diagnostic| serde_json::to_value(diagnostic).ok())
427+
.collect();
428+
let mut translate = Stage::new(refusals.is_empty(), worded);
409429
translate.extra.insert("statements", json!(converted));
410430
translate.extra.insert("profile", json!(profile));
411431
json!({ "parse": parse, "checks": checks, "translate": translate })

0 commit comments

Comments
 (0)