how does a Database work?
- What format is data saved in? (in memory and on disk)
- When does it move from memory to disk?
- Why can there only be one primary key per table?
- How does rolling back a transaction work?
- How are indexes formatted?
- When and how does a full table scan happen?
- What format is a prepared statement saved in?
intro and setting up the REPL
A query goes through a chain of components in order to retrieve or modify data. The front-end consists of the:
- tokenizer
- parser
- code generator
The input to the front-end is a SQL query. the output is sqlite virtual machine bytecode (essentially a compiled program that can operate on the database).
The back-end consists of the:
- virtual machine
- B-tree
- pager
- os interface
The virtual machine takes bytecode generated by the front-end as instructions. It can then perform operations on one or more tables or indexes, each of which is stored in a data structure called a B-tree. The VM is essentially a big switch statement on the type of bytecode instruction.
Each B-tree consists of many nodes. Each node is one page in length. The B-tree can retrieve a page from disk or save it back to disk by issuing commands to the pager.
The pager receives commands to read or write pages of data. It is responsible for reading/writing at appropriate offsets in the database file. It also keeps a cache of recently-accessed pages in memory, and determines when those pages need to be written back to disk.
The os interface is the layer that differs depending on which operating system sqlite was compiled for. In this tutorial, I’m not going to support multiple platforms.
Making a simple REPL
The REPL loop
REPL is the interface that allows a user to interact with the database via the command line. its name describe the infinite loop structure.
- Read: waith for the user to type a command
- Eval(execute): process the command
- print: display the result (or error message)
- loop: repeat unitl the user exits
step by step implementation breakdown
Based on the text provided, here is a comprehensive breakdown of how the REPL (Read-Eval-Print Loop) is implemented in C for this simple database project.
The Core Concept: The REPL Loop
The REPL is the interface that allows a user to interact with the database via the command line. Its name describes its infinite loop structure:
- Read: Wait for the user to type a command.
- Eval (Execute): Process the command.
- Print: Display the result (or an error message).
- Loop: Repeat until the user exits.
In this specific implementation, the “Eval” phase is currently very basic. It only recognizes one command (.exit), and everything else is treated as unrecognized.
Step-by-Step Implementation Breakdown
1. Data Structure: InputBuffer
To manage user input, the code defines a structure to hold the state of the input line. This is necessary because C strings are dynamic, and we need to track the buffer itself, its allocated size, and the actual length of the text entered.
typedef struct {
char* buffer; // Pointer to the actual string data
size_t buffer_length; // Total size of the allocated memory
ssize_t input_length; // Actual length of the string read (excluding newline)
} InputBuffer;buffer: Starts asNULL. This is crucial because it tells thegetlinefunction to allocate memory automatically.buffer_length: Tracks how much memory is currently allocated.input_length: Tracks how many characters were actually typed by the user.
2. Memory Management: new_input_buffer
This function initializes the InputBuffer structure.
InputBuffer* new_input_buffer() {
InputBuffer* input_buffer = malloc(sizeof(InputBuffer));
input_buffer->buffer = NULL; // Critical: NULL tells getline to alloc memory
input_buffer->buffer_length = 0;
input_buffer->input_length = 0;
return input_buffer;
}- Why
malloc? We need heap memory because theInputBufferstruct itself needs to persist as long as the program runs. - Why
buffer = NULL? This is a specific behavior of the POSIXgetlinefunction. If you pass aNULLpointer for the buffer,getlinewill callmallocinternally to create a buffer of sufficient size. This saves you from guessing how much memory to allocate beforehand.
3. The Prompt: print_prompt
A simple function to display the database prompt before reading input.
void print_prompt() {
printf("db > ");
}4. Reading Input: read_input
This is the most complex part of the setup. It uses the POSIX getline function.
void read_input(InputBuffer* input_buffer) {
// getline signature: ssize_t getline(char **lineptr, size_t *n, FILE *stream);
ssize_t bytes_read =
getline(&(input_buffer->buffer), &(input_buffer->buffer_length), stdin);
if (bytes_read <= 0) {
printf("Error reading input\n");
exit(EXIT_FAILURE);
}
// Remove the trailing newline character
input_buffer->input_length = bytes_read - 1;
input_buffer->buffer[bytes_read - 1] = 0; // Null-terminate the string
}How getline works here:
&(input_buffer->buffer): Passes the address of the pointer.getlinecan update this pointer if it needs to reallocate memory (e.g., if the user types a very long command).&(input_buffer->buffer_length): Passes the address of the size variable.getlineupdates this to reflect the new buffer size if it grew.stdin: Reads from standard input (the keyboard).- Return Value:
bytes_readcontains the number of bytes read, including the newline character (\n).
Handling the Newline:
getline includes the \n in the string. For command parsing, we usually don’t want this.
- The code calculates
input_lengthasbytes_read - 1. - It manually replaces the newline character with a null terminator (
0or\0) to make it a valid C string without the line break.
5. Cleaning Up: close_input_buffer
Since getline allocated memory for the buffer, we must free it to avoid memory leaks when the program exits.
void close_input_buffer(InputBuffer* input_buffer) {
free(input_buffer->buffer); // Free the string allocated by getline
free(input_buffer); // Free the struct itself
}6. The Main Loop
The main function ties everything together.
int main(int argc, cihar* argv[]) {
InputBuffer* input_buffer = new_input_buffer();
while (true) {
print_prompt(); // 1. Print "db > "
read_input(input_buffer); // 2. Read user input
// 3. Evaluate the command
if (strcmp(input_buffer->buffer, ".exit") == 0) {
close_input_buffer(input_buffer);
exit(EXIT_SUCCESS);
} else {
printf("Unrecognized command '%s'.\n", input_buffer->buffer);
}
// 4. Loop repeats
}
}The entire code
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
char* buffer;
size_t buffer_length;
ssize_t input_length;
} InputBuffer;
InputBuffer* new_input_buffer() {
InputBuffer* input_buffer = malloc(sizeof(InputBuffer));
input_buffer->buffer = NULL;
input_buffer->buffer_length = 0;
input_buffer->input_length = 0;
return input_buffer;
}
void print_prompt() { printf("db > "); }
void read_input(InputBuffer* input_buffer) {
ssize_t bytes_read =
getline(&(input_buffer->buffer), &(input_buffer->buffer_length), stdin);
if (bytes_read <= 0) {
printf("Error reading input\n");
exit(EXIT_FAILURE);
}
// Ignore trailing newline
input_buffer->input_length = bytes_read - 1;
input_buffer->buffer[bytes_read - 1] = 0;
}
void close_input_buffer(InputBuffer* input_buffer) {
free(input_buffer->buffer);
free(input_buffer);
}
int main(int argc, char* argv[]) {
InputBuffer* input_buffer = new_input_buffer();
while (true) {
print_prompt();
read_input(input_buffer);
if (strcmp(input_buffer->buffer, ".exit") == 0) {
close_input_buffer(input_buffer);
exit(EXIT_SUCCESS);
} else {
printf("Unrecognized command '%s'.\n", input_buffer->buffer);
}
}
}Simple SQL compiler and virtual machine
High-Level Architecture
Real databases like SQLite work in two main phases:
- Front-end (SQL Compiler):
Parses a SQL string (e.g.,INSERT 1 foo bar@baz.com) and converts it into an internal format called bytecode. - Back-end (Virtual Machine):
Executes that bytecode to perform the actual operation (e.g., inserting a row).
This separation offers:
- Reduced complexity in each component.
- Performance gains by caching compiled bytecode for repeated queries.
Refactored main() Function
The main() loop now does the following:
while (true) {
print_prompt();
read_input(input_buffer);
if (input_buffer->buffer[0] == '.') {
// Handle meta-commands like .exit, .tables
switch (do_meta_command(input_buffer)) {
case META_COMMAND_SUCCESS: continue;
case META_COMMAND_UNRECOGNIZED_COMMAND:
printf("Unrecognized command '%s'\n", input_buffer->buffer);
continue;
}
}
// Prepare (compile) the SQL statement
Statement statement;
switch (prepare_statement(input_buffer, &statement)) {
case PREPARE_SUCCESS: break;
case PREPRE_UNRECOGNIZED_STATEMENT:
printf("Unrecognized keyword at start of '%s'.\n", input_buffer->buffer);
continue;
}
// Execute the statement
execute_statement(&statement);
printf("Executed.\n");
}Key Functions:
| Function | Purpose |
|---|---|
do_meta_command() | Handles dot commands like .exit. Returns success or error code. |
prepare_statement() | Parses input and sets the StatementType (INSERT or SELECT). |
execute_statement() | Stub that will eventually run the actual insert/select logic. |
Data Structures Introduced
Enums for Result Codes
typedef enum {
META_COMMAND_SUCCESS,
META_COMMAND_UNRECOGNIZED_COMMAND
} MetaCommandResult;
typedef enum {
PREARE_SUCCESS,
PREPARE_UNRECOGNIZED_STATEMENT
} PrepareResult;
typedef enum {
STATEMENT_INSERT,
STATEMENT_SELECT
} StatementType;Using enums allows the C compiler to warn you if a switch statement doesn’t handle all cases, a safety feature in a language without exceptions.
Statement Struct
typedef struct {
StatementType type;
} Statement;Right now, it only stores the type. Later parts will add fields like user_id, username, email for INSERT.
Implementation Details
1. Meta-Commands
Meta-commands start with a . (e.g., .exit, .tables).
They are handled separately from SQL statements:
MetaCommandResult do_meta_command(InputBuffer* input_buffer) {
if (strcmp(input_buffer->buffer, ".exit") == 0) {
close_input_buffer(input_buffer);
exit(EXIT_SUCCESS);
} else {
return META_COMMAND_UNRECOGNIZED_COMMAND;
}
}2. SQL Compiler (prepare_statement)
Currently, it only recognizes two keywords:
PrepareResult prepare_statement(InputBuffer* input_buffer, Statement* statement) {
if (strncmp(input_buffer->buffer, "insert", 6) == 0) {
statement->type = STATEMENT_INSERT;
return PREPARE_SUCCESS;
}
if (strcmp(input_buffer->buffer, "select") == 0) {
statement->type = STATEMENT_SELECT;
return PREPARE_SUCCESS;
}
return PREPARE_UNRECOGNIZED_STATEMENT;
}- Uses
strncmpforinsertbecause it’s followed by data. - Uses
strcmpforselectbecause it’s a standalone keyword.
3. Virtual Machine Stub (execute_statement)
void execute_statement(Statement* statement) {
switch (statement->type) {
case STATEMENT_INSERT:
printf("This is where we would do an insert.\n");
break;
case STATEMENT_SELECT:
printf("This is where we would do a select.\n");
break;
}
}This is just a placeholder. Future parts will implement actual data storage and retrieval.
Example Session
~ ./db
db > insert foo bar
This is where we would do an insert.
Executed.
db > delete foo
Unrecognized keyword at start of 'delete foo'.
db > select
This is where we would do a select.
Executed.
db > .tables
Unrecognized command '.tables'
db > .exit
~In-Memory, Append-Only, Single-Table Database
1. Core Requirements & Limitations
- Operations: Only
INSERTandSELECTare supported. - Persistence: Data is in-memory only (no disk files yet).
- Schema: A single, hard-coded table named
userswith three columns:id(integer)username(varchar 32)email(varchar 255)
Example Syntax:
insert 1 cstack foo@bar.com
select
2. Data Structures
The code defines structures to hold the data before it is serialized.
The Row Struct: Represents a single user in C memory.
#define COLUMN_USERNAME_SIZE 32
#define COLUMN_EMAIL_SIZE 255
typedef struct {
uint32_t id;
char username[COLUMN_USERNAME_SIZE];
char email[COLUMN_EMAIL_SIZE];
} Row;The Statement Struct: Holds the parsed command and the data to be inserted.
typedef struct {
StatementType type;
Row row_to_insert; // Only used for INSERT statements
} Statement;3. Serialization & Layout
Instead of storing Row structs directly (which wastes space due to padding and varies in alignment), the code serializes them into a compact binary format.
Size & Offsets: The code calculates the exact byte size of each attribute and their offsets to pack data tightly.
- ID: 4 bytes (offset 0)
- Username: 32 bytes (offset 4)
- Email: 255 bytes (offset 36)
- Total Row Size: 291 bytes
Serialization Functions:
serialize_row(): Copies data from aRowstruct into a raw byte buffer.deserialize_row(): Copies raw bytes back into aRowstruct.
This ensures that rows can be stored in a continuous block of memory without gaps.
4. Page-Based Memory Management
To simulate how real databases work, the table is split into pages.
Constants:
PAGE_SIZE: 4096 bytes (matches typical OS virtual memory pages).ROWS_PER_PAGE: Calculated as4096 / 291(approx 14 rows per page).TABLE_MAX_PAGES: 100 pages (limiting the total database size to ~1400 rows).
The Table Struct: Holds an array of pointers to pages and a count of total rows.
typedef struct {
uint32_t num_rows;
void* pages[TABLE_MAX_PAGES];
} Table;Lazy Allocation (row_slot): The function row_slot determines where a specific row resides in memory.
- Calculates which page the row belongs to:
page_num = row_num / ROWS_PER_PAGE. - Calculates the byte offset within that page.
- Crucial: If that page pointer is
NULL, it dynamically allocates memory (malloc) only when needed. This is an “append-only” approach where pages grow as data is inserted.
5. Execution Logic
The execute_statement function now routes commands to specific handlers.
execute_insert:
- Checks if the table is full (
num_rows >= TABLE_MAX_ROWS). - Serializes the input row.
- Places it at the end of the table (
table->num_rows). - Increments the row count.
execute_select:
- Iterates from row 0 to
num_rows. - Retrieves the raw bytes for each row using
row_slot. - Deserializes them back into a
Rowstruct. - Prints the result.
6. Error Handling
The code introduces robust error states:
PREPARE_SYNTAX_ERROR: Triggered ifsscanffails to parse theINSERTarguments correctly (e.g.,insert foo bar 1).EXECUTE_TABLE_FULL: Triggered if more than ~1400 rows are inserted.
7. How It Works in Practice
When you run the program:
new_table()initializes an empty table withNULLpage pointers.insert: Parses the input, serializes the data, allocates a new page if necessary, and writes the bytes.select: Walks through the allocated pages, reconstructs theRowstructs, and prints them.
here is the complete diff for now
@@ -2,6 +2,7 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
+#include <stdint.h>
typedef struct {
char* buffer;
@@ -10,6 +11,105 @@ typedef struct {
} InputBuffer;
+typedef enum { EXECUTE_SUCCESS, EXECUTE_TABLE_FULL } ExecuteResult;
+
+typedef enum {
+ META_COMMAND_SUCCESS,
+ META_COMMAND_UNRECOGNIZED_COMMAND
+} MetaCommandResult;
+
+typedef enum {
+ PREPARE_SUCCESS,
+ PREPARE_SYNTAX_ERROR,
+ PREPARE_UNRECOGNIZED_STATEMENT
+ } PrepareResult;
+
+typedef enum { STATEMENT_INSERT, STATEMENT_SELECT } StatementType;
+
+#define COLUMN_USERNAME_SIZE 32
+#define COLUMN_EMAIL_SIZE 255
+typedef struct {
+ uint32_t id;
+ char username[COLUMN_USERNAME_SIZE];
+ char email[COLUMN_EMAIL_SIZE];
+} Row;
+
+typedef struct {
+ StatementType type;
+ Row row_to_insert; //only used by insert statement
+} Statement;
+
+#define size_of_attribute(Struct, Attribute) sizeof(((Struct*)0)->Attribute)
+
+const uint32_t ID_SIZE = size_of_attribute(Row, id);
+const uint32_t USERNAME_SIZE = size_of_attribute(Row, username);
+const uint32_t EMAIL_SIZE = size_of_attribute(Row, email);
+const uint32_t ID_OFFSET = 0;
+const uint32_t USERNAME_OFFSET = ID_OFFSET + ID_SIZE;
+const uint32_t EMAIL_OFFSET = USERNAME_OFFSET + USERNAME_SIZE;
+const uint32_t ROW_SIZE = ID_SIZE + USERNAME_SIZE + EMAIL_SIZE;
+
+const uint32_t PAGE_SIZE = 4096;
+#define TABLE_MAX_PAGES 100
+const uint32_t ROWS_PER_PAGE = PAGE_SIZE / ROW_SIZE;
+const uint32_t TABLE_MAX_ROWS = ROWS_PER_PAGE * TABLE_MAX_PAGES;
+
+typedef struct {
+ uint32_t num_rows;
+ void* pages[TABLE_MAX_PAGES];
+} Table;
+
+void print_row(Row* row) {
+ printf("(%d, %s, %s)\n", row->id, row->username, row->email);
+}
+
+void serialize_row(Row* source, void* destination) {
+ memcpy(destination + ID_OFFSET, &(source->id), ID_SIZE);
+ memcpy(destination + USERNAME_OFFSET, &(source->username), USERNAME_SIZE);
+ memcpy(destination + EMAIL_OFFSET, &(source->email), EMAIL_SIZE);
+}
+
+void deserialize_row(void *source, Row* destination) {
+ memcpy(&(destination->id), source + ID_OFFSET, ID_SIZE);
+ memcpy(&(destination->username), source + USERNAME_OFFSET, USERNAME_SIZE);
+ memcpy(&(destination->email), source + EMAIL_OFFSET, EMAIL_SIZE);
+}
+
+void* row_slot(Table* table, uint32_t row_num) {
+ uint32_t page_num = row_num / ROWS_PER_PAGE;
+ void *page = table->pages[page_num];
+ if (page == NULL) {
+ // Allocate memory only when we try to access page
+ page = table->pages[page_num] = malloc(PAGE_SIZE);
+ }
+ uint32_t row_offset = row_num % ROWS_PER_PAGE;
+ uint32_t byte_offset = row_offset * ROW_SIZE;
+ return page + byte_offset;
+}
+
+Table* new_table() {
+ Table* table = (Table*)malloc(sizeof(Table));
+ table->num_rows = 0;
+ for (uint32_t i = 0; i < TABLE_MAX_PAGES; i++) {
+ table->pages[i] = NULL;
+ }
+ return table;
+}
+
+void free_table(Table* table) {
+ for (int i = 0; table->pages[i]; i++) {
+ free(table->pages[i]);
+ }
+ free(table);
+}
+
InputBuffer* new_input_buffer() {
InputBuffer* input_buffer = (InputBuffer*)malloc(sizeof(InputBuffer));
input_buffer->buffer = NULL;
@@ -40,17 +140,105 @@ void close_input_buffer(InputBuffer* input_buffer) {
free(input_buffer);
}
+MetaCommandResult do_meta_command(InputBuffer* input_buffer, Table *table) {
+ if (strcmp(input_buffer->buffer, ".exit") == 0) {
+ close_input_buffer(input_buffer);
+ free_table(table);
+ exit(EXIT_SUCCESS);
+ } else {
+ return META_COMMAND_UNRECOGNIZED_COMMAND;
+ }
+}
+
+PrepareResult prepare_statement(InputBuffer* input_buffer,
+ Statement* statement) {
+ if (strncmp(input_buffer->buffer, "insert", 6) == 0) {
+ statement->type = STATEMENT_INSERT;
+ int args_assigned = sscanf(
+ input_buffer->buffer, "insert %d %s %s", &(statement->row_to_insert.id),
+ statement->row_to_insert.username, statement->row_to_insert.email
+ );
+ if (args_assigned < 3) {
+ return PREPARE_SYNTAX_ERROR;
+ }
+ return PREPARE_SUCCESS;
+ }
+ if (strcmp(input_buffer->buffer, "select") == 0) {
+ statement->type = STATEMENT_SELECT;
+ return PREPARE_SUCCESS;
+ }
+
+ return PREPARE_UNRECOGNIZED_STATEMENT;
+}
+
+ExecuteResult execute_insert(Statement* statement, Table* table) {
+ if (table->num_rows >= TABLE_MAX_ROWS) {
+ return EXECUTE_TABLE_FULL;
+ }
+
+ Row* row_to_insert = &(statement->row_to_insert);
+
+ serialize_row(row_to_insert, row_slot(table, table->num_rows));
+ table->num_rows += 1;
+
+ return EXECUTE_SUCCESS;
+}
+
+ExecuteResult execute_select(Statement* statement, Table* table) {
+ Row row;
+ for (uint32_t i = 0; i < table->num_rows; i++) {
+ deserialize_row(row_slot(table, i), &row);
+ print_row(&row);
+ }
+ return EXECUTE_SUCCESS;
+}
+
+ExecuteResult execute_statement(Statement* statement, Table *table) {
+ switch (statement->type) {
+ case (STATEMENT_INSERT):
+ return execute_insert(statement, table);
+ case (STATEMENT_SELECT):
+ return execute_select(statement, table);
+ }
+}
+
int main(int argc, char* argv[]) {
+ Table* table = new_table();
InputBuffer* input_buffer = new_input_buffer();
while (true) {
print_prompt();
read_input(input_buffer);
- if (strcmp(input_buffer->buffer, ".exit") == 0) {
- close_input_buffer(input_buffer);
- exit(EXIT_SUCCESS);
- } else {
- printf("Unrecognized command '%s'.\n", input_buffer->buffer);
+ if (input_buffer->buffer[0] == '.') {
+ switch (do_meta_command(input_buffer, table)) {
+ case (META_COMMAND_SUCCESS):
+ continue;
+ case (META_COMMAND_UNRECOGNIZED_COMMAND):
+ printf("Unrecognized command '%s'\n", input_buffer->buffer);
+ continue;
+ }
+ }
+
+ Statement statement;
+ switch (prepare_statement(input_buffer, &statement)) {
+ case (PREPARE_SUCCESS):
+ break;
+ case (PREPARE_SYNTAX_ERROR):
+ printf("Syntax error. Could not parse statement.\n");
+ continue;
+ case (PREPARE_UNRECOGNIZED_STATEMENT):
+ printf("Unrecognized keyword at start of '%s'.\n",
+ input_buffer->buffer);
+ continue;
+ }
+
+ switch (execute_statement(&statement, table)) {
+ case (EXECUTE_SUCCESS):
+ printf("Executed.\n");
+ break;
+ case (EXECUTE_TABLE_FULL):
+ printf("Error: Table full.\n");
+ break;
}
}
}