Use an index for <, >, <= and >= as well as =, hand rows up in the order of the index's entries, and get every comparison right: unsigned numbers, bytes with a prefix first, NULL in no range, and many rows with one value.
The previous two stages used an index for one question: which rows have exactly this value? An index can answer a better one. Its entries are sorted, so all the values from 'b' up to 'c' sit next to each other in its leaves. Find the first, walk along the leaf links, and stop at the first that is too big: that is a range scan, the same walk the table's own tree already does for id > 25.
This stage also settles the questions that were easy to skip while every lookup was one value: what happens when a value repeats three hundred times, what order strings and numbers really sort in, and where NULL goes.
An entry is the value followed by the table key, so three rows with city 'x' make three different entries, side by side, ordered by their keys:
So where city = 'x' was already a range: from the first entry with 'x' to the last. Nothing in the tree needs a list of rows per value, a DELETE removes exactly the entry of its row, and duplicates come back in key order for free.
A range scan hands its rows up in the order it meets their entries: by value, then by table key. For name >= 'b' and name < 'c' that is b, ba, bz, whatever their ids. Until now every SELECT without ORDER BY came back in key order; from this stage, one whose plan reads an index comes back in index order. That is not a bug to hide: no SQL engine promises any order without ORDER BY, and this one makes the order it gives part of its contract so the tests can read it.
The ORDER BY and GROUP BY shortcuts of the Querying part assumed rows in table key order, so a range scan of an index turns them off: ORDER BY on the table's key keeps its Sort, and GROUP BY on the table's key is a HashAggregate, not a SortAggregate. An = scan of an index still hands its rows up in key order, since its entries share one value.
An index is only correct if its idea of order is the same as the comparisons in the WHERE. Three traps, each in the tests:
| Values | Wrong order | Right order | Why |
|---|---|---|---|
256, 1, 255 | 256, 1, 255 (little-endian bytes) | 1, 255, 256 | INT compares as a number |
3000000000, 2 | 3000000000, 2 (signed) | 2, 3000000000 | INT is unsigned |
'b', 'B', 'ab', 'abc', '' | case-folded, or C strings | '', 'B', 'ab', 'abc', 'b' | VARCHAR compares bytes; a prefix comes first |
NULL sorts before every value in the index, so a scan from the start of the index meets the NULL entries first. But a NULL is never in a range: name < 'm' is unknown for a NULL name, exactly as the Filter would decide. So a range with no lower bound must start at the first entry that is not NULL. name is null is not an index bound at all in this part; it stays a Filter over a SeqScan.
In the create-index stage, name > 'b' was a Filter over a SeqScan; it is now a range IndexScan, so the earlier case that checked that plan is retired here.
The index-maintenance stage made every UPDATE find its rows first and change them after. Ranges are where that rule earns its keep:
update t set n = 20 where n > 5;
With an index on n, the scan walks forward from 6. Each row it changes gets n = 20 and a new entry further ahead in the same walk. A scan that changed rows as it went would meet them again and count them twice. Were SET to take n + 10, as other engines allow, it would never stop. Collect the keys first, then change the rows, and each changes once.
Key bounds still win, then an equality on an index, then a range on an index.
Seek once, then follow the leaf links.
These are the exact lines the tests check. The full definition is on the output contract page, section 56.
| Input | Output |
|---|---|
explain select * from t where name >= 'b' and name < 'c'; | [PLAN] └── IndexScan (table=t, index=idx, range: name >= 'b' AND name < 'c') |
explain select * from t where n < 3 and name > 'b'; (indexes on both) | Filter (name > 'b') over IndexScan (table=t, index=by_n, range: n < 3) |
explain select * from t where name > 'b' and n = 1; | Filter (name > 'b') over IndexScan (table=t, index=by_n, key=1) |
explain select * from t where name is null; | Filter (name IS NULL) over SeqScan (table=t) |
select id, name from t where name >= 'b' and name < 'c'; | the rows in name order, then [EXEC] rows_examined=3 index_lookups=3 |
There is no new syntax and no new code.
$droid --db names.dbWelcome to droid!>create table t (id int, name varchar(20), n int);Create table statement executed successfully>create index idx on t (name);Create index statement executed successfully>insert into t (id, name, n) values (1, 'ba', 1);Insert statement executed successfully (1 rows)>insert into t (id, name, n) values (2, 'B', 2);Insert statement executed successfully (1 rows)>insert into t (id, name, n) values (4, 'bz', 4);Insert statement executed successfully (1 rows)>insert into t (id, name, n) values (5, 'b', 5);Insert statement executed successfully (1 rows)>insert into t (id, name, n) values (6, 'c', 6);Insert statement executed successfully (1 rows)>select id, name from t where name >= 'b' and name < 'c';id | name5 | b1 | ba4 | bz(3 rows)[EXEC] rows_examined=3 index_lookups=3>select id, name from t where name < 'b';id | name2 | B(1 rows)[EXEC] rows_examined=1 index_lookups=1>.exitexiting.. good bye!
'B' is 0x42 and sorts before 'a', 0x61.update t set n = 20 where n > 5 meets its own new entries and counts them twice.The page above is the same for every language. These are the only language-specific hints.
memcmp on the shorter length, then the lengths, gives byte order with a prefix first; never strcmp, which stops at a zero byte.std::string_view::compare and std::lexicographical_compare on unsigned char both give byte order.[u8] implements Ord in exactly this order; compare byte slices, not str with any locale.std.mem.order(u8, a, b) returns byte order with a prefix first.