Skip to content

Binder, cost model and the optimiser - #3

Merged
sahilkalgutkar merged 1 commit into
mainfrom
feature/logical-plan
Aug 28, 2026
Merged

sahilkalgutkar merged 1 commit into
mainfrom
feature/logical-plan

Conversation

@sahilkalgutkar

Copy link
Copy Markdown
Owner

Parse tree in, optimised logical plan out.

Binder

The single place that answers "does this column exist" and "is this well typed". Once a plan leaves it, every column is a position and every node knows its type, so nothing downstream carries a catalog or produces a name error.

Decisions worth calling out:

  • An ambiguous column is an error, not a first-match win. SELECT id FROM orders o JOIN customers c ON … is refused. Silently picking one is how a join query returns the wrong column with no complaint.
  • A bare column outside GROUP BY is refused with "must appear in GROUP BY or inside an aggregate" — but only after confirming the column exists, so a typo still reports as a typo.
  • BETWEEN and CASE x WHEN … desugar here. Two comparisons are something pushdown can already split, prune with and evaluate; a third node shape would have to be taught to all three.
  • ORDER BY binds against the pre-projection scope, so SELECT id … ORDER BY amount works without selecting amount. Ordinals and aliases resolve back to the expression they name.
  • An ON clause splits into hash-join keys plus a residual filter. Only an equality with one side reading purely left columns and the other purely right becomes a key; everything else, including an equality within one side, stays a filter, because a hash join cannot build on it.

Cost model

Cardinality estimates come from the zone maps the storage layer already writes — no extra pass over data. Equality selectivity is 1/distinct from real statistics where a scan provides them, textbook constants above that. The estimates only ever choose between plans; a bad one costs a slower plan, never a wrong answer, and that is the reason the fallbacks are allowed to be crude. Statistics are read through a projected scan, so pruning does not silently point the estimator at the wrong column.

Optimiser

Four rules, each of which must preserve both the rows and the output schema. The schema half is what makes them composable, and optimising_never_changes_the_output_schema asserts it across a spread of queries.

Constant folding evaluates literal arithmetic, applies the boolean identities, cancels double negation and drops dead CASE branches — but deliberately stops at division by zero and integer overflow. Folding those would let the plan-time answer differ from the run-time one, which is worse than not folding.

Predicate pushdown splits conjunctions so each part travels independently, rewrites predicates through column-only projections, and ends inside the scan — where the predicate becomes a zone-map test and whole row groups stop being read. Two rules matter more than the mechanics:

  • It never pushes into the padded side of an outer join. Pushing c.region = 3 into the right side of a LEFT JOIN deletes rows that should have come back NULL-padded; there is a test per join type.
  • A cross join with an equality in WHERE — the old-style comma join — becomes a real inner join with a hash-joinable key, rather than staying a full cross product.

Join reordering puts the smallest relation at the bottom of an inner-join chain, greedily preferring a relation that has a join key to what is already joined over a smaller unrelated one — an accidental cross product is not something a later choice recovers from. Reordering permutes the output columns, so the rule wraps the result in a projection restoring the original order; the node above cannot tell. Outer-join chains are left alone, since reordering them changes which side gets padded.

Projection pushdown narrows every scan to the columns actually read and renumbers every expression above it. In a columnar format this is the single biggest win available, and the renumbering is the part that has to be exactly right — getting it wrong reads the wrong column and reports a plausible wrong answer. A count-only query still keeps one column so it has rows to count.

Verification. 124 tests in the crate, including one per pushdown rule, one asserting no join key is lost in reordering, and one asserting no cross product is introduced.

Three layers between a parse tree and something runnable.

The binder is the only place that resolves names, so error messages about
missing or ambiguous columns all come from one function. An unqualified column
matching two tables in a join is an error rather than a first-match win, and a
bare column outside GROUP BY is rejected instead of returning an arbitrary
row. BETWEEN and the CASE-with-operand form desugar here, so the optimiser and
the evaluator only ever see comparisons.

The cost model estimates cardinality from the zone maps the storage layer
already writes. Its numbers only ever choose between plans, never decide what
a query returns, which is why the fallbacks are allowed to be textbook
constants.

Four rules run over the plan. Constant folding stops short of division by zero
and integer overflow so the plan-time answer cannot differ from the run-time
one. Predicate pushdown splits conjunctions so each part travels separately,
refuses to push into the padded side of an outer join, and turns a comma join
with an equality in WHERE into a real inner join. Join reordering puts the
smallest relation at the bottom of an inner-join chain, preferring a relation
that has a join key over a smaller unrelated one, and restores the original
column order above so nothing higher in the tree notices. Projection pushdown
narrows every scan to the columns actually read and renumbers everything above
it.

Every rule has to preserve the output schema, and a test asserts exactly that
across a spread of queries.
@codecov

codecov Bot commented Aug 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.69473% with 296 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/qf-plan/src/optimizer.rs 81.30% 227 Missing ⚠️
crates/qf-plan/src/binder.rs 94.80% 46 Missing ⚠️
crates/qf-plan/src/cost.rs 97.27% 11 Missing ⚠️
crates/qf-plan/src/logical.rs 98.18% 9 Missing ⚠️
crates/qf-plan/src/expr.rs 99.47% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

@sahilkalgutkar
sahilkalgutkar merged commit 89c1293 into main Aug 28, 2026
3 checks passed
@sahilkalgutkar
sahilkalgutkar deleted the feature/logical-plan branch September 9, 2026 18:13
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