BitBase is a small, from-scratch SQL database engine and REPL, written in C++17. It implements its own tokenizer, parser, executor, on-disk storage format, B+ tree index, pager, and write-ahead log — no external database library is used anywhere in the project.
This document covers:
- Quick start
- High-level architecture
- On-disk layout
- Module-by-module reference
- SQL grammar supported
- Known design constraints
- Running the test suite
- Troubleshooting
- A C++17 compiler (g++/clang++ on Linux/macOS, or MinGW-w64/MSVC on Windows)
- CMake >= 3.16
# Ubuntu/Debian
sudo apt update && sudo apt install build-essential cmake
# macOS
brew install cmakecd bitbase_cli
mkdir build && cd build
cmake .. -DCMAKE_BUILD_TYPE=Release
cmake --build . -jThis produces bitbase_cli (or bitbase_cli.exe on Windows) inside build/
(the exact sub-path depends on your CMake generator — check with
find . -name "bitbase_cli*" if unsure).
./bitbase_cliBitbase> create table users (id INT primary key, name TEXT, email TEXT unique);
Bitbase> insert into users values (1, 'Alice', 'alice@example.com');
Bitbase> select * from users;
(1, Alice, alice@example.com)
Bitbase> .exitImportant: SQL keywords (select, insert, create, where, and,
order, by, limit, set, values, into, from, table, primary,
key, unique, drop) must be typed lowercase. Data type names (INT,
BIGINT, FLOAT, DOUBLE, BOOL, TEXT) must be typed uppercase. See
§6 for why.
A data/ folder and a wal.log file are created in whatever directory you
launch the executable from — this is where all table files and the catalog
live. Run the binary from a consistent working directory if you want your
data to persist between sessions.
┌─────────────────────────────────────────────────────────────┐
│ main() │
└──────────────────────────────┬────────────────────────────────┘
│
┌───────▼────────┐
│ Repl │ read–eval–print loop
│ (repl.cpp) │
└───┬─────────┬───┘
│ │
starts with '.'│ │everything else
│ │
┌──────────▼──┐ ┌──▼────────────┐
│ MetaCommand │ │ Parser │ tokenize + parse
│ Handler │ │ (parser.cpp) │ → Statement
│ (meta.cpp) │ └───────┬────────┘
└──────────────┘ │
┌────────▼─────────┐
│ Executor │ dispatches on
│ (executor.cpp) │ Statement::type
└───┬─────────────┬──┘
│ │
┌──────────▼──┐ ┌──────▼───────┐
│ WALManager │ │ Database │
│ (wal.cpp) │ │ (database.cpp)│
└──────────────┘ └──────┬────────┘
│ owns
┌───────▼────────┐
│ Table │ one per
│ (table.cpp) │ SQL table
└───┬─────────┬──┘
│ │
┌───────────▼──┐ ┌───▼──────────┐
│ Pager │ │ B+ Tree │
│ (pager.cpp) │ │ (node.cpp) │
└───────────────┘ └───────────────┘
Request flow for a typical command (e.g. select * from users where id = 1;):
Repl::start()reads a line from stdin.- If it starts with
.,MetaCommandHandler::handle()processes it (.exit,.tables,.help). - Otherwise,
Parser::parse()tokenizes the line and fills out aStatementstruct describing what to do. Executor::execute()switches onstatement.typeand calls into the rightTablemethods on the right table (looked up viaDatabase::get_table()).Tablemethods read/write raw bytes through thePager, and — for primary-key tables — maintain a B+ tree index (node.cpp) for fast point/range lookups.- Mutating statements (INSERT/UPDATE/DELETE) also get logged to
wal.logviaWALManager, after the storage-level operation succeeds.
When you run bitbase_cli from directory X, it creates:
X/
├── data/
│ ├── bitbase.meta ← catalog: plain-text list of table names
│ ├── users.db ← one binary file per table
│ ├── orders.db
│ └── ...
└── wal.log ← write-ahead log (append-only text log)
Each table file is a flat sequence of fixed-size 4096-byte pages, addressed by page number starting at 0:
| Page | Purpose |
|---|---|
| Page 0 | Metadata page: [num_rows: u32][schema_size: u32][serialized schema bytes...] |
| Page 1 | Root of the B+ tree index (only meaningful if the table has a PRIMARY KEY) |
| Page 2+ | Row heap — each page starts with a 4-byte "bytes used" counter, followed by a sequence of [row_size: i32][row bytes...] records. A negative row_size marks that record as tombstoned (deleted) without physically compacting the page. |
Tables without a primary key still get a valid (empty) B+ tree root page —
it's just never populated, since there's no key to index on. All reads for
such tables go through a full sequential scan of the row heap
(Table::get_all_dynamic()).
Rows are encoded dynamically based on the table's Schema (see
dynamic_row_format.cpp) — there's no fixed-width row struct. Each column is
serialized according to its DataType:
| DataType | Encoding |
|---|---|
INT32 |
4-byte little-endian int |
INT64 |
8-byte little-endian int |
FLOAT |
4-byte IEEE-754 |
DOUBLE |
8-byte IEEE-754 |
BOOL |
1 byte |
TEXT |
length-prefixed string |
[column_count: u32]
for each column:
[name_len: u32][name bytes][type: u8][flags: u8]
flags bit 0 = is_primary, bit 1 = is_unique. (Prior to the July 2026
bugfix pass, these flags were not persisted at all — see the CHANGELOG
note in §6 if you're working with .db files
created by an older build; they are not forward-compatible with this format.)
Entry point. Constructs a Repl and calls start().
Repl::start()
Owns the long-lived MetaCommandHandler, Parser, and Executor instances
for the whole session. Loop body:
- print
Bitbase>, read a line - empty line → skip
- line starts with
.→ hand toMetaCommandHandler;.exitbreaks the loop - otherwise →
Parser::parse(), thenExecutor::execute()on success, or print the parser's error message on failure
MetaCommandHandler::handle(input, db) → MetaCommandResult
| Command | Behavior |
|---|---|
.exit |
returns EXIT, which breaks the REPL loop |
.tables |
prints every table name currently in db.tables |
.help |
prints a short static command list |
| anything else | returns UNRECOGNIZED (silently ignored by the REPL — no error is printed) |
tokenize(input) (in utils/tokenizer.cpp)
Splits a line into tokens. Rules:
- whitespace separates tokens and is discarded
(,),,,;are always their own single-character tokens, regardless of surrounding whitespace (so(id,name)and( id , name )tokenize identically)- text inside
'single quotes'is captured verbatim as one token (quotes stripped later by the parser), so string literals may contain spaces - everything else accumulates into a token until whitespace or one of the
above punctuation marks is hit — this means multi-character operators
like
>=/<=only tokenize correctly if separated from the number by whitespace on at least one side, and unquoted identifiers/keywords are compared byte-for-byte (see §6)
Parser::parse(input, statement, error) → bool
Looks at tokens[0] and dispatches to one of six hand-written statement
parsers (INSERT / SELECT / DELETE / UPDATE / CREATE TABLE / DROP TABLE),
filling in a Statement struct (see parser/statement.h). Returns false
with a human-readable error string for malformed input. Trailing ; tokens
are simply left unconsumed and ignored — semicolons are optional.
Notable per-statement quirks:
- SELECT's
WHEREclause has a special fast path: if the condition is exactlyid >= N and id <= M, it's parsed as a range query (statement.is_range) instead of a generic condition list, so it can use the B+ tree's range scan instead of a full table scan. Any otherWHEREshape (including a plainid = N) falls back to genericAND-chained conditions instatement.conditions. - Generic
WHERE/condition parsing supports=,!=,>,<,>=,<=and any number ofAND-joined clauses (noOR, no parentheses/grouping). - CREATE TABLE allows at most one
PRIMARY KEYcolumn (a second one is a parse error) and any number ofUNIQUEcolumns. A column can bePRIMARY KEYorUNIQUE, not both, in the current grammar. - UPDATE syntax is fixed-position:
update <table> set <col> = <val> [where ...]— only a single column can be set per statement.
Executor::Executor()
Constructs the Database (which loads the on-disk catalog) and opens
wal.log for append.
Executor::execute(statement)
One case per StatementType:
- INSERT — looks up the table, checks column count matches the schema,
runs a
UNIQUE-constraint pre-check across all unique columns (viaTable::exists_value_in_column), then callsTable::insert(). On success, appends an entry to the WAL. - SELECT — chooses a data-access strategy in this priority order:
range query (B+ tree) → point lookup by primary key (
find_all_by_id, if theWHEREclause pins the PK column with=) → full scan (get_all_dynamic). Generic conditions are then applied viaTable::filter_rows.ORDER BYandLIMITare applied last, in that order, after filtering. - DELETE — no
WHERE→Table::delete_all(). WithWHERE→Table::delete_where_full(), which does a full-scan filter and tombstones matching rows (removing them from the B+ tree too, if the table has a PK). - UPDATE — always goes through
Table::update_where()(a full-scan + filter + in-place-or-reinsert rewrite), which enforces both "can't update the primary key column" and "can't violate a UNIQUE constraint." - CREATE_TABLE —
Database::create_table()thenTable::set_schema()(which persists the schema to the table's metadata page immediately). - DROP_TABLE —
Database::drop_table()deletes the in-memoryTable, removes the.dbfile from disk, and rewrites the catalog.
Database owns an unordered_map<string, Table*> and a plain-text
catalog file (data/bitbase.meta, one table name per line).
Database()— createsdata/if missing, thenload_catalog()load_catalog()— reads the catalog file and opens (constructs) aTablefor every listed name; does not callrebuild_index()on reload, because the B+ tree pages are already persisted as part of each.dbfile — there's nothing to rebuildcreate_table(name)— refuses if the name is already taken; otherwise creates a freshTable, runs (a no-op, since it's empty)rebuild_index(), and rewrites the catalogdrop_table(name)— destructs theTableobject (flushing/closing its file), deletes the.dbfile, rewrites the catalogget_table(name)— map lookup, returnsnullptrif not found
This is the biggest and most important module. A Table owns one Pager
(one open file) and an in-memory Schema.
Construction (Table::Table(filename))
- Opens (or creates) the file via
Pager - If the file is brand new (
file_length == 0): zeroes page 0'snum_rowsandschema_sizefields - Otherwise: reads
num_rowsand deserializes theSchemastraight out of page 0 - Ensures page 1 is initialized as a valid B+ tree leaf node if it isn't already (first-time setup)
insert(values) → bool
- If the table has a primary key, parses the PK value as
u32and does a full-scan duplicate check againstget_all_dynamic()(not the B+ tree — this is the "real source of truth" per an in-code comment, precisely to avoid depending on index state) - Serializes the row via
serialize_dynamic_row()and appends it to the first row-heap page with enough free space (never compacts/reuses tombstoned space — the heap only grows) - If there's a primary key, also inserts
{key → RowPointer}into the B+ tree (btree_insert), splitting/creating a new root as needed - Bumps and persists
num_rows
get_all_dynamic() → full sequential scan of every row-heap page,
skipping tombstoned (negative-size) records. This is the fallback path used
whenever there's no usable index, and is also used internally by the
duplicate-key check above.
scan_all_index() → walks the B+ tree via find_leftmost_leaf +
leaf_node_next_leaf chaining to visit every row in primary-key order.
Returns nothing useful on a table without a primary key (empty tree).
exists_value_in_column(col_idx, value) → bool
Used for UNIQUE enforcement. Does a full scan (get_all_dynamic()) and
string-compares the target column. Deliberately does not use
scan_all_index(), because that would silently return nothing on tables
without a primary key, breaking UNIQUE on such tables (this was a real bug,
fixed — see §6).
find_by_id / find_all_by_id / range_query → B+ tree point/range
lookups. Require a primary key to return anything meaningful.
delete_by_id(key) → finds the row pointer via the B+ tree, flips the
row's size field negative (tombstone) in the heap, then removes the key from
the B+ tree.
delete_all() / delete_where_full(conds) → full-scan tombstoning;
the WHERE variant re-derives each matching row's PK (if any) and calls
delete_by_id to also clean up the index, or tombstones directly for
PK-less tables.
update_by_id(key, column, value) — B+ tree-based single-row update path.
Not the path the executor actually uses for UPDATE statements (see
update_where below) but kept as a lower-level primitive; enforces PK and
UNIQUE checks identically to update_where.
update_where(conds, column, value) — the path the executor calls for
every UPDATE. Full scan + filter_rows per candidate row; refuses to touch
the primary-key column at all; enforces UNIQUE before applying the change.
Rewrites in place if the new serialized row is the same size or smaller;
otherwise tombstones the old row and calls insert() for the new one
(meaning the row's physical location, and therefore B+ tree pointer, changes
on size growth — this is why the check happens before serialization).
filter_rows(rows, conds) → applies an AND-chain of Statement::Condition
against already-materialized rows, dispatching comparisons per column
DataType (numeric columns compare numerically; TEXT/BOOL compare as
strings). Supports =, !=, >, <, >=, <=.
order_rows(rows, column) → in-memory sort by one column (ascending only;
no DESC keyword in the grammar).
rebuild_index() → resets the B+ tree root and, if the table has a
primary key, walks every row via get_all_dynamic() and re-inserts each into
a fresh tree. Called automatically whenever a table is created.
value.h—DataTypeenum (INT32, INT64, FLOAT, DOUBLE, BOOL, TEXT) andValue = std::variant<int32_t, int64_t, float, double, bool, std::string>column.h—Column { name, type, is_primary, is_unique }schema.cpp—Schemawrapsvector<Column>plus:add_column,get_column_index,get_primary_index(linear scans)serialize()/deserialize()— binary (de)serialization used to persist the schema in each table's metadata page (format described in §3)
dynamic_row_format.cpp — serialize_dynamic_row / deserialize_dynamic_row:
the schema-driven row (de)serializer used by every Table operation. Each
column is packed/unpacked according to its DataType (see the encoding table
in §3).
Pager wraps a single FILE* and an in-memory array of loaded 4KB pages
(pages[TABLE_MAX_PAGES], up to 10,000 pages ≈ 40MB per table file).
get_page(n)— lazily loads pagenfrom disk intopages[n]on first access (zero-filling if it's a brand-new page past current EOF); returns the cached pointer on subsequent callsflush(n)— writespages[n]back to disk at the right offsetallocate_page()— hands out the next page number for row-heap data (grows monotonically from wherever row storage starts)allocate_btree_page()— hands out the next page number for B+ tree nodes (tracked separately vianext_btree_page, starting at page 2)
Pages are never evicted from memory once loaded — the whole table's touched pages stay resident for the process lifetime.
A textbook disk-based B+ tree, one tree per table, keyed on the primary key
(as a u32):
- Leaf nodes: header
[num_cells][next_leaf]+ cells of[key][page_id][offset](aRowPointerinto the row heap) - Internal nodes: header
[num_keys][right_child]+ cells of[child_page][key] btree_insert— recursive insert with node splitting on overflow;create_new_rootbuilds a new internal root when the existing root splitsbtree_find/btree_find_leaf/leaf_search— point lookupbtree_delete/leaf_delete— key removal (leaf-level only; this implementation does not rebalance/merge underfull nodes after deletion, it just removes the cell)find_leftmost_leaf+leaf_node_next_leaf— leaf-chain traversal used for full-index scans and range queries, since leaves are linked left-to-right
WALManager appends human-readable log lines to wal.log for every
successful INSERT/DELETE/UPDATE (log_insert, log_delete, log_update),
flushed immediately after each write. This gives you a plain-text audit trail
of every mutating statement executed against the database. Table data
durability itself comes from the row-heap being written directly to each
table's own .db file on every insert()/flush() call.
tokenize()— see §4 parser above
CREATE TABLE <name> (
<col> <TYPE> [PRIMARY KEY | UNIQUE],
<col> <TYPE> [PRIMARY KEY | UNIQUE],
...
);
DROP TABLE <name>;
INSERT INTO <name> VALUES (<val>, <val>, ...);
SELECT * | <col>, <col>, ... FROM <name>
[WHERE <col> <op> <val> [AND <col> <op> <val> ...]]
[ORDER BY <col>]
[LIMIT <n>];
UPDATE <name> SET <col> = <val> [WHERE <col> <op> <val> [AND ...]];
DELETE FROM <name> [WHERE <col> <op> <val> [AND ...]];<TYPE>∈{ INT, BIGINT, FLOAT, DOUBLE, BOOL, TEXT }(uppercase)<op>∈{ =, !=, >, <, >=, <= }- String literals use
'single quotes'and may contain spaces ;is optional- No
OR, no parentheses/grouping inWHERE, noJOIN, no aggregate functions, noGROUP BY, noDESCsort order, noNULLliteral in SQL text (columns can hold noNULL— every column must get a value onINSERT) - Special case:
WHERE id >= N AND id <= Mis recognized as a contiguous range and served from the B+ tree; every otherWHEREshape does a full table scan (with a single-row B+ tree point lookup as an optimization for a bareWHERE id = N)
Meta commands (must start with ., no trailing ;):
.exit exit the REPL
.tables list all table names
.help print available commands
These are things you'll likely bump into — noted here so they read as "documented behavior" rather than mysterious bugs:
- Keyword case-sensitivity is split by design. SQL keywords (
select,create,where,and, etc.) must be lowercase; type names (INT,TEXT, etc.) must be uppercase. The tokenizer does no case-normalization, and the parser compares tokens byte-for-byte against hardcoded lowercase keyword strings and uppercase type strings. TypingSELECTorintwill fail to parse (Unrecognized command/Unknown data type). - Operators need surrounding whitespace. Because the tokenizer only
splits on whitespace and a fixed punctuation set (
( ) , ;), writingage>=18with no spaces produces a single garbled token instead of three tokens. Always writeage >= 18. - No OR, no grouping, no JOIN, no NULL, no DESC. The
WHEREgrammar is a flatAND-chain only; there's no relational join across tables. data/schema file format changed. As of this bugfix pass, a column'sPRIMARY KEY/UNIQUEflags are persisted on disk (previously they were silently dropped on save, meaning constraints were forgotten after every process restart — see the project's fix history)..db/.metafiles created by a build prior to this fix will misparse under the current binary. Delete any olddata/directory before using a build with this fix if you have leftover files from an earlier version.wal.logis an audit trail, not a recovery mechanism. Table durability comes entirely from direct file-backed row storage (each.dbfile is written on every insert/update/delete);wal.logrecords the same events as a plain-text log but isn't replayed on startup.- No transactions.
BEGIN/COMMIT/ROLLBACKdon't exist in the grammar; every statement takes effect immediately and independently. - B+ tree deletion doesn't rebalance. Deleting keys can leave underfull internal/leaf nodes; this doesn't cause incorrect results, but the tree isn't reclaiming/compacting space the way a production engine would.
- Row-heap pages never reclaim tombstoned space. Deleted/updated-and-grown rows leave a permanent gap in their page; the file only grows over time, it never shrinks or compacts.
- A column can be
PRIMARY KEYorUNIQUE, not stated as both in a single column declaration (the parser'sif/else ifonly lets one modifier apply per column).
test_bitbase.sh is a black-box functional test suite (74 checks) that
drives the compiled binary exactly as a user would — piping SQL text into its
stdin and asserting on what it prints — covering every feature in
§5: meta commands, all CREATE TABLE variants,
INSERT (including PK/UNIQUE violations), every WHERE operator, ORDER BY,
LIMIT, UPDATE, DELETE, DROP TABLE, multi-table isolation, and —
importantly — that constraints and index-backed queries survive a process
restart.
./test_bitbase.sh path/to/bitbase_clie.g.
./test_bitbase.sh build/bitbase_cliThe script builds nothing itself — build first, then point it at the
resulting binary. It runs everything in a fresh temporary directory
(mktemp -d) so it never touches your real data//wal.log, prints a
PASS/FAIL line per case with a diff-style dump of actual output on
failure, and exits non-zero if anything fails (safe to wire into CI).
The .sh script requires a POSIX shell and won't run directly under
powershell.exe. Either:
- run it from Git Bash or WSL instead (pointing at the
.exe, e.g../test_bitbase.sh build/bitbase_cli/bitbase_cli.exe), or - use a native PowerShell port of the same 74 checks, if one has been added
to this repo (check for a
test_bitbase.ps1alongside the.shfile) — run it as:./test_bitbase.ps1 -Binary build\bitbase_cli\bitbase_cli.exe
Every failure prints the exact expected substring and the actual captured
output, so a failing run is self-explanatory without needing to re-run
manually. A FAIL on any persistence/constraint test after a fresh clean
build most likely means the on-disk schema format and the binary have
drifted apart — see the data/ format note in
§6.
Nothing prints, no data/ folder appears, when run from one shell but it
works fine from another (e.g. works in PowerShell, silent in Git Bash).
This points to something shell/terminal-specific rather than a code bug —
first isolate whether it's a piping issue (try running the .exe completely
interactively, typing commands by hand, with no | involved) versus a
build/path issue (confirm you're pointing at the exact same freshly built
binary in both shells, e.g. via Get-Item/ls -la timestamps). If output
only ever appears in bursts or right before you'd expect a crash, suspect
output buffering in whatever terminal emulator you're using rather than the
program's logic — piped/non-console stdout can be fully buffered depending
on the environment.
Table already exists / duplicate errors right after a clean rebuild.
Delete data/ and wal.log in your working directory before your first run
of a newly rebuilt binary, especially if you've rebuilt with schema-format
changes (see §6) — stale files from an older
build will not parse correctly under a changed on-disk format.
cmake not found. Install it (see §1); there is no
build path in this project that avoids CMake other than manually invoking
g++/clang++ on every .cpp file under src/ with -Iinclude -std=c++17.