feat(#22): full CHECK narrowing (Tier 1 + Tier 2 + Tier 3) on analyzed-single-truth - #35
Open
wokalski wants to merge 4 commits into
Open
feat(#22): full CHECK narrowing (Tier 1 + Tier 2 + Tier 3) on analyzed-single-truth#35wokalski wants to merge 4 commits into
wokalski wants to merge 4 commits into
Conversation
Read column-bound CHECK constraints via `pg_get_constraintdef`, parse
the predicate with `pg_query`, and reduce the recognised shapes into a
TS literal union applied to the column's rendering. Bails liberally on
anything unfamiliar — partial narrowing is worse than none.
Tier 1 predicates handled:
- col = 'lit' → "lit"
- col IN ('a', 'b', ...) → "a" | "b"
- col = ANY (ARRAY['a', 'b', ...]) → "a" | "b"
- col IS NULL OR <one of the above> → ... | null
Tiers 2 (jsonb object shapes) and 3 (OR-discriminated unions, row
variants from `num_nonnulls`, searched CASE) are intentionally out of
scope here — they can land as a follow-up on top of this.
Refinements only override narrowable base renders (`string` / `number`
/ `boolean` / `Json`). Custom domains, enums, `[types.by_name]` swell
overrides, alias-suffix overrides (`AS "col!: T"`), and `--@swell.type`
comment hints take precedence over a CHECK refinement.
In `table_schemas` the row-type renderer would otherwise emit
`"beta" | null | null` for a nullable column with `IS NULL OR ...` —
strip the trailing ` | null` when the column is already nullable so
the renderer's nullability suffix is the single source of truth.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…row variants) Extends the Tier 1 column-level literal unions with the remaining narrowings from #22 so CHECK constraints can shape both the per-column type and the row type itself: Tier 2 — jsonb object shapes An AND-chain of atomic predicates targeting the same `jsonb` column reduces to a TS object type. Recognised atoms: - `jsonb_typeof(col) = 'object'` object discriminator - `col ? 'k'` / `col ?& array['a','b']` required keys - `jsonb_typeof(col->'k') = '<type>'` typed field - `col->>'k' = 'lit'` literal field - key-count idiom closes the object Tier 3 (column-level) An OR chain whose branches each AND-chain against the same column reduces to a TS union (e.g. discriminated payload shapes). Tier 3 (row-level) Cross-column disjunctions surface as `TableSchema.row_checks`, one per CHECK constraint. Codegen emits the table type as `interface FooBase {...}` plus `type Foo = FooBase & (variant | variant | …)`, chaining one intersection per CHECK so multiple CHECKs compute their joint constraint. Two shapes: - `num_nonnulls(a, b, …) = 1` → one variant per arg (XOR). - searched `CASE WHEN <disc>='lit' THEN <pred> [ELSE …] END` pinned per branch; `ELSE false` is exhaustive, `true / NULL / missing` widens with an `Exclude<Base, "lit" | …>` catch-all. Bail liberally: anything we don't recognise drops the *whole* refinement for that column / row. Refinements only override narrowable base renders (string / number / boolean / Json) — custom domains, enums, composites, `[types.by_name]` overrides, alias-suffix overrides (`AS "col!: T"`) and comment hints all take precedence. Conflicting column-level CHECKs on the same column intersect (literal unions set-intersect; object shapes merge field requirements; truly disjoint refinements drop). `con.convalidated = true` filters out `NOT VALID` constraints that don't actually hold against existing rows. Corpus - `billing.feature_flags` already covered Tier 1. - New `billing.widgets` (Tier 2 jsonb object shape). - New `billing.payloads` (Tier 3 col-level OR-of-AND). - New `billing.contacts` (Tier 3 row-level `num_nonnulls = 1`). - New `billing.field_configs` (Tier 3 row-level searched CASE with `ELSE false`). - Per-shape corpus tests + an arrow-expression test verifying that losing the table_ref drops the refinement, falling back to `Json`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Generalises the row-level CHECK reducer so the variants it produces
aren't limited to the two pre-baked shapes (`num_nonnulls = 1`,
searched-CASE with literal-discriminant + jsonb-shape THEN). Any OR
chain whose branches each AND-chain single-column atomic predicates
now reduces to row variants, and CASE THEN branches now accept the
same multi-column AND-chains (not just a single col-level
refinement).
Atomic per-column predicates the row reducer recognises in each
branch:
- `col = lit` literal pin (string / int / bool)
- `col IN (…)` / ANY literal-set union
- `col IS NOT NULL` non-null form of the column's base type
- `col IS NULL` literal `null`
- jsonb shape atoms the same set as Tier 2
Two examples this enables:
CHECK (
(kind = 'image' AND url IS NOT NULL AND body IS NULL)
OR (kind = 'text' AND body IS NOT NULL AND url IS NULL)
)
→ `{ kind: "image"; url: string; body: null }
| { kind: "text"; body: string; url: null }`
CHECK (CASE
WHEN status = 'paid' THEN paid_at IS NOT NULL
WHEN status = 'draft' THEN paid_at IS NULL
ELSE true
END)
→ `{ status: "paid"; paid_at: Date }
| { status: "draft"; paid_at: null }
| { status: Exclude<string, "paid" | "draft"> }`
Single-column OR branches still bail to the col-level Tier 3 Union
path so the cleaner `T = a | b | …` shape wins over a row
intersection — `(kind='a') OR (kind='b')` stays a literal union.
Also relaxes the `variants.len() < 2` floor in `reduce_case` to
`variants.is_empty()` so a single-WHEN + `ELSE false` CASE
(constraining the entire table to one shape) is preserved as a
single-variant refinement rather than silently dropped.
New unit tests
- `row_or_of_and_with_disjoint_null_tests` — XOR via NullTests
- `row_or_of_and_with_literal_discriminant_and_payload_columns`
- `row_or_of_and_single_column_bails_to_col_level`
- `row_or_of_and_branches_with_no_columns_bails`
- `row_case_then_is_not_null_refines_column`
- `row_case_then_and_chain_of_multiple_atoms`
New corpus tables + tests
- `billing.blocks` — kind-discriminated url/body XOR
- `billing.shipments` — CASE THEN with `paid_at IS NOT NULL`
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ble FKs
A `LEFT JOIN parent ON parent.key = child.fk` where `child.fk` is a
nullable single-column FK to `parent(key)` makes the parent's presence
in a row *exactly* `child.fk IS NOT NULL` — referential integrity
guarantees a match iff the fk is non-null. Correlating that with a
row-level CHECK on the child turns the join output into a
discriminated union where the absent arc is provably null.
The killer case is an exclusive arc:
CREATE TABLE notifications (
...,
comment_id uuid REFERENCES comments(id),
alert_id uuid REFERENCES alerts(id),
CHECK (num_nonnulls(comment_id, alert_id) = 1)
);
SELECT c.body AS comment_body, a.message AS alert_message
FROM notifications n
LEFT JOIN comments c ON c.id = n.comment_id
LEFT JOIN alerts a ON a.id = n.alert_id
→ { comment_body: Comments["body"]; alert_message: null }
| { comment_body: null; alert_message: Alerts["message"] }
Neither fk column need be selected — the CHECK + FK + LEFT JOIN are
enough to anti-correlate the two parents' columns.
New module `fk_join`:
- `join_presence` reduces an ON clause to the single child column
that must be non-null for the parent to be present. Only a bare
`parent.key = child.fk` equality qualifies; extra conjuncts,
filters, non-equi joins, USING, and unqualified refs yield no
presence condition and the join is skipped. This is the one place
that reads ON clauses, so widening coverage later is local.
- `validate_fks` confirms the FK direction against pg_constraint:
the *child* (fk source) must be the non-null side and the *parent*
(fk target) the outer-join-widened side. The reverse orientation
(`parent LEFT JOIN child`, a has-many) correctly does not narrow.
- `synth` projects the child's CHECK `RowRefinement` onto the output
columns: child pins map to their output columns; each absent arc
nulls its parent's output columns. Falls back to a 2-variant split
when there's no CHECK but the fk column itself is selected.
Soundness gate: codegen renders a variant's non-overridden columns as
non-null (known present in that arm), so we bail entirely if any
join-*widened* output column (inherently NOT NULL, nulled only by an
outer join) comes from a relation this module doesn't drive — else it
would be silently forced non-null.
Wiring: `analyze` fetches row refinements for every plan-scanned table
(superset of referenced tables, so a CHECK on a join-only child still
fires), builds per-output `(alias, table_ref, widened)` metadata, and
threads the synthesized variants in as the final `build_row_variants`
fallback after FULL JOIN / GROUPING SETS.
Tests: 5 `fk_join` synthesis unit tests (exclusive arc, single-arc
CHECK, no-CHECK fk-in-output, fk-not-selected bail, unrelated-widened
bail) + 5 corpus cases on a new `notifications`/`comments`/`alerts`
arc, `feed_items`/`articles` single arc, `bookmarks` no-CHECK, plus
negative cases pinning the unselected-fk and reverse-orientation
non-firing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXRCrtbQ4ZxHPv9aS8eqvQ
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Reimplements PR #25 — the full three-tier CHECK refinement system — on top of the new analyzed-single-truth internals.
col = lit,col IN (…),col = ANY (ARRAY[…])col IS NULL OR <above>allow_nulljsonb_typeof(col) = 'object',col ? 'k'/col ?& array[…],jsonb_typeof(col->'k') = '<type>',col->>'k' = 'lit', key-count idiomnum_nonnulls(a, b, …) = 1WHEN <disc>='lit' THEN <pred> [ELSE …]ELSE falseis exhaustive,true/NULL/missing widens with anExclude<Base, "lit" | …>catch-allWhat changed
crates/swell-analyzer/src/checks.rs— full Refinement model:Literal,JsonFieldType,ObjectShape,Refinement(LiteralUnion/ Object / Union),
RowVariant,RowRefinement. Reducer dispatcheson
AndExpr(clause accumulator),OrExpr(nullable-literal-setidiom OR discriminated-union OR nothing), atoms (AExpr / NullTest).
Row-level
parse_row_check_defrecognisesnum_nonnulls = 1andsearched CASE with optional catch-all.
crates/swell-analyzer/src/query.rs—TableSchemagainsrow_checks: Vec<TableRowCheck>. EachTableRowCheckis one CHECKreduced to a
Vec<TableRowVariant>.crates/swell-analyzer/src/lib.rs::analyze— applies column-levelrefinements to each
InferredColumnwhose base TS render isnarrowable (
string/number/boolean/Json). Customdomain renderings (enums, user types),
[types.by_name]overrides,alias-suffix overrides (
AS "col!: T"), and comment hints all winover a CHECK refinement.
crates/swell-analyzer/src/lib.rs::table_schemas— applies columnrefinements to row-type columns AND populates
row_checksfromchecks::fetch_row_refinements.crates/swell-codegen/src/lib.rs::render_table_interface— whenrow_checksis non-empty, emitsinterface FooBase {...}plustype Foo = FooBase & (variant | …) & (variant | …) & …, chainingone intersection per CHECK so TS computes the joint constraint.
Disconnected CHECKs collapse cleanly; contradictory branches
collapse to
neverautomatically. TheFooBaseident isdisambiguated against sibling table names so a literal
<x>_basetable can't silently merge via TS interface merging.
Precedence
Refinements only override types where narrowing is unambiguous
(
string,number,boolean,Json). Domain renderings, enums,[types.by_name]overrides fromswell.toml, alias-suffix overrides(
AS \"col!: T\"), and--@swell.typecomment hints all takeprecedence over a CHECK-derived refinement.
Conflict handling
literal unions set-intersect; object shapes merge field
requirements; truly disjoint refinements drop the whole column
(non-deterministic narrowing via HashMap iteration order is the
worst-of-all-worlds outcome).
(variant | …)intersection— TS computes the joint constraint, contradictory variants collapse
to
never.con.convalidated = truefilter on both fetch queries —NOT VALIDconstraints don't actually hold against existing rows, soit's unsound to narrow on them.
Test plan
checks.rscovering each shape + bail case(equality / IN / ANY /
IS NULL OR, qualified column refs,cast-around-literal, Tier 2 object with required typed keys,
Tier 3 OR-discriminated union, relational / function /
two-column / disjoint-OR bails, intersection of two unions,
row-level
num_nonnulls = 1+ bails, row-level CASE withELSE false, row-level CASE with missing ELSE that produces acatch-all).
billing.mdagainst live Postgres exercising:- Tier 1 IN / ANY / equality /
IS NULL OR- Tier 2 jsonb object shape (
widgets.meta)- Tier 3 col-level OR-discriminated jsonb (
payloads.payload)- Tier 3 row-level
num_nonnulls = 1(contacts.email/phone)- Tier 3 row-level CASE (
field_configs.field_type/config)-
meta -> 'width'arrow expression loses table_ref → fallsback to plain
Json-
SELECT *against a row-variant table preserves theBase & (variants)intersection in the result typecargo test --workspacegreen.🤖 Generated with Claude Code