Create and drop secondary indexes whose entries are a column's value followed by the row's key, fill one from an existing table, record it in the catalog, and plan an equality on an indexed column as an IndexScan that looks each row up in the table.
A table's B+Tree answers one question fast: which row has this key? Every other question, where name = 'n250' included, reads every leaf. On a table of fifty leaves that is fifty page reads to find one row.
An index answers the same kind of question for another column. It is a second B+Tree, kept beside the table, ordered by that column. Find the value in it, and it tells you where the row is.
The interesting part is what "where the row is" means.
The obvious answer is a page and slot: "the row is in page 37, slot 4". It is also wrong, for an engine like this one. Rows live inside the table's B+Tree, and they move: a leaf split sends half of them to a new page, a merge sends them back, VACUUM renumbers pages, a long row spills to overflow pages. An index full of page numbers would be wrong after the next insert.
What does not move is the row's key. So an index entry is the indexed value followed by the row's table key, and nothing else:
Putting the table key inside the entry's key, rather than after it as a payload, solves duplicates before they appear. Two rows with the same name make two entries that differ in their table key, so no two entries are ever equal, an index never needs a list of rows per value, and deleting one row later removes exactly one entry.
Entries compare left to right: by value first (INT as unsigned numbers, VARCHAR as bytes with a prefix before the longer string, NULL before everything), then by table key.
A lookup through an index goes down two trees:
That second descent is the price of an index that stores keys, and it is paid once per matching row. For one match it is nothing. For half the table it is far more than a full scan, and the optimizer stages of this part are about knowing which case you are in. For now the rule is simple: an equality on an indexed column uses the index.
The [EXEC] line shows both descents' work. rows_examined counts the rows the scan handed up, as always: 1, not 500. A new field, index_lookups, counts the table lookups made through entries. They are equal in this stage; a later stage makes the second one zero.
An index is recorded in the catalog, next to its table: its name, table, column and root page. That one decision makes the rest of the engine handle it for free:
Index names are global, like table names in their own name space: create index idx on a (x) and create index idx on b (y) cannot both exist.
Check everything first, then allocate, fill, and record.
The table's own key still comes first; an index is the next choice; a full scan the last.
Two descents for every row it hands up.
These are the exact lines the tests check. The full definition is on the output contract page, section 54.
| Input | Output |
|---|---|
create index idx on t (name); | Create index statement executed successfully |
drop index idx; | Drop index statement executed successfully |
indexes | [INDEX] Indexes: 1, then [INDEX] idx on t (name) root=<page> |
index check idx | [INDEX] Check idx: OK entries=500 |
select id from t where name = 'n250'; | the row, then [EXEC] rows_examined=1 index_lookups=1 |
explain select * from s where name = 'bob'; | [PLAN] └── IndexScan (table=s, index=idx, key='bob') |
explain select * from s where id = 4 and name = 'bob'; | Filter (name = 'bob') over IndexScan (table=s, key=4): the table key wins |
explain create index idx on s (name); | [PLAN] └── CreateIndex (index=idx, table=s) |
tokenize create index idx on t (name) | [KEYWORD_CREATE - create] [KEYWORD_INDEX - index] [IDENTIFIER - idx] [KEYWORD_ON - on] [IDENTIFIER - t] [SYMBOL - (] [IDENTIFIER - name] [SYMBOL - )] |
ast create index idx on t (name); | Statement: CREATE INDEX, Index: idx, Table: t, Columns: [name] |
ast drop index idx; | Statement: DROP INDEX, Index: idx |
explain drop index idx; | [PLAN] └── DropIndex (index=idx) |
500 rows of about 420 bytes, reopened with --cache-pages 4 | a lookup through the index reads at most 14 pages (page 0 and the catalog included); where pad = 'y' reads more than 80 |
| an index name already used | [ERROR:01602] <message> |
DROP INDEX or index check of a missing name | [ERROR:01603] <message> |
| a value of more than 255 bytes to index | [ERROR:01604] <message> |
index and on are keywords. Only = on an indexed column uses the index in this stage: name > 'b' is still a Filter over a SeqScan.
$droid --db shop.dbWelcome to droid!>create table s (id int, name varchar(20), n int);Create table statement executed successfully>insert into s (id, name, n) values (1, 'ada', 3);Insert statement executed successfully (1 rows)>insert into s (id, name, n) values (2, 'bob', 5);Insert statement executed successfully (1 rows)>insert into s (id, name, n) values (3, 'cy', 3);Insert statement executed successfully (1 rows)>select id from s where name = 'cy';id3(1 rows)[EXEC] rows_examined=3>create index idx on s (name);Create index statement executed successfully>select id from s where name = 'cy';id3(1 rows)[EXEC] rows_examined=1 index_lookups=1>explain select id from s where name = 'cy';[PLAN] select id from s where name = 'cy'[PLAN] └── Project (cols=[id])[PLAN] └── IndexScan (table=s, index=idx, key='cy')>index check idx[INDEX] Check idx: OK entries=3>.exitexiting.. good bye!
The page above is the same for every language. These are the only language-specific hints.
trait KeyOrder with one implementation for table keys and one for index entries keeps one tree implementation; Ordering::then composes value and key.comptime parameter to the tree, or store one in the tree handle.