Part 1 - Introduction and Setting up the REPL

Part 2 - World's Simplest SQL Compiler and Virtual Machine

As a web developer, I use relational databases every day at my job, but they’re a black box to me. Some questions I have:

  • 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?
    In other words, how does a database work?

To figure things out, I’m writing a database from scratch. It’s modeled off sqlite because it is designed to be small with fewer features than MySQL or PostgreSQL, so I have a better hope of understanding it. The entire database is stored in a single file!

Sqlite

There’s lots of documentation of sqlite internals on their website, plus I’ve got a copy of SQLite Database System: Design and Implementation.

|sqlite architecture (https://www.sqlite.org/zipvfs/doc/trunk/www/howitworks.wiki)

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 the 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.

A journey of a thousand miles begins with a single step, so let’s start with something a little more straightforward: the REPL.

Making a Simple REPL

Sqlite starts a read-execute-print loop when you start it from the command line:

  1. ~ sqlite3
  2. SQLite version 3.16.0 2016-11-04 19:09:39
  3. Enter ".help" for usage hints.
  4. Connected to a transient in-memory database.
  5. Use ".open FILENAME" to reopen on a persistent database.
  6. sqlite> create table users (id int, username varchar(255), email varchar(255));
  7. sqlite> .tables
  8. users
  9. sqlite> .exit
  10. ~

To do that, our main function will have an infinite loop that prints the prompt, gets a line of input, then processes that line of input:

  1. int main(int argc, char* argv[]) {
  2. InputBuffer* input_buffer = new_input_buffer();
  3. while (true) {
  4. print_prompt();
  5. read_input(input_buffer);
  6. if (strcmp(input_buffer->buffer, ".exit") == 0) {
  7. exit(EXIT_SUCCESS);
  8. } else {
  9. printf("Unrecognized command '%s'.\n", input_buffer->buffer);
  10. }
  11. }
  12. }

We’ll define InputBuffer as a small wrapper around the state we need to store to interact with getline(). (More on that in a minute)

  1. struct InputBuffer_t {
  2. char* buffer;
  3. size_t buffer_length;
  4. ssize_t input_length;
  5. };
  6. typedef struct InputBuffer_t InputBuffer;
  7. InputBuffer* new_input_buffer() {
  8. InputBuffer* input_buffer = malloc(sizeof(InputBuffer));
  9. input_buffer->buffer = NULL;
  10. input_buffer->buffer_length = 0;
  11. input_buffer->input_length = 0;
  12. return input_buffer;
  13. }

Next, print_prompt() prints a prompt to the user. We do this before reading each line of input.

  1. void print_prompt() { printf("db > "); }

To read a line of input, use getline():

  1. ssize_t getline(char **lineptr, size_t *n, FILE *stream);

lineptr : a pointer to the variable we use to point to the buffer containing the read line.

n : a pointer to the variable we use to save the size of allocated buffer.

stream : the input stream to read from. We’ll be reading from standard input.

return value : the number of bytes read, which may be less than the size of the buffer.

We tell getline to store the read line in input_buffer->buffer and the size of the allocated buffer in input_buffer->buffer_length. We store the return value in input_buffer->input_length.

buffer starts as null, so getline allocates enough memory to hold the line of input and makes buffer point to it.

  1. void read_input(InputBuffer* input_buffer) {
  2. ssize_t bytes_read =
  3. getline(&(input_buffer->buffer), &(input_buffer->buffer_length), stdin);
  4. if (bytes_read <= 0) {
  5. printf("Error reading input\n");
  6. exit(EXIT_FAILURE);
  7. }
  8. // Ignore trailing newline
  9. input_buffer->input_length = bytes_read - 1;
  10. input_buffer->buffer[bytes_read - 1] = 0;
  11. }

Finally, we parse and execute the command. There is only one recognized command right now : .exit, which terminates the program. Otherwise we print an error message and continue the loop.

  1. if (strcmp(input_buffer->buffer, ".exit") == 0) {
  2. exit(EXIT_SUCCESS);
  3. } else {
  4. printf("Unrecognized command '%s'.\n", input_buffer->buffer);
  5. }

Let’s try it out!

  1. ~ ./db
  2. db > .tables
  3. Unrecognized command '.tables'.
  4. db > .exit
  5. ~

Alright, we’ve got a working REPL. In the next part, we’ll start developing our command language. Meanwhile, here’s the entire program from this part:

  1. #include <stdbool.h>
  2. #include <stdio.h>
  3. #include <stdlib.h>
  4. #include <string.h>
  5. struct InputBuffer_t {
  6. char* buffer;
  7. size_t buffer_length;
  8. ssize_t input_length;
  9. };
  10. typedef struct InputBuffer_t InputBuffer;
  11. InputBuffer* new_input_buffer() {
  12. InputBuffer* input_buffer = malloc(sizeof(InputBuffer));
  13. input_buffer->buffer = NULL;
  14. input_buffer->buffer_length = 0;
  15. input_buffer->input_length = 0;
  16. return input_buffer;
  17. }
  18. void print_prompt() { printf("db > "); }
  19. void read_input(InputBuffer* input_buffer) {
  20. ssize_t bytes_read =
  21. getline(&(input_buffer->buffer), &(input_buffer->buffer_length), stdin);
  22. if (bytes_read <= 0) {
  23. printf("Error reading input\n");
  24. exit(EXIT_FAILURE);
  25. }
  26. // Ignore trailing newline
  27. input_buffer->input_length = bytes_read - 1;
  28. input_buffer->buffer[bytes_read - 1] = 0;
  29. }
  30. int main(int argc, char* argv[]) {
  31. InputBuffer* input_buffer = new_input_buffer();
  32. while (true) {
  33. print_prompt();
  34. read_input(input_buffer);
  35. if (strcmp(input_buffer->buffer, ".exit") == 0) {
  36. exit(EXIT_SUCCESS);
  37. } else {
  38. printf("Unrecognized command '%s'.\n", input_buffer->buffer);
  39. }
  40. }
  41. }

Part 2 - World's Simplest SQL Compiler and Virtual Machine

原文: https://cstack.github.io/db_tutorial/parts/part1.html