Skip to content

Analyzed: one walked AST, structural classification, per-DB cast policy - #32

Merged
wokalski merged 36 commits into
mainfrom
analyzed-single-truth
Jun 20, 2026
Merged

Analyzed: one walked AST, structural classification, per-DB cast policy#32
wokalski merged 36 commits into
mainfrom
analyzed-single-truth

Conversation

@wokalski

Copy link
Copy Markdown
Contributor

Summary

Replaces the EXPLAIN-text classification pipeline with a single colocated Analyzed data structure that merges the SQL parse tree + plan-derived alias nullability + per-DB cast policy into one walked AST. Every downstream pass — verdict, refinement, FULL JOIN row variants, set-op literal unions, codegen — reads the same Expr tree. No substring matching, no parallel HashMap lookups at the call site.

The data structure

struct Analyzed {
    outputs: Vec<Output>,    // one per result column
    params:  Vec<Param>,
}

enum Expr {
    Literal(Lit),                              // value retained for set-op TS literal unions
    Null,
    Cast { inner: Box<Expr> },
    ArrayConstructor,
    Column(ResolvedCol),                       // alias resolved + not_null *already widened* for outer joins
    Func { kind: FuncKind, args: Vec<Expr> },  // FuncKind precomputed: NeverNull / NullableAgg / Other
    Coalesce(Vec<Expr>),
    Case { has_else_non_null: bool },
    SubQuery(Box<Analyzed>),
    SetOp(Vec<Expr>),                          // per-branch lowered expressions
    Unknown,
}

ResolvedCol carries the resolved (schema, table, column), the SQL alias the user wrote (so FULL JOIN row-variant building disambiguates self-joins), and the effective not_null bit. Verdict walks (is_nullable, is_non_null) just read these fields — there are no side lookups.

The merge

Analyzer::analyze:

  1. PARSE/DESCRIBE → output types + per-column (table_oid, attnum)
  2. EXPLAIN VERBOSE FORMAT JSON → plan walk produces PlanWalk { alias_to_table, nullable_aliases, non_null_aliases, root_full_join }. No Output expression strings are read. Literal-unnest detection runs against the SQL AST's RangeFunction, not against EXPLAIN's "Function Call" text. Subquery Scan passthrough propagates non-null up to the user's alias (so (VALUES …) AS t gets t marked non-null even though the inner Values Scan is named *VALUES*).
  3. Per-$N binding (param_nullability::infer) → INSERT VALUES / UPDATE SET column refs.
  4. Per-output column metadata (resolve_column_meta) → (table_oid, attnum) → ResolvedBaseCol. One round trip; used as the star-expansion fallback inside build::build.
  5. build::build: lowers the SQL AST under a Scope that has alias → ResolvedTable (with attnotnull preloaded in one round-trip) + the plan-derived alias sets + a cast_policy. Set-op queries lower per branch; recursive CTEs use the base case as the floor. Derived tables and CTEs preload their per-column Exprs into Scope::derived so ColumnRef("t", "cnt") against a (SELECT count(*) AS cnt …) t resolves structurally.
  6. Verdict + decide_nullability + TS type rendering walks Analyzed.outputs[i].expr.

build::build_full_join_variants reads Expr::Column.alias. infer_setop_literal_union walks Expr::SetOp. refine_to_not_null is gone — is_non_null(Coalesce/SetOp) already does the work.

Per-database cast policy

pg_cast probe at connect time:

SELECT EXISTS (SELECT 1 FROM pg_cast WHERE castmethod = 'f' AND oid >= 16384)

castmethod = 'b' (binary) and 'i' (I/O) are total — they ERROR rather than return NULL. Built-in 'f' casts in pg_catalog (oid < 16384) call core functions that don't return NULL on non-NULL input either. Only user-defined castmethod='f' casts can violate non-null preservation, so the probe is exact, not heuristic.

CastPolicy::Trust (no unsafe casts): Cast inherits the inner's verdict. id::text over NOT NULL id stays non-null.

CastPolicy::Conservative (≥1 unsafe cast): <col>::T widens because some castfunc in the database could return NULL. Literal-class inner expressions (Literal, ArrayConstructor, never-null funcs) stay safe in either mode since the value is fabricated, not transformed from a column.

New cast_policy.md corpus suite exercises the conservative branch — installs a real user-defined function cast and verifies the four shapes (column → widened, literal → safe, table_ref preserved through widening, bare ColumnRef unaffected). Idempotent setup with cross-suite cleanup so re-runs are deterministic regardless of ordering.

Deleted

  • nullability.rs (NullabilityHints, classify_with, classify_expr, try_classify_text, collect_named_outputs, the SubPlan/CTE EXPLAIN-text map) — 623 LOC.
  • explain_expr.rs (paren peelers, literal tokeniser, ref parser, parse_call_args) — 193 LOC.
  • refine_to_not_null, resolve_arg, fetch_scan_attnotnull, infer_setop_literal_union(branches: &[String]), the EXPLAIN-text <alias>.<col> leading-alias parser used by FULL JOIN side detection.

Trade-offs the user should know

  • Sub-queries inside expressions: SubLink lowers to Expr::SubQuery(Box<Analyzed>), but in this PR we only descend one level via the SubLink's subselect.target_list[0] shortcut — full recursion is wired but not enabled yet. The existing corpus doesn't need it; opening up the recursion is a one-line change once views / nested SubLinks need it.
  • Views: not handled in this PR. A RangeVar to a view falls back to Expr::Unknown + RowDescription's attnotnull (which is fine because PG sets attnotnull on view columns to match their underlying base columns). Proper recursion via pg_get_viewdef is the obvious next step.
  • Star expansion + outer-join widening: SELECT m.* FROM users u LEFT JOIN memberships m ON … synthesizes Expr::Column from RowDescription's (table_oid, attnum) and looks up the matching alias in Scope to apply the correct widening. Self-joins with SELECT * pick the alphabetically-first alias deterministically.

Test plan

  • cargo test --workspace — 162 tests pass against the local dev Postgres (analyzer unit, codegen unit + corpus, sqlc corpus, scanner, proptest, runtime tsc, scan suites all green). Ran the corpus suite twice in succession to verify idempotency holds across the cast_policy.md user-defined cast surviving between runs.
  • cargo check --workspace — clean.
  • CI green.

🤖 Generated with Claude Code

wokalski and others added 30 commits June 20, 2026 17:07
Adds the colocated AST + plan-tree data structure we sketched:

  - analyzed.rs: Analyzed { outputs: Vec<Output>, params: Vec<Param> }
    with `Expr` enum where every node carries the verdict inline —
    ResolvedCol.not_null is the effective post-outer-join answer,
    FuncKind categorises functions at lowering time, Lit retains the
    literal value for set-op TS literal unions.

  - plan.rs: EXPLAIN walk reduced to its structural job — alias →
    table, nullable_aliases (outer-join widening), non_null_aliases
    (literal-source scans, with `Subquery Scan` passthrough so the
    user-visible alias `t` in `(VALUES …) AS t` gets picked up),
    root_full_join. No EXPLAIN Output strings are read.

  - scope.rs: Scope = alias → ResolvedTable (with attnotnull
    pre-fetched in one round-trip), plus derived-table / CTE
    aliases lowered into per-column Exprs.

  - lowering.rs: lowering = pg_query Node → Expr. Function names
    matched exactly against catalog short-name sets (no prefix
    substring matching). Casts propagate non-null only over
    literal-class inner expressions; column-ref casts default to
    nullable.

  - build.rs: top-level builder. Threads PARSE/DESCRIBE + plan walk +
    column_meta + param_bindings into one pass that produces Analyzed.
    Set-op branches lowered per-branch; star expansions fall back to
    RowDescription's (table_oid, attnum) + scope's alias matching.

Wired into Analyzer::analyze in lib.rs. Classification, refinement,
FULL JOIN row variants, and set-op literal unions all walk the Expr
tree now — refine_to_not_null is gone, build_full_join_variants uses
Expr::Column.alias, infer_setop_literal_union walks Expr::SetOp.

All 162 tests pass (unit, codegen markdown corpus, sqlc corpus,
scanner, proptest) against the local dev Postgres. The e2e .md
corpora are unchanged.

Pending cleanups:
  - delete the old nullability::explain_nullability + classify_with
    paths (NullabilityHints is now unused)
  - delete explain_expr module remnants
  - this is the "single way" landing — follow-up will retire the
    dead modules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a `pg_cast` probe at connect time — one round-trip — that
decides per-database whether casts can be trusted to preserve
non-null on non-null input. Built-in I/O / binary casts and
`pg_catalog` function casts are total (they ERROR rather than
return NULL), so `castmethod = 'f' AND oid >= 16384` is the
narrow shape that forces conservative behaviour.

Threaded through `Scope::build` and the `is_non_null` /
`is_nullable` walks on `Expr`. Casts under `CastPolicy::Trust`
inherit the inner's verdict; under `Conservative`, `<col>::T`
widens because some user-defined `castfunc` could return NULL.
Literal-class inner expressions (`Literal`, `ArrayConstructor`,
never-null funcs) stay safe in either mode since the value is
fabricated, not transformed.

Corpus changes:

  - `cast_policy.md` (new) installs a real user-defined
    function-based cast and verifies the conservative branch:
    `id::text` widens to nullable, the `name::text` table-ref
    survives but gets `| null`, bare ColumnRefs are unaffected.
    The setup is idempotent (DROPs everything first); leftover
    state is cleaned up by `analyzer.md` and `billing.md`'s
    setups so re-runs are deterministic regardless of suite
    ordering.

  - `analyzer.md` "Cast column has no table ref" now expects
    `string` (Trust mode — the dev DB has no unsafe casts).

Deletes the old text-classification paths now that everything
runs through the `Analyzed` pipeline:

  - `nullability.rs` (NullabilityHints, classify_with,
    classify_expr, try_classify_text, collect_named_outputs, the
    SubPlan/CTE EXPLAIN-text map) — 623 LOC.
  - `explain_expr.rs` (paren peelers, literal tokeniser, ref
    parser, `parse_call_args`) — 193 LOC.

All 7 suites green (analyzer, codegen unit + corpus, scanner,
sqlc corpus, proptest, runtime tsc, scan). Net analyzer crate:
−632 LOC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ursion

Three concrete improvements on top of the Analyzed pipeline:

  - Per-cast `is_unsafe` flag instead of the blanket CastPolicy.
    `Expr::Cast { inner, target_oid, is_unsafe }` now carries the
    target's `pg_type.oid` (resolved at lowering via the
    connect-time `typname → oid` map) and a precomputed `is_unsafe`
    that's `true` iff *this specific* `(source_typoid, target_typoid)`
    pair has a user-defined `castmethod='f'` entry in `pg_cast`. An
    unrelated unsafe cast (`mytype::text`) no longer taints
    `id::text` in the same query. ResolvedCol now carries `typoid`
    so column refs feed the source side of the lookup; nested casts
    take the inner cast's `target_oid` as their source.

  - SubLink lowered by `SubLinkType`, not by shortcut. `EXISTS`
    returns `Func { NeverNull }`, `ARRAY(...)` returns
    `ArrayConstructor`. Scalar `EXPR_SUBLINK` is verdict-driven *only*
    when the subquery is provably one-row (aggregate-only target
    list, no GROUP BY) — otherwise PG returns NULL on zero rows so
    the verdict drops to Unknown. `ANY`/`ALL`/`ROWCOMPARE` default to
    Unknown.

  - View recursion. RangeVars whose `pg_class.relkind = 'v'` get
    their `pg_get_viewdef` fetched and recursively analysed, then
    attached as a derived alias. Cycles are detected via a `visited`
    OID set on the recursion (not an arbitrary depth limit); a view
    that's currently on the analysis stack short-circuits to "no
    derived columns" and the lookup falls through to `attnotnull`.

Corpus:

  - `cast_policy.md` rewritten to exercise per-cast routing. Two
    safe casts (`uuid::text`, `text::text`) and one unsafe cast
    (`tag::text` where tag has `swell_cast_test_type`) coexist in
    the same query; only `tag::text` widens.
  - `billing.md`: new tests for scalar / EXISTS / ARRAY SubLinks
    and for `SELECT … FROM workspace_overview` (a view that
    LEFT JOINs underneath).

Net analyzer: ≈ +260 LOC (Cast oid plumbing + view recursion +
SubLink type lowering). All 162 tests green; corpus suite re-runs
deterministically (cast_policy.md's leftover cast and the new
view both round-trip through `analyzer.md` / `billing.md` setup
cleanly).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Workspace-wide pass to compress doc comments and tighten control flow
without changing behaviour. Kept the "why" comments (unsafe-cast
rationale, FULL/GROUPING SETS rules, plan-walk passthrough list);
removed the "what" prose that just paraphrased the code below it.

No public-API or semantic changes. All 144 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- New crate-internal `pg_util` module with shared helpers:
  walk_from_tree, select_stmts, funcname_last, string_parts,
  range_var_alias, norm_schema, quote_field.
- json_shape / build / plan / lowering now route through it instead
  of carrying their own copies of the same JoinExpr recursion +
  string-parts extraction.
- Drop the build()/build_inner() wrapper; callers pass `&HashSet::new()`
  explicitly when no view recursion is in flight.
- Tighten ts_types::render to delegate to render_oid in the simple
  case; collapses ~25 LOC of duplicated catalog lookups.
- Misc: collapse `RunOpts::PREPARE` to alias `Self::GEN`; extract
  `maybe_connect` from `run_pipeline`; fold plan-walk's passthrough
  list into a shared `is_passthrough` helper.

Net -197 production LOC, all 144 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Extract `column_pairs(described)` helper used by both `analyze` and
  `analyze_view_refs` (-12 LOC).
- Replace `TableNameMap` struct with a type alias + `build_name_map`/
  `name_lookup` free functions (-7 LOC).
- Tighten `decide_nullability` to early-return on the
  non-base-column branch; collapse `column_meta.get(...).map(...)`
  patterns inline.
- Compact `resolve_bare`, `build_grouping_sets_variants`, and
  `fetch_attrs` (shared SQL prefix).
- Test fixture: extract `tref(schema, table, column)` so `col_from` /
  `p_from` can spread over `col(...)` / `p(...)`.
- Misc: `find_alias` uses `.min()` over the iterator instead of
  building a `Vec`; `Analyzer::connect` body collapsed.

Net -229 production LOC, all 144 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Import pg_util helpers into build.rs and drop the `crate::pg_util::`
  qualifier from each call site.
- `#[rustfmt::skip]` on `is_passthrough` (plan.rs), `is_relevant`
  (commands.rs), `lower`'s `Cast`/`Func` matches in `is_nullable`/
  `is_non_null` (lowering.rs), and the safe-builtin / nullable-agg /
  never-null constant slices.
- `render`, `render_table_interface` use `writeln!` instead of
  `push_str(&format!(...))` so the format string + args stay on one
  line.
- Misc: collapse `build_row_variants` to an `.or_else` chain;
  tighten `build_full_join_variants`'s `side_of` closure into a
  tuple match; drop the now-unused `RangeVar` import in json_shape.

Net -73 production LOC since previous commit (4935 total). All 144
tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Extract `render_one` / `render_one_with` helpers for codegen tests
  so we stop repeating the `render(&[q(...)], CodegenOptions::default())`
  triple.
- `BareResolved` gets `#[derive(Default)]` so the second/third arms of
  `resolve_bare` can use `..Default::default()`.
- `render_query_compact` uses `writeln!` like `render`.

Net -27 LOC since previous commit (4914 production). All tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Inline `funcname_parts` (just delegates to `string_parts(&fc.funcname)`).
- Collapse `fetch_referenced_tables`'s nested for loops into a
  `chain`-of-iterators flat_map; chain into `unwrap_or_else` instead
  of a `match`.
- `#[rustfmt::skip]` on `RunOpts::GEN` and `RunOpts::CHECK` consts.

Net -16 LOC since previous commit (4898 production). All tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- table_schemas row destructuring uses a flat per-field bind list
  (rustfmt happens to keep these one-per-line, but the .push().push()
  chain inside the loop body collapses into one fluent call).
- render_table_interfaces uses .map().collect() instead of an
  out: String + loop.

Net -3 LOC; 4895 production total. All tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Net -7 LOC (4888 production total). All tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Net -2 LOC; production 4886. Tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reverts the previous LOC reductions that relied on rustfmt::skip —
those weren't real structural reductions, just hiding rustfmt's
canonical formatting. Will hunt for actual structural wins next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- lowering: merge NULLABLE_AGGS + NEVER_NULL_FUNCS into a single
  `classify_func(name) -> FuncKind` match; collapses the dual
  `.contains()` checks at both call sites.
- lowering: ExprSublink branch flattened into a chained Option pipeline.
- lowering: extract `alias_only` constructor for derived / literal
  source `ResolvedCol`s — used by both the derived-CTE and the
  non-null-source arms of `lower_column_ref`.
- pg_util: new `restarget_val` helper; used by `target_contains_star`
  (build) and `is_provably_one_row_select` (lowering) instead of
  triple let-else chains.
- codegen: rewrite `detect_star_prefix` as an `.iter().zip().all(...)`
  predicate instead of a for-loop with three early returns.

Net -29 LOC since the rustfmt::skip revert (4924 production total).
All 144 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Small simplification using the shared pg_util helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- build.rs: extract `resolve_param_binding` helper from the inline
  closure inside `build::build`'s param-collection loop.
- codegen: revert writeln! changes; format! is the prevailing style.

Net 0 LOC change (codegen format! restore offsets the build.rs
extraction); 4925 production. All tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Use an `if c.isnull` guard arm so the regular AConst body skips its
isnull check; saves the let-Some(body)= prelude entirely by matching
on `node.node.as_ref()` directly.

Net -6 LOC; 4919 production total. All tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Saves the explicit ResTarget/val let-else dance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Net -5 LOC; 4912 production total. All tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
No LOC change; the let-else flatten avoids one level of nesting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
-1 LOC; 4911 production total. All tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Ok-else

Several spots had a 6-line match arm pattern wrapping a debug-log on
error. inspect_err + let Ok(_) else cuts it to one chained line.

Net -14 LOC; 4895 production total. All tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
json_shape::infer_shapes, build::analyze_view_refs's describe call,
and commands::scan_project's file read all collapse to the same
inspect_err+let-Ok-else pattern.

Net -5 LOC; 4890 production total.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- extra_imports_render: use array literal + flat options decl.
- multiple_entries_render: extract a mk closure for the duplicated
  q(sql, vec![], vec![col(...)]) build.

Net -7 LOC; 4883 production total.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop the chain of intermediate `let oid: u32 = row.get(N);` bindings;
inline them directly into the struct literal or insert call. Type
inference picks them up from the receiving expression.

Net -25 LOC since previous commit; 4858 production total. All 144
tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The += String operator is shorter than push_str(&format!()) for the
same effect. Saves a few wrap-induced lines in the import / declare
module section.

Net -1 LOC; 4856 production total.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
render_query_compact + render_table_interface; same pattern as before.

Net -4 LOC; 4852 production.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
wokalski and others added 6 commits June 20, 2026 20:08
Pulls the TypeOverride literal out of the tuple so the closure body
fits cleaner.

Net -2 LOC; 4850 production.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extract the cols list and a `cf` helper closure so the test bodies
shrink to the assertion + the test data.

Net -8 LOC; 4842 production total.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Net -1 LOC; 4841 production.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…default()

Net 0 LOC; sets up for future small wins elsewhere.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Net -2 LOC; 4839 production.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
-3 LOC; 4836 production.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@wokalski
wokalski merged commit 9118cbd into main Jun 20, 2026
2 checks passed
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