Skip to content

feat(#22): full CHECK narrowing (Tier 1 + Tier 2 + Tier 3) on analyzed-single-truth - #35

Open
wokalski wants to merge 4 commits into
analyzed-single-truthfrom
feat/check-constraints-on-analyzed
Open

feat(#22): full CHECK narrowing (Tier 1 + Tier 2 + Tier 3) on analyzed-single-truth#35
wokalski wants to merge 4 commits into
analyzed-single-truthfrom
feat/check-constraints-on-analyzed

Conversation

@wokalski

@wokalski wokalski commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Reimplements PR #25 — the full three-tier CHECK refinement system — on top of the new analyzed-single-truth internals.

Tier Predicate shape Result
1 col = lit, col IN (…), col = ANY (ARRAY[…]) literal union
1 col IS NULL OR <above> propagates allow_null
2 AND-chain of jsonb_typeof(col) = 'object', col ? 'k' / col ?& array[…], jsonb_typeof(col->'k') = '<type>', col->>'k' = 'lit', key-count idiom open / closed object shape
3 (col) OR over AND-chains targeting the same column discriminated union
3 (row) num_nonnulls(a, b, …) = 1 row variants — exactly one of the listed columns is non-null per variant
3 (row) searched-CASE WHEN <disc>='lit' THEN <pred> [ELSE …] row variants pinning the discriminant + the THEN refinement; ELSE false is exhaustive, true/NULL/missing widens with an Exclude<Base, "lit" | …> catch-all
bail relational, arithmetic, regex, function calls, two-column compares, anything unfamiliar base type — partial narrowing is worse than none

What changed

  • crates/swell-analyzer/src/checks.rs — full Refinement model:
    Literal, JsonFieldType, ObjectShape, Refinement (LiteralUnion
    / Object / Union), RowVariant, RowRefinement. Reducer dispatches
    on AndExpr (clause accumulator), OrExpr (nullable-literal-set
    idiom OR discriminated-union OR nothing), atoms (AExpr / NullTest).
    Row-level parse_row_check_def recognises num_nonnulls = 1 and
    searched CASE with optional catch-all.
  • crates/swell-analyzer/src/query.rsTableSchema gains
    row_checks: Vec<TableRowCheck>. Each TableRowCheck is one CHECK
    reduced to a Vec<TableRowVariant>.
  • crates/swell-analyzer/src/lib.rs::analyze — applies column-level
    refinements to each InferredColumn whose base TS render is
    narrowable (string / number / boolean / Json). Custom
    domain renderings (enums, user types), [types.by_name] overrides,
    alias-suffix overrides (AS "col!: T"), and comment hints all win
    over a CHECK refinement.
  • crates/swell-analyzer/src/lib.rs::table_schemas — applies column
    refinements to row-type columns AND populates row_checks from
    checks::fetch_row_refinements.
  • crates/swell-codegen/src/lib.rs::render_table_interface — when
    row_checks is non-empty, emits interface FooBase {...} plus
    type Foo = FooBase & (variant | …) & (variant | …) & …, chaining
    one intersection per CHECK so TS computes the joint constraint.
    Disconnected CHECKs collapse cleanly; contradictory branches
    collapse to never automatically. The FooBase ident is
    disambiguated against sibling table names so a literal <x>_base
    table 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 from swell.toml, alias-suffix overrides
(AS \"col!: T\"), and --@swell.type comment hints all take
precedence over a CHECK-derived refinement.

Conflict handling

  • Column-level: multiple CHECKs on the same column intersect
    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).
  • Row-level: each CHECK becomes its own (variant | …) intersection
    — TS computes the joint constraint, contradictory variants collapse
    to never.
  • con.convalidated = true filter on both fetch queries — NOT VALID constraints don't actually hold against existing rows, so
    it's unsound to narrow on them.

Test plan

  • 19 unit tests in checks.rs covering 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 with
    ELSE false, row-level CASE with missing ELSE that produces a
    catch-all).
  • Corpus tests in billing.md against 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 → falls
    back to plain Json
    - SELECT * against a row-variant table preserves the
    Base & (variants) intersection in the result type
  • Full workspace cargo test --workspace green.

🤖 Generated with Claude Code

wokalski and others added 2 commits June 20, 2026 21:10
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>
@wokalski wokalski changed the title feat(#22): Tier 1 CHECK literal unions on analyzed-single-truth feat(#22): full CHECK narrowing (Tier 1 + Tier 2 + Tier 3) on analyzed-single-truth Jun 20, 2026
wokalski and others added 2 commits June 20, 2026 23:26
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant