droid
Logical Plans and Three Rewrites
Indexes and the Optimizer · Stage 10 of 11

Logical Plans and Three Rewrites

Plan what the SQL asks for, then rewrite it into something cheaper that means the same

Build a logical plan that mirrors the SELECT, print it with explain logical, and apply exactly three rewrites before choosing scans: constant folding, HAVING pushdown and index condition pushdown; --no-rewrite turns them off and must print the same rows.

Not run yet · 6 tests
Concepts in this stage
  • A logical plan says what is asked; a physical plan says how it is done
  • A rewrite turns a plan into an equivalent one: same rows, less work
  • Constant folding does at plan time what would otherwise be done once per row
  • Pushing a predicate down makes it run where it throws rows away earliest
  • Every rewrite is safe only under conditions; checking them is the whole job
Where this stage sits in the engine
Talking to the user
REPL
Sessions
Printer
Server
Wire protocol
Understanding SQL
Lexer
Parser
Validator
Answering a query
Planner
Executor
Keeping changes safe
TransactionsWrite-ahead log
Undo log
Lock manager
Read views
Storing rows
Row formatSlotted pageB+Tree
Reaching the disk
PagerDatabase file
this stagebuilt alreadystill to comeA statement enters at the top; rows come back up.

Why this stage exists

Until now the planner turned SQL into a physical plan in one step, reading the WHERE for key and index bounds as it went. Real planners split that in two. First a logical plan, which says only what is asked: these rows, from this table, filtered this way, grouped, sorted. Then rewrites, each of which turns the plan into another that returns exactly the same rows with less work. Only then do they choose scans and algorithms.

The split matters because the rewrites can be reasoned about one at a time. Each is a small rule with a condition that makes it safe. This stage adds three, each one visible in the counters.

The logical plan

explain logical shows the plan before anything clever happens:

explain logical select name from t where id = 2 + 3 and city = 'x'
Project (cols=[name])
Filter ((id = (2 + 3)) AND (city = 'x'))
↑ every row
Get (table=t)
explain select name from t where id = 2 + 3 and city = 'x'
Project (cols=[name])
Filter (city = 'x')
↑ 1 row
ran
IndexScan (table=t, key=5)
Folding 2 + 3 turns a conjunct the planner could not use into a key bound.

Rule 1: constant folding

id = 2 + 3 is a comparison with an expression, and before this stage an expression on the right was not a key bound: the planner could not use the key, and the table was scanned. But 2 + 3 reads no column, so its value is the same for every row. Compute it once, while planning, and the conjunct becomes id = 5.

Folding also finds conditions that are always true or never true. 2 > 1 is dropped. 1 = 0, or null = 1, which is NULL and so keeps no row, makes the whole WHERE impossible: the Filter and the scan become a single node, Empty, which hands up nothing and reads nothing. select count(*) from t where 1 = 0 still prints its one row, 0, because the aggregate above Empty still runs.

Two conditions keep it safe. A subexpression whose evaluation fails, like 1 / 0, is left alone: folding must not turn a statement that would never have reached it into an error. And folding stays out of the select list, because the headers are made from the expressions as typed.

Rule 2: HAVING pushdown

select dept, count(*) from emp group by dept having dept = 'eng';

The HAVING runs after grouping: every department is grouped, counted, and then all but one thrown away. But dept = 'eng' reads only the grouping column, and calls no aggregate, so it gives the same answer for a row as for that row's group. Move it into the WHERE and only engineering rows are grouped at all. groups= drops from 3 to 1, and the moved conjunct may even become an index bound.

having count(*) > 1 cannot move: it is about the group, which does not exist yet in the WHERE.

Rule 3: index condition pushdown

With an index on (city, age):

select name from t where city = 'x' and age % 2 = 0;

The scan uses city = 'x'. age % 2 = 0 cannot be a range, so it lands in the Filter above, and the scan looks up every row of city x in the table only for half of them to be dropped. But age is in the index entry. Test the condition on the entry, before the lookup, and the dropped rows are never fetched:

[PLAN]   └── IndexScan (table=t, index=ca, key=('x'), filter: (age % 2) = 0)
[EXEC] rows_examined=5 index_lookups=5

With --no-rewrite the same query examines 10 rows and makes 10 lookups.

The condition: every column the conjunct reads must be in the entry, meaning the index's columns or the table key.

--no-rewrite

Every rule must leave the rows exactly as they were. --no-rewrite turns all three off, and the tests run each query both ways and compare. A rewrite that changes a result is a bug, however much faster it is.

The algorithm in words

1

Build the logical plan

The plan before any choice is made.

  1. 1.Make a Get for the table: no key, no index, no range.
  2. 2.Put the whole WHERE, as typed, in one Filter above it.
  3. 3.Add the aggregate (as Aggregate, keys and aggs), the HAVING Filter, Project, Distinct, Sort and Limit as the earlier stages place them.
  4. 4.explain logical prints this tree and stops.
2

Rewrite

Fold and push the HAVING down, choose the scan as the earlier stages do, then push into the index.

  1. 1.Fold: in the WHERE and the HAVING, evaluate every subexpression that reads no column and calls no aggregate; leave one whose evaluation fails as it was. Drop conjuncts that became true. If one became false or NULL, replace the Filter and the Get with Empty.
  2. 2.Push HAVING down: move each HAVING conjunct with no aggregate that reads only GROUP BY columns to the end of the WHERE.
  3. 3.Choose the scan as before, from the rewritten WHERE.
  4. 4.Push into the index: if the scan is an IndexScan of an index, move each remaining conjunct that reads only the index's columns and the table key into the scan, to be tested on the entry before the lookup.

Output contract for this stage

These are the exact lines the tests check. The full definition is on the output contract page, section 62.

InputOutput
explain logical select name from t where id = 2 + 3 and city = 'x';Project (cols=[name]), Filter ((id = (2 + 3)) AND (city = 'x')), Get (table=t)
explain select name from t where id = 2 + 3 and city = 'x';Project (cols=[name]), Filter (city = 'x'), IndexScan (table=t, key=5)
explain select name from t where 1 = 0;Project (cols=[name]), Empty
explain select name from t where city = 'x' and age % 2 = 0;IndexScan (table=t, index=ca, key=('x'), filter: (age % 2) = 0) under the Project
explain select dept, count(*) from emp group by dept having dept = 'eng';HashAggregate (...) over Filter (dept = 'eng') over SeqScan (table=emp)
explain logical select dept, count(*) from emp group by dept;Project (cols=[dept, COUNT(*)]), Aggregate (keys=[dept], aggs=[COUNT(*)]), Get (table=emp)
explain logical delete from t;[ERROR:00302] <message>
droid --no-rewritethe plans of the cost-model stage; the same rows

What you should see when done

shell
$droid --db rewrite.db
Welcome to droid!
>create table t (id int, name varchar(10));
Create table statement executed successfully
>insert into t (id, name) values (1, 'ada');
Insert statement executed successfully (1 rows)
>insert into t (id, name) values (2, 'bob');
Insert statement executed successfully (1 rows)
>select name from t where id = 1 + 1;
name
bob
(1 rows)
[EXEC] rows_examined=1
>select name from t where 1 = 0;
name
(0 rows)
[EXEC] rows_examined=0
>explain logical select name from t where id = 1 + 1;
[PLAN] select name from t where id = 1 + 1
[PLAN] └── Project (cols=[name])
[PLAN] └── Filter (id = (1 + 1))
[PLAN] └── Get (table=t)
>.exit
exiting.. good bye!

Common bugs in this stage

  • Folding an expression that fails. where id = 1 / 0 on an empty table prints no error; folding it would.
  • Folding the select list. select 1 + 1 from t must keep its header 1 + 1.
  • Treating NULL as false only at the top. A conjunct that folds to NULL keeps no row, just like false.
  • Pushing a HAVING that reads an aggregate, or a column that is not grouped. It means something else in the WHERE.
  • Pushing a condition into the index that reads a column the entry lacks. The entry cannot answer it.
  • A rewrite that changes row order. Compare with --no-rewrite: same rows, same order.

Language notes

The page above is the same for every language. These are the only language-specific hints.

  • C: write the rules as functions that take a plan node and return a (possibly new) node; free what they replace.
  • C++: a visitor over the plan tree keeps each rule in one place.
  • Rust: a rule is fn(Plan) -> Plan; match on the node shape makes each condition explicit.
  • Zig: a tagged union for plan nodes and a switch per rule; allocate new nodes from the statement's arena.

Checklist

One line per thing the tests check. The tags name the test cases.

  • A constant expression on the key is folded, so the key's IndexScan reads one row
    constant-folding
  • A WHERE that can never hold reads nothing
    always-false
  • A HAVING on a grouping column filters before grouping: one group formed instead of three
    having-pushdown
  • A condition on an index column is tested on the entry, before the row is looked up
    index-condition-pushdown
  • explain logical mirrors the SQL; explain shows what the rewrites and the planner made of it
    logical-vs-physical
  • explain after the rewrites: Empty, a pushed HAVING, a filter inside the index scan, an OR left alone
    rewrite-plans