droid
CREATE TABLE and the Catalog
Flexible Storage · Stage 6 of 8

CREATE TABLE and the Catalog

Tables you define, described in a table the database keeps about itself

Add CREATE TABLE with INT and VARCHAR(n) columns. Keep every table's name, root page and columns in a catalog: a B+Tree like any table's, whose root the file header records. Give each table its own tree, encode its rows by its columns, and make every statement and debug command work on any table.

Not run yet · 17 tests
Concepts in this stage
  • DDL and DML: a statement that changes what the database can hold, and statements that change what it holds
  • A system catalog: the database describes its own tables in a table, as sqlite_master and pg_class do
  • One tree per table, each found through the catalog's record of its root
  • A row codec driven by a schema: the columns decide the bytes, not code written for users
  • The first column is the key: an INT, whatever it is called
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
Transactions
Write-ahead log
Undo log
Lock manager
Read views
Storing rows
Row format
Slotted page
B+Tree
Reaching the disk
Pager
Database file
this stagebuilt alreadystill to comeA statement enters at the top; rows come back up.

Why this stage exists

Every stage so far has worked on one table that nobody created. users, with its id, name and email, was written into the program: the row format knew its three columns, the parser knew their names, and the tree's root was wherever the program put it. A database that can only hold the table its author thought of is not much of a database.

This stage lets the user say what tables exist. CREATE TABLE products (id int, title varchar(40), price int); is a different kind of statement from the ones before it. INSERT, SELECT, UPDATE and DELETE change the rows a table holds; CREATE TABLE changes what the database can hold at all. The first kind is called DML, data manipulation; the second DDL, data definition.

A definition has to be kept somewhere, and survive a restart like the rows do. Real databases keep it where they keep everything else: in tables. SQLite has sqlite_master, PostgreSQL pg_class and pg_attribute. droid gets a catalog, a B+Tree of ordinary rows, one per table, stored with the same leaves, splits and pages as any table's.

What changes: the catalog

The catalog is a tree like any other, with rows of four columns: id INT, name VARCHAR(65535), root INT, columns VARCHAR(65535). Its root is the page in the file header's root field, the field that named the users root in the previous stage. It never changes, which is why the previous stage made roots stay put.

0
3f
00
00
00
04
00
00
01
00
00
00
08
00
70
14
72
6f
64
75
63
74
73
02
00
00
00
24
00
69
28
64
20
49
4e
54
2c
20
74
69
74
6c
65
20
56
42
41
52
43
48
41
52
28
34
30
29
2c
20
70
72
56
69
63
65
20
49
4e
54
  • row_sizebytes 0–3 (4 bytes) · 63
  • column_countbytes 4–5 (2 bytes) · 4
  • null_bitmapbytes 6–6 (1 bytes) · zero
  • idbytes 7–10 (4 bytes) · 1: the first table created
  • name_lenbytes 11–12 (2 bytes) · 8
  • namebytes 13–20 (8 bytes) · products
  • rootbytes 21–24 (4 bytes) · 2: the page of the table's root leaf
  • columns_lenbytes 25–26 (2 bytes) · 36
  • columnsbytes 27–62 (36 bytes) · id INT, title VARCHAR(40), price INT
The catalog row for products, created first on a new file. It is the row format of varlen-rows with four columns, keyed by its id in the catalog's leaf. The column list is text, written the way catalog list prints it; reading a table's schema means parsing it back.

Where things go. The first CREATE TABLE on a new file writes the header, page 0, then allocates the catalog's root leaf, page 1, and records it in the header. Every CREATE TABLE then allocates the new table's root leaf and writes the catalog row. So the first table's root is page 2, the second's page 3. Pages are still allocated one after the other from the one pager, so a page pager alloc takes in between moves the next root along: after it, the second table's root is page 4. The page commands work as before on a page page init made, and only there: the header and every tree leaf, the catalog's included, are 00606 to them. A catalog row names its table's root, and the table's tree is found through it, as the catalog is found through the header: page 0 leads to the catalog, the catalog to each table.

The catalog is not a table SQL can name. catalog list shows it:

[CATALOG] Tables: 2
[CATALOG]   users root=2 (id INT, name VARCHAR(1000), email VARCHAR(1000))
[CATALOG]   products root=3 (id INT, title VARCHAR(40), price INT)

Keeping the column list as text is what SQLite does: sqlite_master stores each table's CREATE statement and parses it again on open. PostgreSQL stores a row per column in pg_attribute. Text is less to build; one row per column is easier to change, which matters once ALTER TABLE arrives.

What changes: the schema drives everything

Checking a CREATE TABLE. Nothing changes until every check has passed, and the first that fails is the one reported: a table of that name already exists (01101); then each column in the order written, its type (01102) and its name, not used by an earlier column (00408); then the first column is INT (01103); then the catalog row fits in 1000 bytes (00407), since the catalog's rows obey the row limit like any table's. A type is INT or VARCHAR(n) with n from 1 to 65535, in any case: int, Int and INT are one type. Table and column names keep their case and are compared as bytes, so users and Users are two tables.

Rows. A row is encoded by its table's columns, in table order: an INT is 4 bytes, a VARCHAR is its length in 2 bytes and then its bytes, after the header of varlen-rows with the table's column count. The null bitmap in that header has one bit per column, rounded up to whole bytes: one byte for up to 8 columns, two for 9 to 16. It is still all zero, but its width moves every field after it, so a table of 9 columns starts its first column at byte 8. An INSERT may list its columns in any order, and may leave some out: an INT left out is 0, a VARCHAR the empty string. The first column cannot be left out (00406), because it is the key.

The key. The first column of every table is its primary key. Rows are kept in its order, it cannot repeat (00701), and a WHERE on it, with =, < or >, is an IndexScan, whatever it is called: IndexScan (table=orders, range: oid > 3).

Everything that said users. SELECT's header lists the table's own columns. btree find prints its Row: line with the table's column names, btree structure products shows that table's tree, and plans name the table. users is not special any more: it does not exist until a CREATE TABLE makes it, and a SELECT from it before then is 00401.

What changes: the language

create and table are keywords now:

InputOutput
tokenize CREATE table t (id int, s varchar(10));[KEYWORD_CREATE - CREATE], [KEYWORD_TABLE - table], [IDENTIFIER - t], [SYMBOL - (], [IDENTIFIER - id], [IDENTIFIER - int], … [IDENTIFIER - varchar], [SYMBOL - (], [NUMBER - 10], …
ast create table products (id int, title VARCHAR( 40 ), price Int);Statement: CREATE TABLE, Table: products, Columns: [id int, title VARCHAR(40), price Int]
explain create table t (id int);[PLAN] create table t (id int), [PLAN] └── CreateTable (table=t)

Type names are identifiers, not keywords: the parser accepts any identifier as a type, with an optional length, and whether the type exists is checked when the statement runs. So ast create table t (id blob); parses, and running it is 01102. A table with no columns, create table t ();, is 00302.

The algorithm in words

1

Parse CREATE TABLE

Two keywords and a column list; the types are checked later.

  1. 1.create and table become keywords. A type name is an identifier, optionally followed by ( a number ).
  2. 2.The statement is CREATE TABLE <name> (<column> <type>, ...); with at least one column. Anything else is 00302.
  3. 3.ast prints the table and each column with its type as typed, the length written without spaces.
2

Check, then create

Every check comes before any page changes, in the contract's order.

  1. 1.The table does not exist yet (01101). Then each column in the order written: its type is INT or VARCHAR(n) with n from 1 to 65535 (01102), and its name is not an earlier column's (00408). Then the first column is INT (01103). Then the catalog row fits in 1000 bytes (00407).
  2. 2.If the file has no catalog, allocate its root leaf and record it in the header. Then allocate the new table's root leaf.
  3. 3.Insert the catalog row: the next id (one more than the largest, 1 for the first), the name, the root, the column list. Print the success line.
  4. 4.Invariant: a table's catalog row, and so its root, never changes once it is written.
3

Use the schema everywhere

What was written for users now reads the catalog.

  1. 1.Find a table by name, as bytes, in the catalog. None of that name is 00401.
  2. 2.Encode a row by the table's columns in table order: an INT is 4 bytes, a VARCHAR its length in 2 bytes then its bytes. A column an INSERT leaves out is 0 or the empty string; the first cannot be left out (00406).
  3. 3.SELECT headers, btree find's Row: line, plans and the IndexScan all name the table's own columns; the key is the first column.

Output contract for this stage

The full definition is on the output contract page, sections 14 and 19.

InputOutput
a CREATE TABLE that succeedsCreate table statement executed successfully
catalog list[CATALOG] Tables: <n>, then per table in id order [CATALOG] <name> root=<page> (<column> <TYPE>, ...)
a table that exists[ERROR:01101] <message>
a type that is not INT or VARCHAR(n), n from 1 to 65535[ERROR:01102] <message>
a first column that is not INT[ERROR:01103] <message>
a column named twice[ERROR:00408] <message>
a catalog row over 1000 bytes[ERROR:00407] <message>
an INSERT without the first column[ERROR:00406] <message>
a table that does not exist[ERROR:00401] <message>

Earlier stages' tests still run from here, on users: the runner creates it first, with create table users (id int, name varchar(1000), email varchar(1000));, before each case that needs it. The cases whose pages that creation moves (the users root is now page 2, the header's root is the catalog) are retired here by name.

What you should see when done

On a new file:

droid session
Welcome to droid!
>catalog list
[CATALOG] Tables: 0
>select * from users;
[ERROR:00401] Unknown table: users
>create table products (id int, title varchar(40), price int);
Create table statement executed successfully
>create table users (id int, name varchar(1000), email varchar(1000));
Create table statement executed successfully
>insert into products (id, title, price) values (1, 'lamp', 30);
Insert statement executed successfully (1 rows)
>insert into products (title, id) values ('desk', 2);
Insert statement executed successfully (1 rows)
>insert into users (id, name, email) values (1, 'dan', 'd@e');
Insert statement executed successfully (1 rows)
>select * from products;
id | title | price
1 | lamp | 30
2 | desk | 0
(2 rows)
[EXEC] rows_examined=2
>catalog list
[CATALOG] Tables: 2
[CATALOG] products root=2 (id INT, title VARCHAR(40), price INT)
[CATALOG] users root=3 (id INT, name VARCHAR(1000), email VARCHAR(1000))
>btree find products 2
[BTREE] Find key=2: FOUND (page=2 cell=1)
[BTREE] Row: id=2 title='desk' price=0
>db header
[DB] Header: magic='droid db' version=1 page_size=4096 root=1
>create table products (id int);
[ERROR:01101] Table products already exists
>.exit
exiting.. good bye!

Things to notice. users does not exist until it is created. products was created first, so its root is page 2 and users' page 3; the catalog, page 1, is what the header records. Row 2 left out price, which is 0, and listed its columns in another order. Both tables have a row with id 1, in two trees.

Common bugs in this stage

  • Checking after writing. A CREATE TABLE that fails on its third column must leave no catalog, no root page, nothing. Run every check first.
  • A catalog that is not a table. Keeping the schema in memory, or in a format of its own, works until the restart. Store it in a tree of rows and read it back with the code that reads every other table.
  • Allocating the table's root before the catalog's. On a new file the catalog is page 1 and the first table's root page 2, in that order.
  • users left behind. Code written for three columns (a header line, a Row: line, a row size) keeps working for users and breaks for products. Test a table with other columns, in another order.
  • Case-folding names. Keywords and type names are not case-sensitive; table and column names are.
  • The key found by name. The key is the first column, whatever it is called. Looking for a column called id works for two tables out of three.
  • A one-byte bitmap for every table. users has three columns and one bitmap byte; a table of nine has two, and every field after them moves by one.
  • Rows encoded in INSERT order. The columns of an INSERT can come in any order; the row is always in table order.

Language notes

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

  • C and C++: a table's schema is a small struct of column names, types and lengths; keep one per table in memory, loaded from the catalog on open, and pass it to the row codec instead of the three users fields.
  • Rust: a Vec of column descriptions with an enum for the type makes the codec a loop over the schema; a HashMap from name to schema, loaded on open, is the in-memory catalog.
  • Zig: a slice of column structs with a tagged union for the type does the same; allocate the schemas with the allocator that lives as long as the database.

Checklist

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

  • A new file has no tables, not even users
    catalog-empty
  • The first CREATE TABLE puts the catalog at page 1 and the table's root at page 2
    create-first-table
  • Two tables, two trees: a row goes to its own table only
    catalog-roots-distinct
  • A table's columns decide how its rows are encoded
    schema-drives-rows
  • The null bitmap has one bit per column: nine columns take two bytes
    bitmap-grows-with-columns
  • The first column is the key, whatever it is called
    key-named-anything
  • Tables, columns and rows survive a restart
    data-and-schema-persist
  • pager and page still work beside the catalog, and a table takes the next page
    pages-beside-the-catalog
  • Creating a table that exists is 01101
    create-dup-code
  • A type that is not INT or VARCHAR(n) is 01102
    create-unknown-type
  • The first column must be INT: 01103
    create-non-int-key
  • A column named twice is 00408, and the checks come in order
    create-checks-in-order
  • A catalog row over 1000 bytes is 00407
    catalog-row-too-large
  • Table names are case-sensitive
    names-are-case-sensitive
  • create and table are keywords
    tokenize-create
  • ast prints a CREATE TABLE
    ast-create
  • explain prints a CreateTable plan and creates nothing
    explain-create