diff --git a/docs/ENGINE_REFACTORING.md b/docs/ENGINE_REFACTORING.md index 40bd7e99..caa0b649 100644 --- a/docs/ENGINE_REFACTORING.md +++ b/docs/ENGINE_REFACTORING.md @@ -478,7 +478,8 @@ feature work**, and so we can tell the difference between "this is awkward" and WHERE *operator* now delegates. **Slice 5 (method calls) done 2026-09-26**, closing P56 and P57 and deciding D3. **Slice 4 finished 2026-10-01** (AND / OR / NOT / CASE, one truth rule — closing P61 and P62, deciding D4): WHERE - evaluates nothing itself any more. Slice 6 is next. + evaluates nothing itself any more. **Slice 6 (the last) begun 2026-10-03** + with its pin — no engine change. **The active workstream:** parity fixes that touch expression evaluation land as slices of this entry, not as patches. - **Where:** `src/data/arithmetic_evaluator.rs` (`ArithmeticEvaluator`, value → @@ -791,6 +792,26 @@ feature work**, and so we can tell the difference between "this is awkward" and let the loop and the `connector` field go; `in_operator_lifter` and `subquery_executor` copy conditions one by one and want checking with it. A natural companion to slice 6. +- **Slice 6, pin (2026-10-03).** No engine change; the parity report moved + by exactly the twelve new cases (217 → 229; 186 → 190 AGREE, the four + guards). + - *HAVING and `IIF` as truth-value sites.* The sixth matrix's table gains + two runs: a `Having` column (each row grouped on its own through + `QueryEngine`, `T` kept / `F` dropped / `E` failed) and `IIF(v, true, + false)` through both evaluators, expectations from DuckDB (`if()` for + `IIF`, which DuckDB lacks). **15 divergences**: 7 HAVING, 8 `IIF` (4 × 2 + evaluators). The two copies disagree with D4 *and with each other* — empty + text keeps a group but is FALSE to `IIF`; dates TRUE to HAVING, FALSE to + `IIF` — and both read NaN as FALSE. Corpus: two HAVING cases waiting over + `predicate_text.csv` (P62) and one guard; `IIF` is matrix-only. + - *Joins.* `execute_join` routes a single plain-column equality to the hash + path (INNER, LEFT, RIGHT as a swapped LEFT) and everything else to three + nested-loop builders (`*_multi`). P52 now has a DIFFER case on each of the + six plus the expression form. Probing found **only equality wrong**: `<`, + `<>` and a LEFT join over an inequality already agree — the comparator's + NULL ordering never satisfies them — so those three are AGREE guards for + when the nested loop stops using the bare comparator. + `nested_loop_join_inner` / `nested_loop_join_left` have no callers. - **Slices, in the R10 pattern — no-op slices kept apart from the one that changes answers:** 1. ✅ **Pin the divergences, no engine change.** *(Done 2026-09-13.)* A per-operator matrix run through @@ -822,7 +843,17 @@ feature work**, and so we can tell the difference between "this is awkward" and `Trilean::from_value`) and JOIN's bare comparison go through the same collapse. `WhereClause`'s connector list (above) can go with it. JOIN NULL keys need care on the *hash* path too, not only the nested loop. [R8](#r8) stage 2 (the legacy WHERE stack) is - a natural lull task alongside. + a natural lull task alongside. In parts: + - 6a ✅ pin (2026-10-03, above). + - 6b HAVING and `IIF` onto `Trilean::from_value` — exactly the 15 + matrix entries and the two HAVING corpus cases move. + - 6c P52: the nested loop compares through the predicate layer; the hash + path skips NULL keys on build and probe, LEFT still emitting the + unmatched row. Dead single-condition builders deleted first. Exactly + the seven join cases move. + - 6d `WhereClause`'s connector list → `BinaryOp AND`; the adapter folds + into the row filter and `RecursiveWhereEvaluator` goes. Parity + byte-identical. R13 closes. - **Explicitly not in scope:** window evaluation (`BatchWindowEvaluator`), aggregate registries ([R12](#r12)), correlated scoping ([R7](#r7) / [P3](SQL_PARITY.md#p3), [P49](SQL_PARITY.md#p49)). And [P15](SQL_PARITY.md#p15) @@ -926,7 +957,7 @@ R8 legacy WHERE ──── independent; stage 2 is self-contained, do it in a R10 Trilean ──────── DONE; closed P18/P19 (parity 125 → 129) R11 ORDER BY resolver ─ independent; small, but a behaviour change — wants its own parity run; now slice 3 of R14 R12 aggregate registries ─ independent; step 1 is a provable no-op, do it before the next aggregate fix -R13 one evaluator ─── ACTIVE from 2026-09-13; slices 1 (pin), 2 (3VL), 3 (construction + case mode) DONE; 4 operators DONE (BETWEEN/IN, comparisons, IS NULL, LIKE); 5 (method calls) DONE; 4 tail (AND/OR/NOT/CASE, D4) DONE 2026-10-01 → 6 +R13 one evaluator ─── ACTIVE from 2026-09-13; slices 1 (pin), 2 (3VL), 3 (construction + case mode) DONE; 4 operators DONE (BETWEEN/IN, comparisons, IS NULL, LIKE); 5 (method calls) DONE; 4 tail (AND/OR/NOT/CASE, D4) DONE 2026-10-01 → 6 (6a pin DONE 2026-10-03 → 6b HAVING/IIF → 6c P52 joins → 6d retire WHERE evaluator) R14 one column resolver ─ after R13; slice 1 (pin) any time → 2 (stop flattening ColumnRef; P58 join) → 3 (converge copies, absorbs R11) → 4 (P49/P60 strictness) ──→ feeds P3 scope spine ``` @@ -974,3 +1005,4 @@ AGREE count — which makes it safe to land well before the semantics change. | 2026-09-26 | R13 slice 5: method calls. Pinned 55 divergences across both evaluators; P56 (NULL receiver → FALSE) and P57 (WHERE refused all but five methods on a comparison's left) filed and closed; D3 decided (search methods follow `--case-insensitive`). Registry search functions fixed first (180), then WHERE delegated and ~700 lines of its arms and readers deleted — 179 → **183 AGREE** / 213 | — | | 2026-10-01 | R14 filed from P58 (`alias."quoted col"`): parser fix landed on a branch and worked in every clause but JOIN ON, whose resolver strips at the last dot. Five resolver copies inventoried; P59 (quoted alias) and P60 (GROUP BY unknown column → NULL group) filed | — | | 2026-10-01 | R13 slice 4 finished: AND / OR / NOT / CASE. Pinned 19 divergences (sixth matrix, values as truth values); P61 (WHERE CASE without ELSE → FALSE) and P62 (five truth tables; `'f'` read as TRUE) filed and closed; D4 decided (DuckDB's cast to boolean). `Trilean::from_value` the one rule; WHERE evaluates nothing itself — 183 → **186 AGREE** / 217 | — | +| 2026-10-03 | R13 slice 6 pin: HAVING (new matrix column) and `IIF` as truth-value sites — 15 divergences, the two copies disagreeing with each other and reading NaN as FALSE; P52 cased on all six live join paths plus expressions, only equality wrong, three inequality guards AGREE. No engine change — 217 → 229 cases, 186 → 190 AGREE (the guards) | — | diff --git a/docs/SQL_PARITY.md b/docs/SQL_PARITY.md index c9e25be3..6b99775f 100644 --- a/docs/SQL_PARITY.md +++ b/docs/SQL_PARITY.md @@ -65,10 +65,11 @@ session apiece to fix properly, so **discovery is paused and the effort moves to picking them off**. Widen the corpus again when the open list is short, or opportunistically when a fix needs a case that doesn't exist yet. -Corpus coverage today: tiers 01–10, **217 cases** (186 AGREE / 15 DIFFER / -12 GAP / 1 OURS_ONLY / 3 BOTH_ERR as of 2026-10-01, after R13 slice 4 closed -[P61](#p61) and [P62](#p62); [P51](#p51), [P52](#p52) open, [P53](#p53) open -at low priority). The largest single movement so far remains the 2026-09-05 +Corpus coverage today: tiers 01–10, **229 cases** (190 AGREE / 22 DIFFER / +12 GAP / 2 OURS_ONLY / 3 BOTH_ERR as of 2026-10-03, after R13 slice 6's pin +added P52's join paths and P62's HAVING copy — four guards AGREE, eight cases +await slice 6; [P51](#p51), [P52](#p52) open, [P53](#p53) open at low +priority). The largest single movement so far remains the 2026-09-05 NULL-ordering slice, which closed [P13](#p13) stage 2 and [P17](#p17) together — eleven cases in one change. **Tier 10 (aggregate & NULL edges) is still deliberately partial** — it holds the P14, P18–P20 and P41 cases and their @@ -2438,7 +2439,25 @@ out of date. - **Status:** 🔴 OPEN — silent, too many rows. [R13](ENGINE_REFACTORING.md#r13) slice 6. - **Corpus:** `10_aggregate_nulls.toml :: join_on_null_key_both_sides` (`expect = "DIFFER"`). Its neighbour `join_on_null_key` AGREEs and could not - see this: it has NULL on only one side. + see this: it has NULL on only one side. **Every live join path pinned + 2026-10-03** (R13 slice 6), each `expect = "DIFFER"`: + + | Case | Path | DuckDB | Ours | + |---|---|---|---| + | `join_on_null_key_both_sides` | hash INNER | 7 | 32 | + | `left_join_on_null_key_both_sides` | hash LEFT | 12 | 32 | + | `right_join_on_null_key_both_sides` | hash LEFT, tables swapped | 12 | 32 | + | `join_on_null_keys_two_conditions` | nested-loop INNER (multi) | 5 | 14 | + | `left_join_on_null_keys_two_conditions` | nested-loop LEFT (multi) | 12 | 12, NULL-label rows matched | + | `right_join_on_null_keys_two_conditions` | nested-loop RIGHT (multi) | 12 | 12, likewise | + | `join_on_null_key_expression` | nested loop over expressions | 7 | 32 | + + **Only equality is wrong.** Three guards AGREE already — + `join_on_null_score_less_than`, `join_on_null_team_not_equal`, + `left_join_on_null_score_inequality` — because the comparator's ordering and + `<>` answers with a NULL side are never true; they are there to hold when the + nested loop's comparator changes. `nested_loop_join_inner` and + `nested_loop_join_left` (the single-condition forms) have no callers. - **Observed:** `null_edges a JOIN null_edges b ON a.label = b.label` returns **32** rows; DuckDB **7**. Seven distinct non-NULL labels match themselves; the five NULL-label rows then pair with each other, 5 × 5 = 25. @@ -2676,11 +2695,25 @@ out of date. - **Status:** ✅ FIXED 2026-10-01 by [R13](ENGINE_REFACTORING.md#r13) slice 4 under [D4](#d4): `Trilean::from_value` is the one rule, and three of the five copies below are gone (183 → 185 AGREE). HAVING's `is_truthy` and - `IIF` still carry their own tables — slice 6. + `IIF` still carry their own tables — slice 6, **pinned 2026-10-03**: 7 + HAVING and 8 `IIF` divergences recorded in the matrix, two corpus cases + waiting (below). - **Corpus:** `02_where.toml :: where_text_flag_as_predicate`, `where_not_text_flag` (DIFFER → AGREE), `where_text_not_a_boolean` (OURS_ONLY → `expect = "BOTH_ERR"`), over new `data/predicate_text.csv`. Evaluator matrix: `TEXT_TRUTHY` / `TEXT_REFUSED` / `DATE_TRUTHY` entries. + Slice 6 (the two remaining copies): `07_grouping.toml :: + having_text_flag_as_condition` (`expect = "DIFFER"` — `'f'`, `'N'`, `'no'` + keep their groups), `having_text_not_a_boolean` (`expect = "OURS_ONLY"`, to + become BOTH_ERR) and the guard `having_not_text_flag`. Matrix: + `EXPECTED_TRUTH_HAVING` / `KNOWN_TRUTH_HAVING` (a new `Having` column — each + row a group of its own through `QueryEngine`) and `EXPECTED_TRUTH_IIF` / + `KNOWN_TRUTH_IIF`. **`IIF` has no corpus case:** DuckDB has no `IIF`; its + expectations were generated with `if()`, which takes the same arguments. + The pin also showed the two copies disagree with *each other* — empty text + keeps a HAVING group but is FALSE to `IIF`; a date is TRUE to one and FALSE + to the other — and that both read NaN as FALSE, where D4 reads it as + non-zero. - **Observed:** `WHERE flag` over `yes`, `no`, `true`, `f`, `N` returns every row with a value — `'no'` and `'f'` are TRUE because they are not empty. DuckDB reads boolean text (`yes`/`true` TRUE, `no`/`f`/`N` FALSE) and raises diff --git a/tests/comparison/corpus/07_grouping.toml b/tests/comparison/corpus/07_grouping.toml index 6759283a..3085215a 100644 --- a/tests/comparison/corpus/07_grouping.toml +++ b/tests/comparison/corpus/07_grouping.toml @@ -73,3 +73,30 @@ sql = "SELECT team, COUNT(*) AS n FROM null_edges GROUP BY team HAVING NOT (MIN( # beta, gamma and the NULL team, and drops delta. HAVING runs on the value # evaluator, which answers the comparison false, negates it to TRUE, and admits # delta. The same predicate in WHERE is right - see tests/evaluator_matrix_tests.rs. + +# --- P62's HAVING copy: a value used as the HAVING condition (R13 slice 6) --- + +[[case]] +id = "having_text_flag_as_condition" +data = "predicate_text.csv" +sql = "SELECT flag FROM predicate_text GROUP BY flag HAVING flag ORDER BY flag" +expect = "DIFFER" +# P62, pinned 2026-10-03 for R13 slice 6. Text used as a truth value is read as +# a boolean (D4): DuckDB keeps 'true' and 'yes'. HAVING collapses the value with +# its own is_truthy, which reads any text as TRUE, so 'f', 'N' and 'no' keep +# their groups too. The same predicate in WHERE is right (where_text_flag_as_predicate). + +[[case]] +id = "having_not_text_flag" +data = "predicate_text.csv" +sql = "SELECT flag FROM predicate_text GROUP BY flag HAVING NOT flag ORDER BY flag" +# A guard, not a gap: NOT converts the text itself (Trilean::from_value), so +# is_truthy only ever sees a boolean or NULL here and the answer is right. + +[[case]] +id = "having_text_not_a_boolean" +data = "predicate_text.csv" +sql = "SELECT word FROM predicate_text GROUP BY word HAVING word ORDER BY word" +expect = "OURS_ONLY" +# P62 / D4. Text that does not read as a boolean is an error in DuckDB, and in +# our WHERE; HAVING's is_truthy keeps every non-NULL group instead. diff --git a/tests/comparison/corpus/10_aggregate_nulls.toml b/tests/comparison/corpus/10_aggregate_nulls.toml index 7b2124dc..a7ffb62b 100644 --- a/tests/comparison/corpus/10_aggregate_nulls.toml +++ b/tests/comparison/corpus/10_aggregate_nulls.toml @@ -116,6 +116,78 @@ expect = "DIFFER" # key, so a NULL probe finds the NULL build rows; the nested-loop path uses # compare_with_op, which also reports NULL = NULL as equal. R13 slice 6. +# --- P52 on every live join path (pinned 2026-10-03, R13 slice 6) --- +# The case above is the hash INNER path. A single equality between two plain +# columns takes the hash path for INNER, LEFT and RIGHT; anything else - a +# second condition, an expression, an inequality - takes a nested loop, with +# separate INNER, LEFT and RIGHT builders. Only equality is wrong: the +# inequality cases at the end AGREE and guard the nested loop when its +# comparator changes. + +[[case]] +id = "left_join_on_null_key_both_sides" +data = "null_edges.csv" +sql = "SELECT a.id AS aid, b.id AS bid FROM null_edges a LEFT JOIN null_edges b ON a.label = b.label ORDER BY a.id, b.id" +expect = "DIFFER" +# P52, hash LEFT path. DuckDB 12 rows: 7 matches plus the 5 NULL-label rows +# unmatched, b NULL. We return 32 - the NULL labels pair 5 x 5. + +[[case]] +id = "right_join_on_null_key_both_sides" +data = "null_edges.csv" +sql = "SELECT a.id AS aid, b.id AS bid FROM null_edges a RIGHT JOIN null_edges b ON a.label = b.label ORDER BY b.id, a.id" +expect = "DIFFER" +# P52, hash path via the swapped LEFT join. DuckDB 12 rows, we return 32. + +[[case]] +id = "join_on_null_keys_two_conditions" +data = "null_edges.csv" +sql = "SELECT a.id AS aid, b.id AS bid FROM null_edges a JOIN null_edges b ON a.label = b.label AND a.team = b.team ORDER BY a.id, b.id" +expect = "DIFFER" +# P52, nested-loop INNER path (two conditions). DuckDB 5 rows, we return 14. + +[[case]] +id = "left_join_on_null_keys_two_conditions" +data = "null_edges.csv" +sql = "SELECT a.id AS aid, b.id AS bid FROM null_edges a LEFT JOIN null_edges b ON a.label = b.label AND a.id = b.id ORDER BY a.id, b.id" +expect = "DIFFER" +# P52, nested-loop LEFT path. 12 rows both ways, but for the NULL-label rows +# DuckDB leaves b NULL and we fill it with the row itself. + +[[case]] +id = "right_join_on_null_keys_two_conditions" +data = "null_edges.csv" +sql = "SELECT a.id AS aid, b.id AS bid FROM null_edges a RIGHT JOIN null_edges b ON a.label = b.label AND a.id = b.id ORDER BY b.id, a.id" +expect = "DIFFER" +# P52, nested-loop RIGHT builder; the mirror of the LEFT case. + +[[case]] +id = "join_on_null_key_expression" +data = "null_edges.csv" +sql = "SELECT a.id AS aid, b.id AS bid FROM null_edges a JOIN null_edges b ON UPPER(a.label) = UPPER(b.label) ORDER BY a.id, b.id" +expect = "DIFFER" +# P52, nested loop over evaluated expressions. UPPER(NULL) is NULL, which then +# meets the same comparator. DuckDB 7 rows, we return 32. + +[[case]] +id = "join_on_null_score_less_than" +data = "null_edges.csv" +sql = "SELECT a.id AS aid, b.id AS bid FROM null_edges a JOIN null_edges b ON a.score < b.score ORDER BY a.id, b.id" +# Guard: an ordering comparison with a NULL side is already never true. + +[[case]] +id = "join_on_null_team_not_equal" +data = "null_edges.csv" +sql = "SELECT a.id AS aid, b.id AS bid FROM null_edges a JOIN null_edges b ON a.team <> b.team ORDER BY a.id, b.id" +# Guard: NULL <> x is already never true, so the NULL-team rows join nothing. + +[[case]] +id = "left_join_on_null_score_inequality" +data = "null_edges.csv" +sql = "SELECT a.id AS aid, b.id AS bid FROM null_edges a LEFT JOIN null_edges b ON a.score > b.score + 50 ORDER BY a.id, b.id" +# Guard: the nested-loop LEFT path over an expression with NULLs; unmatched +# rows (including every NULL score) still come back once with b NULL. + [[case]] id = "null_arithmetic" data = "null_edges.csv" diff --git a/tests/evaluator_matrix_tests.rs b/tests/evaluator_matrix_tests.rs index 1b30133a..d7075edb 100644 --- a/tests/evaluator_matrix_tests.rs +++ b/tests/evaluator_matrix_tests.rs @@ -133,6 +133,10 @@ enum Evaluator { Where, /// `ArithmeticEvaluator`, as `SELECT () AS v`. Value, + /// `HAVING ` through `QueryEngine`, each row a group of its + /// own. `T` the group is kept, `F` dropped - FALSE and UNKNOWN both drop + /// at the boundary, so there is no `U` - and `E` the query failed. + Having, } /// A recorded divergence from `EXPECTED`: what this evaluator returns today, @@ -495,6 +499,150 @@ const EXPECTED_TRUTH_BARE: &[(&str, &str)] = &[ const KNOWN_TRUTH: &[Known] = &[]; const KNOWN_TRUTH_BARE: &[Known] = &[]; +/// The same values as a HAVING condition (R13 slice 6). Every row is grouped +/// on its own, so a row string still reads one row at a time. Generated by +/// DuckDB, one group per query, the condition over the grouped columns or an +/// aggregate of them. +const EXPECTED_TRUTH_HAVING: &[(&str, &str)] = &[ + ("n", "TFFT"), + ("x", "TFFT"), + ("s", "EEFE"), + ("tb", "TFFF"), + ("d", "EFFE"), + ("NOT tb", "FTFT"), + ("n > 1", "TFFF"), + ("n = NULL", "FFFF"), + ("CASE WHEN n > 1 THEN 1 ELSE 0 END", "TFFF"), + ("CASE WHEN n > 1 THEN s END", "EFFF"), + ("MAX(tb)", "TFFF"), + ("MAX(s)", "EEFE"), + ("NOT (MIN(n) > 0)", "FTFF"), +]; + +/// HAVING collapses the condition's value with its own `is_truthy`, one of +/// P62's five truth tables and one of the two still standing: any text (even +/// empty) and any date are TRUE, so `'f'` and `'No'` keep their group and text +/// that is not a boolean keeps it rather than failing; NaN is FALSE, where +/// DuckDB reads it as non-zero. +const HAVING_IS_TRUTHY: &str = "P62: HAVING's own is_truthy - text and dates TRUE, NaN FALSE"; + +const KNOWN_TRUTH_HAVING: &[Known] = &[ + Known { + evaluator: Evaluator::Having, + predicate: "x", + observed: "TFFF", + why: HAVING_IS_TRUTHY, + }, + Known { + evaluator: Evaluator::Having, + predicate: "s", + observed: "TTFT", + why: HAVING_IS_TRUTHY, + }, + Known { + evaluator: Evaluator::Having, + predicate: "tb", + observed: "TTFT", + why: HAVING_IS_TRUTHY, + }, + Known { + evaluator: Evaluator::Having, + predicate: "d", + observed: "TFFT", + why: HAVING_IS_TRUTHY, + }, + Known { + evaluator: Evaluator::Having, + predicate: "CASE WHEN n > 1 THEN s END", + observed: "TFFF", + why: HAVING_IS_TRUTHY, + }, + Known { + evaluator: Evaluator::Having, + predicate: "MAX(tb)", + observed: "TTFT", + why: HAVING_IS_TRUTHY, + }, + Known { + evaluator: Evaluator::Having, + predicate: "MAX(s)", + observed: "TTFT", + why: HAVING_IS_TRUTHY, + }, +]; + +/// The same values as `IIF`'s condition (R13 slice 6), through both +/// evaluators. DuckDB has no `IIF`; these were generated with its `if()`, +/// which takes the same three arguments. A condition that is not TRUE - +/// FALSE or UNKNOWN - takes the third argument, so `IIF` never answers `U`. +const EXPECTED_TRUTH_IIF: &[(&str, &str)] = &[ + ("IIF(n, true, false)", "TFFT"), + ("IIF(x, true, false)", "TFFT"), + ("IIF(s, true, false)", "EEFE"), + ("IIF(tb, true, false)", "TFFF"), + ("IIF(d, true, false)", "EFFE"), + ("IIF(NOT tb, true, false)", "FTFT"), + ("IIF(n > 1, true, false)", "TFFF"), + ("IIF(n = NULL, true, false)", "FFFF"), + ("IIF(CASE WHEN n > 1 THEN tb END, true, false)", "TFFF"), +]; + +/// `IIF` reads its condition with a table of its own - P62's other standing +/// copy, and not the same as HAVING's: non-empty text is TRUE (so `'f'` and +/// `'No'` take the second argument) but a date is FALSE, and NaN is FALSE. +const IIF_OWN_TRUTH: &str = "P62: IIF's own truth table - non-empty text TRUE, dates and NaN FALSE"; + +const KNOWN_TRUTH_IIF: &[Known] = &[ + Known { + evaluator: Evaluator::Where, + predicate: "IIF(x, true, false)", + observed: "TFFF", + why: IIF_OWN_TRUTH, + }, + Known { + evaluator: Evaluator::Value, + predicate: "IIF(x, true, false)", + observed: "TFFF", + why: IIF_OWN_TRUTH, + }, + Known { + evaluator: Evaluator::Where, + predicate: "IIF(s, true, false)", + observed: "TFFT", + why: IIF_OWN_TRUTH, + }, + Known { + evaluator: Evaluator::Value, + predicate: "IIF(s, true, false)", + observed: "TFFT", + why: IIF_OWN_TRUTH, + }, + Known { + evaluator: Evaluator::Where, + predicate: "IIF(tb, true, false)", + observed: "TTFT", + why: IIF_OWN_TRUTH, + }, + Known { + evaluator: Evaluator::Value, + predicate: "IIF(tb, true, false)", + observed: "TTFT", + why: IIF_OWN_TRUTH, + }, + Known { + evaluator: Evaluator::Where, + predicate: "IIF(d, true, false)", + observed: "FFFF", + why: IIF_OWN_TRUTH, + }, + Known { + evaluator: Evaluator::Value, + predicate: "IIF(d, true, false)", + observed: "FFFF", + why: IIF_OWN_TRUTH, + }, +]; + fn trilean_char(t: Trilean) -> char { match t { Trilean::True => 'T', @@ -541,6 +689,32 @@ fn observe_value(table: &DataTable, predicate: &str, case_insensitive: bool) -> .collect() } +fn observe_having(table: &DataTable, predicate: &str, case_insensitive: bool) -> String { + let names = table.column_names(); + let sql = format!( + "SELECT {} FROM t GROUP BY {} HAVING {predicate}", + names[0], + names.join(", ") + ); + let engine = QueryEngine::with_case_insensitive(case_insensitive); + (0..table.row_count()) + .map(|row| { + let mut group = DataTable::new("t"); + for name in &names { + group.add_column(DataColumn::new(name)); + } + group + .add_row(table.get_row(row).expect("row in range").clone()) + .unwrap(); + match engine.execute(Arc::new(group), &sql) { + Ok(view) if view.row_count() > 0 => 'T', + Ok(_) => 'F', + Err(_) => 'E', + } + }) + .collect() +} + #[test] fn every_predicate_through_both_evaluators() { check_matrix(&table(), EXPECTED, KNOWN, false); @@ -617,6 +791,22 @@ fn a_bare_value_as_the_where_predicate() { ); } +#[test] +fn a_value_as_the_having_condition() { + check_matrix_with( + &truth_table(), + EXPECTED_TRUTH_HAVING, + KNOWN_TRUTH_HAVING, + false, + &[Evaluator::Having], + ); +} + +#[test] +fn a_value_as_the_iif_condition_through_both_evaluators() { + check_matrix(&truth_table(), EXPECTED_TRUTH_IIF, KNOWN_TRUTH_IIF, false); +} + fn check_matrix( t: &DataTable, expected_answers: &[(&str, &str)], @@ -653,6 +843,7 @@ fn check_matrix_with( let got = match evaluator { Evaluator::Where => observe_where(t, predicate, case_insensitive), Evaluator::Value => observe_value(t, predicate, case_insensitive), + Evaluator::Having => observe_having(t, predicate, case_insensitive), }; (evaluator, got) })