droid
Expression Trees and Precedence
Querying · Stage 1 of 11

Expression Trees and Precedence

Turn a WHERE clause from one comparison into a tree of operators, and evaluate it row by row

Parse AND, OR, NOT, the six comparisons, arithmetic and parentheses into an expression tree with the right shape, check its types before reading a row, evaluate it for each row, and let the planner keep using the primary key for the bounds it can.

Not run yet · 19 tests
Concepts in this stage
  • An expression is a tree: operators are inner nodes, columns and literals are its leaves
  • Precedence decides the shape of the tree, and the shape decides the answer
  • Evaluation returns a typed value, never a bare boolean: an INT, a VARCHAR or a truth value
  • Types are checked once, before any row is read; division by zero can only be found row by row
  • AND and OR stop early when their left side already decides
  • Conjuncts: the parts of a WHERE joined by AND, and the ones a key range can answer
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

Since the where-filter stage a WHERE clause has been one column, one of =, < or >, and one value. Real queries need more: where dept = 'eng' and salary > 100, where not done, where price * qty > 1000. Every later stage of this part leans on the same machinery: ORDER BY can sort by an expression, aggregates sum one, HAVING filters with one. So this stage replaces the single comparison with something general: an expression tree.

The tree is the important word. A line of text has no structure, and a = 1 or b = 2 and c = 3 can be read two ways. Read left to right, it keeps a row where a = 1 only if c = 3 as well. Read the way SQL means it, AND binds tighter, so a row with a = 1 is kept whatever c is. The two readings are two different trees, and they give different rows:

what SQL means
operator
OR
operator
=
column
a
literal
1
operator
AND
operator
=
column
b
literal
2
operator
=
column
c
literal
3
left to right, wrong
operator
AND
operator
OR
operator
=
column
a
literal
1
operator
=
column
b
literal
2
operator
=
column
c
literal
3
The same tokens, two trees. The operator at the root is the one applied last. A row with a = 1 and c = 0 is kept by the tree on the left and dropped by the one on the right.

From text to tree

The lexer learns the new pieces: and, or and not become keywords, and <=, >=, !=, <>, +, -, / and % become symbols. A two-character symbol is one token only when its characters touch, so < = is two tokens and a syntax error. A - is always a token of its own: -1 is unary minus applied to the number 1, not a negative number. That keeps the lexer simple, and it means a-1 and a - 1 are the same thing.

The parser then reads tokens by precedence, from the loosest operator to the tightest:

LevelOperatorsExample
loosestORa = 1 or b = 2
ANDa = 1 and b = 2
NOTnot a = 1 is not (a = 1)
= != <> < > <= >=, one at a timea + 1 = b
+ -a - b - 1 is (a - b) - 1
* / %a + b * 2 is a + (b * 2)
unary --a * 2 is (-a) * 2
tightesta column, a number, a string, ( ... )

The idea behind the parser is in that table. Each level only ever builds operators of its own level, and reads its operands from the level just below it. So the loosest operator ends up at the root, and the tightest ones sit just above the leaves. This is often called precedence climbing or recursive descent by levels. Parentheses work by sending the parser back to the loosest level for what is inside them; they are gone from the tree once it is built.

Comparisons do not chain. a = 1 = 2 has a second comparison where an operand belongs, and is 00302. A comparison gives a truth value, and the next section says why (a = 1) = (b = 2) would not get far either.

Values have types

The old WHERE check answered yes or no. An evaluator for expressions cannot: a + b is not yes or no, it is a number. So evaluation gives a value, and a value has a type: an INT, a VARCHAR, or a truth value, the result of a comparison, AND, OR or NOT. The next stage adds a third truth value, unknown, and is much easier if the evaluator already returns values rather than booleans.

OperatorTakesGives
+ - * / %, unary -INTsan INT
comparisonstwo INTs, or two VARCHARsa truth value
AND OR NOTtruth valuesa truth value

The types of a tree do not depend on any row: a column's type is in the catalog, a literal's is written in the query. So they are checked once, before the first row is read, and a mistake is 00403 with nothing else printed. where name > 5 compares a VARCHAR with an INT; where a is an INT where a condition belongs. Both are caught even on an empty table.

What cannot be checked in advance is a value. a / b is fine until a row has b = 0, and then the statement stops with 01501. Arithmetic runs in signed 64-bit integers: a column holds 0 to 4294967295, but a - 5 may be negative and a * 2 may pass 4294967295 on the way to a comparison. A result outside 64 bits is 01502. Division drops the fraction towards zero, so -7 / 2 is -3, and the remainder takes the sign of the left side, so -7 % 2 is -1.

Evaluating, row by row

The scan hands up one row at a time, and the tree is evaluated from its root for each of them. A row is kept when the root gives true.

AND and OR evaluate their left operand first. When it already decides the answer, false for AND and true for OR, the right operand is not evaluated at all. That is what makes the guard in b != 0 and a / b = 2 work: on a row with b = 0, the division never happens.

A statement that stops with 01501 or 01502 prints its error line and nothing else, even if it had already found rows to print. Collect the rows, and print them only when the statement has finished. An UPDATE or DELETE that stops is undone by the statement rollback you built in Transactions and Recovery.

What the planner can still use

The planner of the index-scan stage turned where id > 25 into an IndexScan. It should keep doing that inside larger expressions, and it can, for one shape of tree: the parts of a WHERE joined by AND at the top. Each of those parts is a conjunct, and a row is kept only when every conjunct holds. So a conjunct that bounds the key can be answered by the B+Tree, and the rest checked by a Filter on the rows the tree finds:

id >= 3 and a = 40 and id <= 5
operator
AND
operator
AND
operator
>=
column
id
literal
3
operator
=
column
a
literal
40
operator
<=
column
id
literal
5
Three conjuncts. The two marked bounds become the range of the IndexScan; what is left of the tree, a = 40, becomes the Filter above it.

An OR at the top is a single conjunct, so id = 4 or id = 5 is still a Filter over a SeqScan. That is correct, just slower; the Indexes and the Optimizer part comes back to it.

The algorithm in words

1

Parse by precedence

One function per level of the precedence table, from the loosest operator to the tightest. Each level reads the level below it for its operands, so tighter operators end up deeper in the tree.

  1. 1.The loosest level is OR. It reads one operand from the AND level, then, as long as the next token is OR, reads another and joins the two under an OR node. Joining as you go makes the operators of one level group from the left.
  2. 2.AND does the same with the NOT level. NOT, when the next token is NOT, consumes it and wraps whatever the NOT level gives next; otherwise it hands over to the comparison level.
  3. 3.The comparison level reads an operand from the additive level and, if a comparison operator follows, reads exactly one more. A second comparison operator right after is a syntax error.
  4. 4.The additive and multiplicative levels work like OR and AND, with + and -, then *, / and %.
  5. 5.Unary minus consumes a - and wraps the next unary operand. The tightest level is a column, a number, a string, or a parenthesised expression, which starts again from the loosest level and must end with ).
  6. 6.Invariant: each operator's operands are made only of operators that bind at least as tightly, or of parenthesised expressions. Printing the tree with every inner operand in parentheses shows it.
2

Check the tree, then evaluate it

A query reads many rows and the tree is the same for each of them. Everything that does not depend on a row is decided once, before the first row.

  1. 1.Walk the tree and look up every column in the table. An unknown one is 00402, before anything else is checked.
  2. 2.Walk it again, giving each node its type after its operands have theirs: arithmetic takes INTs and gives an INT, a comparison takes two INTs or two VARCHARs and gives a truth value, AND, OR and NOT take truth values. The first operator given anything else is 00403, and so is a WHERE whose root is not a truth value.
  3. 3.Then check each number as typed against 0 to 4294967295 (00404).
  4. 4.For each row the scan hands up, evaluate the tree from the root. A leaf gives the row's value or the literal. Arithmetic works in signed 64 bits and stops the statement with 01502 when a result does not fit, and with 01501 when it divides by zero.
  5. 5.AND evaluates its left operand and, when it is false, gives false without looking at the right one. OR does the same with true.
  6. 6.Keep the row when the root gives true. A statement stopped by an error prints that error and nothing else, so hold the rows until the end of the statement.
3

Find the key bounds

The planner from the index-scan stage looked at one comparison. Now it looks at the conjuncts and takes from them what the B+Tree can answer.

  1. 1.Collect the conjuncts: start at the root, and while a node is an AND, look into both of its operands. Every node that is not an AND is a conjunct.
  2. 2.A key bound is a conjunct of the form: the table's first column, then =, <, >, <= or >=, then a number. Anything else stays a conjunct for the Filter.
  3. 3.If there is an = bound, the first one is the scan's key. Otherwise take the first lower bound and the first upper bound as the scan's range.
  4. 4.Rebuild what is left: remove the bounds the scan uses, replacing each AND that lost an operand by its other operand. If something is left, it becomes a Filter above the IndexScan.
  5. 5.Invariant: the rows printed are the same with or without the IndexScan. Only rows_examined and the plan change.

Output contract for this stage

These are the exact lines the tests check. The full definition is on the output contract page, sections 3, 39 and 40.

InputOutput
tokenize a<=1 and b<>-1[IDENTIFIER - a], [SYMBOL - <=], [NUMBER - 1], [KEYWORD_AND - and], [IDENTIFIER - b], [SYMBOL - <>], [SYMBOL - -], [NUMBER - 1]
ast select * from t where a = 1 or b = 2 and c = 3;Statement: SELECT, Table: t, Columns: [*], Where: (a = 1) OR ((b = 2) AND (c = 3))
explain select id from t where id >= 3 and a = 40;[PLAN] select id from t where id >= 3 and a = 40, [PLAN] └── Filter (a = 40), [PLAN] └── IndexScan (table=t, range: id >= 3)
an operator given the wrong types, or a WHERE that is not a condition[ERROR:00403] <message>
a division or remainder by zero, met on a row[ERROR:01501] <message>, and nothing else
a result outside the signed 64-bit range[ERROR:01502] <message>, and nothing else
a = 1 = 2, a missing ), a missing operand[ERROR:00302] <message>

How an expression is printed, in ast and in a Filter: a column as typed, a number as its digits, a string in single quotes; AND, OR and NOT in capitals; other operators as typed, with a space on each side; unary minus with no space. Every operand that is not a column or a literal goes between parentheses, and nothing else does. So a + b * 2 = 7 prints as (a + (b * 2)) = 7, and a single comparison prints as it always did: id = 5.

The plan of a WHERE: with no key bound, a Filter holding the whole WHERE over a SeqScan. Otherwise an IndexScan whose key is the first = bound, or whose range is the first lower and the first upper bound, lower first (range: id >= 3 AND id <= 5), with a Filter above it for whatever is left. rows_examined counts the rows the IndexScan hands up, as before.

What you should see when done

shell
$droid --db shop.db
Welcome to droid!
>create table t (id int, a int, b int, c int, name varchar(20));
Create table statement executed successfully
>insert into t (id, a, b, c, name) values (1, 1, 0, 0, 'ada');
Insert statement executed successfully (1 rows)
>insert into t (id, a, b, c, name) values (2, 0, 2, 3, 'bob');
Insert statement executed successfully (1 rows)
>insert into t (id, a, b, c, name) values (3, 0, 2, 0, 'cy');
Insert statement executed successfully (1 rows)
>select id from t where a = 1 or b = 2 and c = 3;
id
1
2
(2 rows)
[EXEC] rows_examined=3
>select id from t where (a = 1 or b = 2) and c = 3;
id
2
(1 rows)
[EXEC] rows_examined=3
>select name from t where a - 1 < 0 and id >= 2;
name
bob
cy
(2 rows)
[EXEC] rows_examined=2
>explain select name from t where a - 1 < 0 and id >= 2;
[PLAN] select name from t where a - 1 < 0 and id >= 2
[PLAN] └── Filter ((a - 1) < 0)
[PLAN] └── IndexScan (table=t, range: id >= 2)
>select id from t where b != 0 and c / b = 1;
id
2
(1 rows)
[EXEC] rows_examined=3
>select id from t where c / b = 1;
[ERROR:01501] Division by zero
>select id from t where name > 5;
[ERROR:00403] Cannot compare VARCHAR with INT
>.exit
exiting.. good bye!

Things to notice. Moving the parentheses changed which rows came back. a - 1 went below zero for rows 2 and 3, and that was fine. id >= 2 became the range of the scan, so only two rows were examined, while the rest of the WHERE became a Filter. The guarded division ran on every row without an error; the unguarded one stopped on row 1, where b is 0, and printed nothing but its error. The messages are yours to word; the tests check only the codes.

Common bugs in this stage

  • Every binary operator at one level. The parser reads left to right and builds ((a = 1) or (b = 2)) and (c = 3). Write one level per row of the precedence table.
  • Right-grouping subtraction. A level written as "operand, then operator, then the whole level again" builds a - (b - 1). Loop at each level, joining as you go.
  • NOT bound too tightly. If NOT reads only the next operand, not a = 1 becomes (not a) = 1, which is a type error. NOT takes a whole comparison.
  • The evaluator returns a boolean. It works today and breaks in the next stage. Return a value with a type.
  • Unsigned arithmetic. a - 5 on a row with a = 3 wraps to a huge number and a - 5 < 0 keeps nothing. Work in signed 64 bits.
  • Division that rounds down. -7 / 2 is -3 here. If your language's operator floors, or its remainder is always positive, fix the sign yourself.
  • Types checked per row. where name > 5 on an empty table must still be 00403. Check types before the first row.
  • Printing rows before an error. A division by zero on the fifth row must not leave four rows and a header on the screen.
  • No short-circuit. b != 0 and a / b = 2 fails with 01501. AND must not evaluate its right side when the left is false.
  • Parentheses kept in the tree. A "group" node makes ast print extra parentheses and the planner miss conjuncts written as (id >= 3) and a = 1. Parentheses only shape the tree.
  • A conjunct used twice. Taking id >= 3 as the range and leaving it in the Filter gives the right rows and the wrong plan line.

Language notes

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

  • C and C++: an expression node is a tagged struct: a kind, an operator, a value, and pointers to its operands. int64_t from <stdint.h> for arithmetic; __builtin_mul_overflow and its siblings (GCC and Clang) detect a result that does not fit. C's / and % already round towards zero.
  • Rust: an enum Expr with a Box<Expr> per operand is the natural tree. i64::checked_mul, checked_add, checked_sub and checked_div return None on overflow, and checked_div also on division by zero. / and % round towards zero.
  • Zig: a tagged union for the node, with pointers to operands allocated from an arena freed after each statement. @mulWithOverflow and friends detect overflow; @divTrunc and @rem are the rounding this page asks for.

Checklist

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

  • AND binds tighter than OR, whichever side it is written on
    prec-and-over-or
  • Parentheses change the tree and leave nothing in it
    parens-override
  • NOT applies to one comparison, not to the AND after it
    not-binds-tight
  • *, / and % bind tighter than + and -
    arith-prec
  • Operators of one level group from the left, and / drops the fraction
    arith-left-assoc
  • Arithmetic is signed: it goes below zero, and / and % round towards zero
    arith-signed
  • Arithmetic is 64-bit, and a result past it is 01502 with nothing else printed
    int-overflow
  • <= and >= include the value itself, on the key, on INT columns and on text
    lte-gte-rows
  • != and <> are one operator
    neq-both-spellings
  • Strings compare as strings wherever they stand: '10' < '9'
    varchar-lexicographic
  • An operator given the wrong types is 00403, and so is a WHERE that is not a condition
    type-mismatch
  • Names are checked before types, and types before number ranges
    check-order
  • Two comparisons in a row, a missing ) or a missing operand are 00302
    syntax-errors
  • Division by zero is 01501, prints nothing else, and an UPDATE or DELETE that meets it changes nothing
    div-zero
  • AND and OR leave their right side alone when the left decides
    short-circuit
  • UPDATE and DELETE select rows with the same expressions
    update-delete-where
  • and, or and not are keywords; the new operators are symbols, and -1 is two tokens
    tokenize-symbols
  • ast prints the WHERE as a tree, every inner operand in parentheses
    ast-expressions
  • The key bounds among the conjuncts make the IndexScan, the rest a Filter, with the counts to match
    key-range-plan