Analyzed: one walked AST, structural classification, per-DB cast policy - #32
Merged
Conversation
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>
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>
2 tasks
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
Replaces the EXPLAIN-text classification pipeline with a single colocated
Analyzeddata 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 sameExprtree. No substring matching, no parallel HashMap lookups at the call site.The data structure
ResolvedColcarries the resolved(schema, table, column), the SQL alias the user wrote (so FULL JOIN row-variant building disambiguates self-joins), and the effectivenot_nullbit. Verdict walks (is_nullable,is_non_null) just read these fields — there are no side lookups.The merge
Analyzer::analyze:(table_oid, attnum)PlanWalk { alias_to_table, nullable_aliases, non_null_aliases, root_full_join }. NoOutputexpression strings are read. Literal-unnestdetection runs against the SQL AST'sRangeFunction, not against EXPLAIN's "Function Call" text.Subquery Scanpassthrough propagates non-null up to the user's alias (so(VALUES …) AS tgetstmarked non-null even though the inner Values Scan is named*VALUES*).$Nbinding (param_nullability::infer) → INSERT VALUES / UPDATE SET column refs.resolve_column_meta) →(table_oid, attnum) → ResolvedBaseCol. One round trip; used as the star-expansion fallback insidebuild::build.build::build: lowers the SQL AST under aScopethat hasalias → ResolvedTable(with attnotnull preloaded in one round-trip) + the plan-derived alias sets + acast_policy. Set-op queries lower per branch; recursive CTEs use the base case as the floor. Derived tables and CTEs preload their per-columnExprs intoScope::derivedsoColumnRef("t", "cnt")against a(SELECT count(*) AS cnt …) tresolves structurally.Analyzed.outputs[i].expr.build::build_full_join_variantsreadsExpr::Column.alias.infer_setop_literal_unionwalksExpr::SetOp.refine_to_not_nullis gone —is_non_null(Coalesce/SetOp)already does the work.Per-database cast policy
pg_castprobe at connect time:castmethod = 'b'(binary) and'i'(I/O) are total — they ERROR rather than return NULL. Built-in'f'casts inpg_catalog(oid < 16384) call core functions that don't return NULL on non-NULL input either. Only user-definedcastmethod='f'casts can violate non-null preservation, so the probe is exact, not heuristic.CastPolicy::Trust(no unsafe casts):Castinherits the inner's verdict.id::textover NOT NULLidstays non-null.CastPolicy::Conservative(≥1 unsafe cast):<col>::Twidens because somecastfuncin 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.mdcorpus 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
SubLinklowers toExpr::SubQuery(Box<Analyzed>), but in this PR we only descend one level via the SubLink'ssubselect.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.RangeVarto a view falls back toExpr::Unknown+ RowDescription'sattnotnull(which is fine because PG setsattnotnullon view columns to match their underlying base columns). Proper recursion viapg_get_viewdefis the obvious next step.SELECT m.* FROM users u LEFT JOIN memberships m ON …synthesizesExpr::Columnfrom RowDescription's(table_oid, attnum)and looks up the matching alias inScopeto apply the correct widening. Self-joins withSELECT *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 thecast_policy.mduser-defined cast surviving between runs.cargo check --workspace— clean.🤖 Generated with Claude Code