DB from scratch

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:

  1. Read: Wait for the user to type a command.
  2. Eval (Execute): Process the command.
  3. Print: Display the result (or an error message).
  4. 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 as NULL. This is crucial because it tells the getline function 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 the InputBuffer struct itself needs to persist as long as the program runs.
  • Why buffer = NULL? This is a specific behavior of the POSIX getline function. If you pass a NULL pointer for the buffer, getline will call malloc internally 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. getline can 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. getline updates this to reflect the new buffer size if it grew.
  • stdin: Reads from standard input (the keyboard).
  • Return Value: bytes_read contains 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_length as bytes_read - 1.
  • It manually replaces the newline character with a null terminator (0 or \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:

  1. 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.
  2. 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:

FunctionPurpose
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 strncmp for insert because it’s followed by data.
  • Uses strcmp for select because 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 INSERT and SELECT are supported.
  • Persistence: Data is in-memory only (no disk files yet).
  • Schema: A single, hard-coded table named users with 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 a Row struct into a raw byte buffer.
  • deserialize_row(): Copies raw bytes back into a Row struct.

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 as 4096 / 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.

  1. Calculates which page the row belongs to: page_num = row_num / ROWS_PER_PAGE.
  2. Calculates the byte offset within that page.
  3. 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:

  1. Checks if the table is full (num_rows >= TABLE_MAX_ROWS).
  2. Serializes the input row.
  3. Places it at the end of the table (table->num_rows).
  4. Increments the row count.

execute_select:

  1. Iterates from row 0 to num_rows.
  2. Retrieves the raw bytes for each row using row_slot.
  3. Deserializes them back into a Row struct.
  4. Prints the result.

6. Error Handling

The code introduces robust error states:

  • PREPARE_SYNTAX_ERROR: Triggered if sscanf fails to parse the INSERT arguments 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:

  1. new_table() initializes an empty table with NULL page pointers.
  2. insert: Parses the input, serializes the data, allocates a new page if necessary, and writes the bytes.
  3. select: Walks through the allocated pages, reconstructs the Row structs, 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;
     }
   }
 }