IN and BETWEEN: Shorthand Predicates
Add two predicates by rewriting them into comparisons the engine already has
Parse IN and BETWEEN, with and without NOT, and rewrite each into the comparisons, AND and OR it stands for, so that printing, checking, three-valued logic and key ranges all work on it unchanged.
- A shorthand predicate is a rewrite: the parser builds the tree the long form would have built
- BETWEEN is two comparisons joined by AND, and includes both ends
- IN is equalities joined by OR, so a NULL in the list makes NOT IN keep nothing
- IN and BETWEEN stand where a comparison stands: tighter than NOT, looser than arithmetic
- A rewrite reuses everything built after the parser: checks, evaluation and the key bounds of the plan
Why this stage exists
where age >= 30 and age <= 45 and where dept = 'eng' or dept = 'hr' or dept = 'ops' already work. People write them all the time, and SQL has a shorter way to say each: age between 30 and 45 and dept in ('eng', 'hr', 'ops').
Neither one needs anything new below the parser. A BETWEEN is two comparisons and an AND; an IN is a chain of equalities and ORs. So this stage teaches a technique as much as a feature: rewriting. The parser reads the short form and builds the tree of the long one. Everything after it (the checks, the evaluator, the NULL rules of the previous stage and the key bounds of the planner) sees an ordinary expression and works unchanged.
The rewrite
The operand appears once in what was typed and once per comparison in the tree, so it is copied. a + 1 between 2 and 3 becomes ((a + 1) >= 2) AND ((a + 1) <= 3). ast prints the rewritten tree, by the printing rules of the expressions stage, so you can see exactly what your parser built.
Where the predicate sits in the grammar matters. It stands at the level of the comparisons, one per level, as IS NULL does: arithmetic binds tighter, so a + 5 between 20 and 35 asks about a + 5, and NOT is looser, so not a in (10, 20) negates the whole IN. The AND inside a BETWEEN belongs to it: a between 10 and 30 and c = 1 is the BETWEEN, then AND c = 1. That is why the low and high bounds are parsed at the arithmetic level and not as full expressions, which would swallow the second AND.
What the rewrite gives for free
Both ends are included, because the comparisons are >= and <=. And between 50 and 20 keeps nothing: no value is both at least 50 and at most 20. Some engines swap reversed ends; SQL does not, and neither does droid.
NULLs follow the previous stage's tables. A NULL operand makes every comparison unknown, so neither c between 1 and 2 nor c not between 1 and 2 keeps that row. The surprising case is a NULL in an IN list:
c | c in (1, NULL) | c not in (1, NULL) |
|---|---|---|
| 1 | true OR unknown = true | NOT true = false |
| 2 | false OR unknown = unknown | NOT unknown = unknown |
| NULL | unknown | unknown |
c not in (1, NULL) keeps no row at all. It is one of the best-known traps in SQL: a NOT IN over a list that happens to hold a NULL silently returns nothing. With a rewrite you do not have to remember it; it falls out of the OR and NOT tables you already built.
The key range is found by the planner you already have. A BETWEEN on the table's first column becomes two conjuncts, id >= 2 and id <= 3, and each is a key bound, so the plan is a range IndexScan. An IN is an OR, and an OR is never a key bound, so it stays a Filter over a SeqScan:
The algorithm in words
Parse the predicate
IN and BETWEEN sit at the level of the comparisons, where IS NULL already is. After the left operand, look at the next word.
- 1.Parse the left operand as for a comparison: arithmetic and tighter.
- 2.If the next word is NOT, remember it and take it; then the word after it must be IN or BETWEEN, or the line is 00302.
- 3.BETWEEN: parse a low operand, the keyword AND, and a high operand, each arithmetic and tighter. That AND belongs to the BETWEEN, not to the WHERE.
- 4.IN: expect (, then one or more operands separated by commas, then ). An empty list or a trailing comma is 00302.
- 5.After the predicate, another comparison, IS, IN or BETWEEN at the same level is 00302, as two comparisons in a row already are.
Rewrite it
Build the tree the long form would have built, and hand it on. Nothing after the parser needs to know the shorthand existed.
- 1.BETWEEN low AND high becomes (operand >= low) AND (operand <= high).
- 2.IN (x1, x2, x3) becomes ((operand = x1) OR (operand = x2)) OR (operand = x3), grouped from the left; a list of one item is a single comparison.
- 3.The operand goes into every comparison. Give each comparison a copy of it, so no later step finds one node in two places.
- 4.With NOT, put a NOT over the whole rewritten tree.
Output contract for this stage
These are the exact lines the tests check. The full definition is on the output contract page, sections 40 to 42.
| Input | Output |
|---|---|
tokenize a not between 1 and 2 | [IDENTIFIER - a], [KEYWORD_NOT - not], [KEYWORD_BETWEEN - between], [NUMBER - 1], [KEYWORD_AND - and], [NUMBER - 2] |
tokenize id in (1, 2) | [IDENTIFIER - id], [KEYWORD_IN - in], [SYMBOL - (], [NUMBER - 1], [SYMBOL - ,], [NUMBER - 2], [SYMBOL - )] |
ast select * from t where a between 1 and 5; | Where: (a >= 1) AND (a <= 5) |
ast select * from t where a not between 1 and 5; | Where: NOT ((a >= 1) AND (a <= 5)) |
ast select * from t where a in (1, 2, 3); | Where: ((a = 1) OR (a = 2)) OR (a = 3) |
ast select * from t where a in (7); | Where: a = 7 |
ast select * from t where a not in (1, 2); | Where: NOT ((a = 1) OR (a = 2)) |
ast select * from t where a + 1 between 2 and 3; | Where: ((a + 1) >= 2) AND ((a + 1) <= 3) |
explain select * from t where id between 3 and 5; | [PLAN] └── IndexScan (table=t, range: id >= 3 AND id <= 5) |
explain select * from t where id in (2, 4); | [PLAN] └── Filter ((id = 2) OR (id = 4)) over [PLAN] └── SeqScan (table=t) |
in (), in 1, in (1,), between 1, between 1 and, a not = 1, a in (1) = c | [ERROR:00302] <message> |
| an unknown column; mixed types; a number past 4294967295 | [ERROR:00402], [ERROR:00403], [ERROR:00404] <message>, as for the comparisons |
in and between are keywords. There is no new error code: every mistake is a syntax error or the error the rewritten comparisons would give.
What you should see when done
$droid --db people.dbWelcome to droid!>create table p (id int, name varchar(20), dept varchar(10), age int);Create table statement executed successfully>insert into p (id, name, dept, age) values (1, 'ada', 'eng', 36);Insert statement executed successfully (1 rows)>insert into p (id, name, dept, age) values (2, 'bob', 'ops', 41);Insert statement executed successfully (1 rows)>insert into p (id, name, age) values (3, 'cy', 29);Insert statement executed successfully (1 rows)>insert into p (id, name, dept, age) values (4, 'dan', 'hr', 52);Insert statement executed successfully (1 rows)>select name from p where age between 30 and 45;nameadabob(2 rows)[EXEC] rows_examined=4>select name from p where dept in ('eng', 'hr');nameadadan(2 rows)[EXEC] rows_examined=4>select name from p where dept not in ('eng', NULL);name(0 rows)[EXEC] rows_examined=4>ast select * from p where dept not in ('eng', 'hr');Statement: SELECTTable: pColumns: [*]Where: NOT ((dept = 'eng') OR (dept = 'hr'))>explain select name from p where id between 2 and 3;[PLAN] select name from p where id between 2 and 3[PLAN] └── IndexScan (table=p, range: id >= 2 AND id <= 3)>.exitexiting.. good bye!
Cy's department is NULL, so the IN leaves that row out, and so would any NOT IN. The NOT IN with a NULL in its list keeps nobody, not even bob, whose department is in neither. The messages are yours to word; the tests check only the codes.
Common bugs in this stage
- The BETWEEN's AND taken by the WHERE. Parsing the high bound as a full expression reads
2 and c = 1as one operand, and the type check fails. Parse the bounds at the arithmetic level. - Exclusive ends.
>and<instead of>=and<=drop the rows on the boundary. - Swapping reversed ends.
between 50 and 20must keep nothing. - NOT IN as "not equal to any item" in two-valued logic. Checking
!=against each item and ignoring unknowns makesc not in (1, NULL)keep every row but 1. Build the OR and the NOT and the tables do the rest. - One operand node shared by every comparison. It prints correctly, but any step that marks or rewrites a node, such as taking key bounds out of the WHERE, can then change two places at once. Copy it.
- NOT bound to the wrong thing.
a not in (...)andnot a in (...)mean the same;a not = 1is a syntax error, notNOT (a = 1).
Language notes
The page above is the same for every language. These are the only language-specific hints.
- C: write a small function that deep-copies an expression node and its children; free each copy with the tree, as any other node.
- C++: a
std::unique_ptrtree cannot share a child; aclone()method on the node type makes the copies explicit. - Rust: derive
Cloneon the expression enum,Boxchildren included, and call.clone()on the operand for each comparison. - Zig: copy the operand with the same allocator as the tree, so freeing the tree frees the copies.
Checklist
One line per thing the tests check. The tags name the test cases.
- BETWEEN keeps both ends, and nothing when the ends are reversedbetween-inclusive
- NOT BETWEEN keeps the rest, and a NULL operand is in neithernot-between-and-nulls
- IN keeps the rows equal to any itemin-list
- NOT IN keeps the rows equal to no item, and never a NULL operandnot-in
- A NULL in the list: IN still finds matches, NOT IN keeps nothingnull-in-list
- IN and BETWEEN stand where a comparison doesprecedence
- An empty list, a missing AND or parenthesis, a stray NOT: 00302syntax-errors
- Names, types and numbers are checked as for the comparisons they stand forcheck-errors
- UPDATE and DELETE take IN and BETWEEN in their WHEREupdate-delete
- in and between are keywords; ast prints the comparisons they are rewritten intotokenize-ast-rewrite
- BETWEEN on the key is a range IndexScan; IN is a Filterkey-range-plan