From 5c439bc8f325102673393b32c841bdc15e70c551 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:10:17 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(domain):=20correlated-subquery=20evide?= =?UTF-8?q?nce=20family=20=E2=80=94=20NOT=20EXISTS=20/=20NOT=20IN=20anti-j?= =?UTF-8?q?oin=20arms=20(#196=20v1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The check engine was blind to logic living in subqueries: #173 shipped join.anti-join detecting only the LEFT JOIN + IS NULL idiom, with the NOT EXISTS / NOT IN equivalents as declared exclusions. This lands the cute-dbt#196 evidence family (v1) and its first consumers. Domain (src/domain/cte.rs): SubqueryFact { kind: SubqueryKind {NotExists, NotIn} (#[non_exhaustive]), consumer, inner_leaf, equi_keys: Vec } — the LeftJoinFact sibling, riding CteGraph behind #[serde(skip)] (wire-isolation test pins it; the embedded payload shape is byte-identical to pre-#196). Engine (src/adapters/cte_engine.rs): collect_subquery_facts rides the SAME single parse at the same call sites as the LEFT JOIN collection. From top-level WHERE AND conjuncts only: correlated NOT EXISTS over a single plain-table inner (equi pairs normalized OUTER-side-left; a correlated-but-keyless form emits empty keys), and single-column NOT IN (the membership pair; an unresolvable outer side emits empty keys). OR-branch / non-negated / derived-or-multi-table-inner / uncorrelated forms stay silent, each pinned by a negative test. Detector (src/domain/checks.rs): bind_keys / key_match_verdict generalize over an internal KeyedJoinView constructed from both fact families — LEFT JOIN behavior byte-identical (existing tests pin it). detect_join_anti_join gains not_exists[...] / not_in[...] sites with ordinal dedup, form-specific evidence, the same inverted-recommendation sketch, and the same Covered/Uncovered/UNKNOWN ladder. join.left-null-propagation never enumerates subquery constructs (verified by test, not assumed). Spec + ledger: conditions gain both forms (incl. the NOT IN NULL-trap honesty note); the lifted v1 exclusion line is replaced by the residual exclusions; heuristics/registry.toml + book/src/checks/join.anti-join.md regenerated (byte-gated). Tests: the #173 silence pin not_exists_anti_join_is_silent_through_the_ real_engine FLIPS into positive detection; NOT IN positive twin added; covered/uncovered/UNKNOWN through the real engine for both arms; fresh residual-exclusion pins; 14 engine inline tests; 7 detector unit tests; 3 BDD scenarios join coverage_checks.feature (feature count stays 22). Dogfood (the #164 precedent): the synthetic playground-current.json gains int_patients__never_admitted (correlated NOT EXISTS, no unit test) — the golden playground report now renders the not_exists arm UNCOVERED with the inverted sketch (programmatic single-line JSON splice; MANIFEST.toml sha updated; real-fixture pin added). dbt-project/ gains orders_never_refunded (NOT IN) + a unit test whose givens carry a matching pair — verified Covered with attribution against a real fusion-compiled manifest; the live PR preview renders the satisfied twin. jaffle-shop + diff-showcase goldens are byte-identical (no anti-join spec embeds there); playground + explore regenerated and audited delta-by-delta. Closes #196 Co-Authored-By: Claude Fable 5 --- book/src/checks/join.anti-join.md | 11 +- dbt-project/models/marts/_marts__models.yml | 56 ++ .../models/marts/orders_never_refunded.sql | 22 + examples/explore/dag.html | 4 +- examples/explore/tests.html | 11 +- examples/playground-report.html | 2 +- features/coverage_checks.feature | 48 ++ heuristics/registry.toml | 12 +- src/adapters/cte_engine.rs | 581 +++++++++++++++++- src/domain/checks.rs | 444 ++++++++++++- src/domain/cte.rs | 175 ++++++ src/domain/mod.rs | 5 +- tests/check_engine.rs | 366 ++++++++++- tests/fixtures/MANIFEST.toml | 11 +- tests/fixtures/playground-current.json | 2 +- tests/steps/builders.rs | 28 + tests/steps/coverage_checks.rs | 44 +- tests/steps/world.rs | 15 + 18 files changed, 1757 insertions(+), 80 deletions(-) create mode 100644 dbt-project/models/marts/orders_never_refunded.sql diff --git a/book/src/checks/join.anti-join.md b/book/src/checks/join.anti-join.md index eacd8751..57262212 100644 --- a/book/src/checks/join.anti-join.md +++ b/book/src/checks/join.anti-join.md @@ -14,14 +14,19 @@ ## Conditions - the model LEFT-JOINs a relation and filters `WHERE . IS NULL` in a top-level AND conjunct, where `` is one of the join's ON equi-key right columns — the anti-join idiom: the join deliberately keeps the UNMATCHED left rows +- OR the model filters `WHERE NOT EXISTS (SELECT … FROM WHERE …)` in a top-level AND conjunct, the inner being a single plain named relation whose WHERE carries a correlated reference to the outer query — the resolvable outer↔inner equi-conjuncts are the anti-join keys (cute-dbt#196) +- OR the model filters `WHERE NOT IN (SELECT FROM )` in a top-level AND conjunct, the inner being a single plain named relation projecting exactly one column — the membership pair (outer column ↔ inner projected column) is the anti-join key (cute-dbt#196). SQL honesty note: a NULL in the inner column makes NOT IN yield NO rows at all; detection still treats the construct as the anti-join idiom (that is how it is authored), and the matched-row fixture this check recommends is exactly what surfaces the NULL trap - the recommendation INVERTS join.left-null-propagation's: the anti-join's risk is the matched class leaking through, so the missing fixture is a left row that DOES match a right row, with `expect` proving it is excluded -- satisfaction: some unit test's literal givens carry a left row whose ON equi-key matches a right given row (both cells non-NULL, equal on the value-normalized key) -- supersedes join.left-null-propagation on the same construct: NULL right-side columns are the anti-join's working mechanism, not an untested gap +- satisfaction (all arms): some unit test's literal givens carry a left/outer row whose key matches an inner/right given row (both cells non-NULL, equal on the value-normalized key) +- supersedes join.left-null-propagation on the same construct: NULL right-side columns are the anti-join's working mechanism, not an untested gap (the subquery constructs are never enumerated by left-null-propagation at all) - given binding follows the union.arm-coverage premise: only `given` entries carry statically-visible rows — an ungiven non-seed input is an empty mock; join sides bind directly by external leaf name or through a single-external simple-FROM closure ## Exclusions -- the NOT EXISTS / NOT IN anti-join equivalents are NOT detected in v1 — only the LEFT JOIN + IS NULL form is recognized (a declared gap: the construct is silent, never misclassified) +- negated subqueries anywhere but a top-level AND conjunct of a SELECT's WHERE (OR branches, HAVING, JOIN ON, projections) are NOT detected — different semantics or position, silent, never misclassified +- non-negated EXISTS / IN subqueries (semi-join and membership inclusion — future evidence consumers) and scalar subqueries are NOT detected +- a NOT EXISTS / NOT IN whose inner is a derived table, joins or reads several relations, or carries its own WITH clause is NOT detected; an uncorrelated NOT EXISTS (zero outer references) is not a keyed anti-join — silent +- expression (non-column) correlations and projections are not key material: a correlated NOT EXISTS with no resolvable equi pair, and a NOT IN whose outer column does not resolve to a single relation, degrade to UNKNOWN, never UNCOVERED - an IS NULL on a non-key right column is a data filter, not the anti-join idiom — join.left-null-propagation governs that construct - an IS NULL inside an OR branch has different semantics and is never treated as the anti-join filter - unrecoverable join keys, unresolvable side bindings, external `fixture:` files, non-literal `format: sql` givens, and ungiven seed inputs degrade to UNKNOWN, never UNCOVERED diff --git a/dbt-project/models/marts/_marts__models.yml b/dbt-project/models/marts/_marts__models.yml index 11c31900..d4885c56 100644 --- a/dbt-project/models/marts/_marts__models.yml +++ b/dbt-project/models/marts/_marts__models.yml @@ -49,6 +49,24 @@ models: The right-side join key — always NULL on kept rows; projected so the general left-null check provably fires and is superseded. + # cute-dbt#196 dogfood — the NOT IN membership anti-join. Carries a + # unit test whose givens include a MATCHING outer↔inner pair, so the + # PR preview renders join.anti-join COVERED on the not_in construct + # (the satisfied twin of customers_with_no_orders' UNCOVERED arm). + - name: orders_never_refunded + description: > + Orders that were never refunded — the NOT IN (SELECT …) + membership anti-join idiom (cute-dbt#196 subquery evidence family). + columns: + - name: order_id + description: Order identifier (natural key of the kept rows). + - name: customer_id + description: Customer who placed the order. + - name: order_date + description: Date the order was placed. + - name: status + description: Order status. + unit_tests: # DICT format (given + expect). dict only compares listed columns, so # the derived `amount_share` / `distinct_status_count` are asserted while @@ -175,6 +193,44 @@ unit_tests: number_of_orders: 1 customer_lifetime_value: 10 + # cute-dbt#196 dogfood — the anti-join SATISFACTION case: the givens + # carry a matching pair (order 1 IS refunded) and expect proves the + # matched row is excluded. This is exactly the inverted fixture the + # join.anti-join recommendation asks for, so the live preview shows + # the not_in construct COVERED with attribution to this test. + - name: test_orders_never_refunded_excludes_refunded_orders + description: | + orders_never_refunded keeps only orders absent from the refunded + set. Order 1 is refunded (its id appears in stg_refunds) and order + 2 is clean, so the output carries order 2 alone — the matched row + is provably excluded (the anti-join's risk class, exercised). + model: orders_never_refunded + given: + - input: ref('stg_orders') + format: dict + rows: + - order_id: 1 + customer_id: 1 + order_date: '2024-01-01' + status: 'completed' + - order_id: 2 + customer_id: 2 + order_date: '2024-01-02' + status: 'completed' + - input: ref('stg_refunds') + format: dict + rows: + - refund_id: 1 + order_id: 1 + amount: -10 + expect: + format: dict + rows: + - order_id: 2 + customer_id: 2 + order_date: '2024-01-02' + status: 'completed' + # EXTERNAL FIXTURE format (cute-dbt#126). Same shape as # test_orders_sums_payment_methods, but the stg_payments GIVEN is sourced # from an external csv fixture file (tests/fixtures/stg_payments_credit_card.csv) diff --git a/dbt-project/models/marts/orders_never_refunded.sql b/dbt-project/models/marts/orders_never_refunded.sql new file mode 100644 index 00000000..f4f07375 --- /dev/null +++ b/dbt-project/models/marts/orders_never_refunded.sql @@ -0,0 +1,22 @@ +-- orders_never_refunded — the cute-dbt#196 NOT IN anti-join dogfood. +-- +-- The membership anti-join idiom: deliberately keep the orders whose +-- order_id is NOT IN the refunded set. cute-dbt#173 shipped this form +-- as a declared exclusion (silent); cute-dbt#196's correlated-subquery +-- evidence family lifts it — join.anti-join now fires on the +-- not_in[…] construct. Unlike customers_with_no_orders (the UNCOVERED +-- showcase), this model carries a unit test whose givens include a +-- MATCHING pair (order 1 is refunded) with expect proving the matched +-- row is excluded — the live PR preview renders the arm COVERED with +-- attribution. + +select + o.order_id, + o.customer_id, + o.order_date, + o.status +from {{ ref('stg_orders') }} o +where o.order_id not in ( + select r.order_id + from {{ ref('stg_refunds') }} r +) diff --git a/examples/explore/dag.html b/examples/explore/dag.html index db850c67..464efbbb 100644 --- a/examples/explore/dag.html +++ b/examples/explore/dag.html @@ -318,7 +318,7 @@

Model lineage

-

full manifest — 33 models, 58 edges

+

full manifest — 34 models, 60 edges