B+Tree Search and SELECT
Keep the leaf sorted, find a key without looking at every cell, and read the table back
Keep the cells of the leaf in key order, find any key with binary search, reject duplicate keys, and answer SELECT by walking the table with a cursor.
- A sorted leaf: every insert lands in its ordered position, not at the end
- Binary search: halve the candidates at each step, and get the insertion position for free when the key is absent
- Primary key uniqueness: the same search that finds a key is the one that rejects a duplicate
- The cursor: one small object that knows where you are in the table, so SELECT never touches page layout
- Keys are unsigned: 4294967295 is the largest id, not -1
Why this stage exists
At the end of the previous stage, INSERT appended each new cell after the last one. The rows are stored, but the table cannot answer the two questions a table exists to answer: is this key here? and what is in the table? Answering the first by reading every cell works on eight rows and fails on eight million. And the next stage, splitting a full leaf in two, only makes sense if the cells are in order: "the lower half goes left, the upper half goes right" means nothing on an unsorted page.
So this stage makes one promise and builds two features on it. The promise: the keys in a leaf are always in ascending order. The features: finding a key quickly, and reading the whole table back in order.
What changes in the leaf
Nothing changes in the page layout from the previous stage. What changes is where a new cell goes. Insert key 2 into a leaf that holds keys 1 and 3:
Each cell is a 4-byte key followed by the 60-byte row, and the row moves with its key: btree dump shows only the keys, so a leaf whose keys moved without their rows looks right there and is wrong everywhere else. The tests read the file to catch it. The source and the destination of that move overlap, so use whatever your language offers for moving overlapping memory safely; a plain forward copy will overwrite cells before it has moved them.
Why binary search, and why it returns a position
In a sorted leaf, one comparison with the middle key rules out half of the cells. Eight cells need at most four comparisons; a real leaf of 63 cells needs at most six. A linear scan would need up to 63.
Compare keys as unsigned 32-bit numbers, the way they are stored. Read as signed, 4294967295 is −1 and sorts before 0.
The detail that makes the rest of the stage easy: when the key is not there, the search does not just say "no". The place where it gave up is the place where the key belongs. That single result is what btree find reports, what INSERT uses to reject a duplicate, and what INSERT uses to pick the slot. Write the search once and have all three call it.
The position is also what btree find reports as cell: where the key is at the moment you ask, not where it was first written. Key 3 inserted first and followed by keys 1 and 2 is in cell 2.
Why a cursor
SELECT could loop over the cells of page 0 directly, and today that would work. It stops working in btree-leaf-split, when the table spans many leaves. A cursor puts the question "what is the next row?" in one place. Today its answer is "the next cell". Later it becomes "the next cell, or the first cell of the next leaf", and SELECT will not change by a single line.
SELECT, from statement to lines
The parser has been giving you the select list since the parser stage. SELECT is where it first reaches the output. * means all three columns in table order; select email, id from users; means those two, in that order, in the header and in every row. Check the names before reading any row: a table other than users is 00401 and a column users does not have is 00402, the same codes an INSERT gets, and the error is the only line printed.
In this stage SELECT has no WHERE: it returns every row. Filtering comes in its own stage later in this part.
The algorithm in words
Find a key, or the place where it belongs
One search serves three callers: btree find, the duplicate check in INSERT, and the choice of slot for a new cell. It must answer two things at once: is the key here, and at which position.
- 1.Consider all the cells of the leaf as candidates.
- 2.Compare the wanted key with the key of the middle candidate. If they are equal, the key is found at that position.
- 3.If the wanted key is smaller, only the candidates before the middle remain. If it is larger, only those after it remain.
- 4.Repeat until a key matches or no candidates remain.
- 5.When nothing remains, the position where the search stopped is exactly where the key would have to be inserted to keep the leaf sorted. Return it together with 'not found'.
- 6.Invariant to check yourself against: with n cells the search makes at most about log2(n) + 1 comparisons, and the returned position is always between 0 and n inclusive.
Insert in order
Appending was enough in the previous stage. Now every insert must leave the leaf sorted.
- 1.Search for the key. If it is found, stop: report the duplicate key error and change nothing.
- 2.Otherwise the search gave you the position for the new cell.
- 3.Make room: every cell from that position onward moves one cell to the right. Cells before it do not move.
- 4.Write the key and the serialized row into the freed position, and increase the cell count.
- 5.Invariants: after every insert the keys read in strictly increasing order; the cell count grew by exactly one; no existing row changed.
Walk the table with a cursor
SELECT should not know how a page is laid out. A cursor hides it behind three operations.
- 1.Start: position the cursor at the first cell of the table. On an empty table the cursor starts already at the end.
- 2.Read: give back the row under the cursor, decoded with the row format from Row Serialization.
- 3.Advance: move to the next cell. When there is no next cell, the cursor is at the end.
- 4.Before reading anything, check the statement: the table must be users (00401) and every column in the select list one of its columns (00402).
*stands for id, name and email, in that order. - 5.SELECT prints the header, the selected column names joined by ' | ', then reads and advances until the cursor is at the end, printing the selected values of each row in the same order and counting rows, then prints the footer.
- 6.Invariant: because the leaf is sorted, the rows come out in ascending id order without any sorting step.
Output contract for this stage
These are the exact lines the tests check. The full definition is on the output contract page, sections 3, 9 and 10.
| Input | Output |
|---|---|
btree find <key>, key present | [BTREE] Find key=<k>: FOUND (page=<p> cell=<i>) then [BTREE] Row: id=<id> name='<name>' email='<email>' |
btree find <key>, key absent | [BTREE] Find key=<k>: NOT_FOUND |
btree find with no key, a key that is not digits, or past 4294967295 | [ERROR:00104] <message> |
select * from users; | the header id | name | email, one line per row as <id> | <name> | <email>, then (<n> rows) |
select email, id from users; | the header email | id, one line per row as <email> | <id>, then (<n> rows) |
SELECT from a table that is not users | [ERROR:00401] <message> |
SELECT of a column users does not have | [ERROR:00402] <message> |
| INSERT of an existing key | [ERROR:00701] <message>, one line, and nothing else |
The footer always says rows, also for (0 rows) and (1 rows). An empty table still prints the header. Strings print as stored, without the zeros that pad them: an empty name is name='' in btree find. Cells are numbered from 0. In this stage the page is always 0.
What you should see when done
$droid --db users.dbWelcome to droid!>select * from users;id | name | email(0 rows)>insert into users (id, name, email) values (3, 'carol', '[email protected]');Insert statement executed successfully>insert into users (id, name, email) values (1, 'alice', '[email protected]');Insert statement executed successfully>insert into users (id, name, email) values (2, 'bob', '[email protected]');Insert statement executed successfully>btree dump[BTREE] Page 0: type=LEAF num_cells=3[BTREE] Cell 0: key=1 (60 bytes)[BTREE] Cell 1: key=2 (60 bytes)[BTREE] Cell 2: key=3 (60 bytes)>btree find 3[BTREE] Find key=3: FOUND (page=0 cell=2)>btree find 9[BTREE] Find key=9: NOT_FOUND>insert into users (id, name, email) values (1, 'mallory', '[email protected]');[ERROR:00701] Key 1 already exists>select * from users;id | name | email1 | alice | [email protected]2 | bob | [email protected]3 | carol | [email protected](3 rows)>select name, id from users;name | idalice | 1bob | 2carol | 3(3 rows)>.exitexiting.. good bye!
Three things to notice. The rows were inserted as 3, 1, 2 and come back as 1, 2, 3. Key 3 was inserted first and is reported in cell 2, because the two later inserts moved it. And after the rejected insert, row 1 is still alice. The error messages are yours to word; the tests check only the codes.
Common bugs in this stage
- The duplicate check runs after the insert. The error is printed, but the second row is already in the leaf, or the success message is printed as well. Search first, and return before touching the page.
- Searching an empty leaf. With zero cells there is no middle cell to read. The search must answer "not found, position 0" without reading anything.
- Off by one at the ends. Test inserting a key smaller than every key in the leaf and a key larger than every key. The positions must be 0 and n.
- The move overwrites cells. A forward, cell-by-cell copy to the right destroys the cells it has not moved yet. Move from the last cell backwards, or use an overlap-safe move.
- Moving the wrong amount. The cells to move are those from the insertion position to the end; the distance is one cell, 64 bytes, not 60 and not 4.
btree findprints the row from the wrong cell. If FOUND reportscell=2but the row printed is another one, the search result and the read are using different positions.- SELECT forgets the empty table. A cursor on an empty table must start at the end, so the loop body never runs, and the header and
(0 rows)are still printed. - Comparing keys as signed numbers. 4294967295 lands in cell 0, before key 0. Keys are unsigned.
- Moving the keys but not the rows. The dump looks right, and
btree findprints the wrong row for a key. A cell is 64 bytes, key and row, and moves as one. btree findreports where the key was written. The cell is the key's position now. Inserting a smaller key moves it.- Ignoring the select list. Every SELECT prints all three columns. The header and each row follow the list, in its order.
- Sorting inside SELECT. If SELECT sorts, the leaf is not sorted and
btree dumpwill show it. Fix the insert, not the output.
Language notes
The page above is the same for every language. These are the only language-specific hints.
- C and C++:
memmoveis the overlap-safe move;memcpyis not. - Rust:
slice::copy_withinmoves a range inside one buffer and handles overlap. - Zig:
std.mem.copyBackwardsis the one to use when the destination is to the right of the source.
Checklist
One line per thing the tests check. The tags name the test cases.
- Keys inserted out of order are stored in order, each row moving with its keybtree-sorted-insert
- A leaf filled in any order holds its 8 keys in order, and SELECT returns them in that orderbtree-sorted-full-leaf
- Keys compare as unsigned numbers: 4294967295 goes lastbtree-sorted-unsigned
btree find <key>reports FOUND with the page and cell, then the rowbtree-find-existing · btree-find-after-multiple- The cell
btree findreports is where the key is now, after the inserts that moved itfind-reports-cell - An empty string prints as '' in the Row linebtree-find-id-only
btree find <key>reports NOT_FOUND on an empty tree, and below, between and above the keys of a populated onebtree-find-missing · find-missing-populatedbtree findwith no key, a key that is not digits or a key past 4294967295 is 00104btree-find-arguments- After a restart the keys are found in their cells, and a new key still goes in orderfind-after-reopen
- Inserting an existing key prints 00701 and no success line, and adds no cellbtree-duplicate-key-error
- A refused duplicate, in the same session or the next, leaves the original row untouchedduplicate-preserves-original
- SELECT on an empty table prints the header and
(0 rows)select-empty-table - SELECT prints the header, one line per row and the
(n rows)footerselect-after-insert · select-row-count - SELECT returns rows in ascending id order whatever the insertion orderselect-sorted-after-unordered-insert
- The header and the rows follow the select listselect-column-headers
- SELECT from an unknown table is 00401, of an unknown column 00402select-name-errors
- A SELECT run with -c prints the table and exits 0select-c-mode