Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Appendix A: Enough Rust to Build a Database

You do not need to master Rust before building a database. This appendix explains the language features used by the chapters, using code from the database itself. Read the section you need, then return to the database concept that brought you here.

This is a growing reference, not a Rust course. It deliberately leaves out features that our database does not use yet. When a later chapter needs something new, the corresponding explanation can be added here.

A.1 Build and run the project

Rust projects are commonly built with Cargo. Cargo.toml names the package and records its dependencies. src/main.rs is the entry point for our executable, while files such as src/row.rs and src/plan.rs hold related code.

cargo run --quiet
cargo test
cargo fmt --check
cargo clippy

cargo run builds and starts the program. --quiet hides Cargo’s routine messages. cargo test runs the test suite. The final two commands check formatting and warn about suspicious or unnecessarily complicated code.

A.2 Values, variables, and text

A variable gives a name to a value. Chapter 1 constructs Ada’s row with this expression:

Row::new(vec![
    ("id", Value::Integer(1)),
    ("name", Value::Text("Ada".to_string())),
    ("salary", Value::Integer(70_000)),
])

The program assigns expressions like this to names using let. For example, let employees = ... binds employees to the relation on the right. Variables cannot be reassigned by default. Adding mut, as in let mut output_rows, permits the code to change that variable’s value.

vec![...] creates a growable list. This list contains column-and-value pairs. The complete employees variable uses another list around its three rows. Underscores improve the readability of number literals, so 70_000 has the same value as 70000.

String owns its text. A value such as "Ada" is a string slice, written &str, which provides a read-only view of text stored elsewhere. Calling .to_string() creates an owned String.

A.3 Group data with enums and structs

An enum represents a value that can take one of several forms:

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Value {
    Integer(i64),
    Text(String),
}

Integer and Text are variants of Value. The data in parentheses belongs to that variant. i64 is a signed 64-bit integer. Handling the variants separately prevents the engine from treating a name as a salary.

A struct groups fields that always belong together:

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Row {
    values: Vec<(String, Value)>,
}

Row has one field named values. Its type is a list of tuples. Each tuple contains exactly two items: a column name and its value. The angle brackets describe the kind of item stored in the list.

The pub keyword makes an item available outside its module. The #[derive(...)] attribute asks Rust to generate common behavior. Clone allows explicit cloning, Debug provides diagnostic formatting, and PartialEq with Eq permits equality comparisons. Clone does not copy values automatically.

A.4 Define and call methods

An impl block defines behavior associated with a type. This excerpt shows the first two Row methods; project() follows them in src/row.rs:

impl Row {
    pub fn new(values: Vec<(&str, Value)>) -> Self {
        let mut owned_values = Vec::new();

        for (column, value) in values {
            owned_values.push((column.to_string(), value));
        }

        Self {
            values: owned_values,
        }
    }

    pub fn get(&self, column: &str) -> Option<&Value> {
        for (name, value) in &self.values {
            if name == column {
                return Some(value);
            }
        }

        None
    }
}

Row::new(...) calls a function associated with the Row type. It does not operate on an existing row. Inside this block, Self means Row. By contrast, row.get("salary") calls a method on a particular row, and &self gives the method read-only access to it.

Row::new(), Row::get(), and Row::project() belong to our database. They are not built into Rust. The arrow in -> Self states what a function returns. Other examples include -> Vec<Row> and -> Option<&Value>.

Rust’s Display trait defines how a value appears in user-facing text. The implementation for Value handles its two variants separately:

impl fmt::Display for Value {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Value::Integer(value) => write!(formatter, "{value}"),
            Value::Text(value) => write!(formatter, "\"{value}\""),
        }
    }
}

The standard fmt module supplies Display, Formatter, and fmt::Result. A Result represents either success or failure, and fmt::Result is the form used while formatting text. write! sends text to the formatter. The ? returns a formatting failure to the caller instead of continuing. Once Row implements Display, println!("{row}") uses it automatically.

The row formatter calls .iter().enumerate() to visit stored values while also receiving their positions. It writes ", " before every entry except the first, avoiding an extra comma at either end of the row.

A.5 Make decisions and repeat work

A for loop processes a collection one item at a time. Row::get() uses one to search its column-and-value pairs:

for (name, value) in &self.values {
    if name == column {
        return Some(value);
    }
}

None

The loop borrows the stored list and names the two parts of each pair. if runs its block only when the column names are equal. return immediately finishes the method.

Option represents a value that may be absent. Some(value) means the search succeeded, while None means no requested column was found. This makes absence explicit instead of inventing a special value for it.

A match chooses behavior based on the form of a value:

let value = match row.get(column) {
    Some(Value::Integer(value)) => value,
    Some(Value::Text(_)) => panic!("column is not an integer: {column}"),
    None => panic!("unknown column: {column}"),
};

The first arm extracts an integer. The second recognizes text, and _ means its contents are not needed. The final arm handles a missing column. Here, panic! stops the program with the supplied message. Later chapters will introduce ordinary query errors when they are needed.

A.6 Own, borrow, clone, and box data

Rust tracks who owns each value. When an owner goes away, Rust releases that value. Passing an owned value can move it to a new owner. A reference beginning with & temporarily borrows a value, allowing code to inspect it without taking it from its owner.

Our executor sometimes needs a separate owned result. The scan uses rows.clone() to make a complete copy, and projection clones selected names and values into new rows. These copies are deliberately simple, but chapter 1 identifies their cost.

Plans introduce one more ownership tool:

Filter {
    column: String,
    greater_than: i64,
    input: Box<Plan>,
}

A plan can contain another plan, which makes the type recursive. Rust must know the size of every directly stored value, but an endlessly nested Plan has no fixed direct size. Box<Plan> stores the child separately and keeps a fixed-size pointer in the parent. Box::new(child) creates that box.

A.7 Modules and tests

Each Rust file is a module in this small project. The beginning of main.rs declares two modules and brings selected names into scope:

mod plan;
mod row;

use plan::Plan;
use row::{Row, Value};

Tests live beside the code they verify. #[test] marks a function as a test, and assert_eq! compares an actual value with the expected value:

#[test]
fn scan_returns_source_rows() {
    let rows = employees();
    let plan = Plan::Scan { rows: rows.clone() };

    assert_eq!(plan.execute(), rows);
}

If the values differ, the assertion fails and prints diagnostic output. The derived Debug, PartialEq, and Eq behavior on Row and Value makes this comparison possible.

A.8 Read compiler errors

Compiler messages often look larger than the mistake that caused them. Start with the first error, note its file and line, and read the primary message before its suggestions. Later messages may be consequences of the first one.

For example, calling row.project(columns) before defining project() causes Rust to report that no such method exists for Row. Check whether the name is misspelled, whether its impl block exists, and whether the item is accessible. Fix that first error, then compile again.

Treat compiler suggestions as clues rather than commands. A suggestion may make the program compile while adding a copy we did not intend. Ask what the value represents in the database and who should own it, then choose the smallest change that preserves that meaning.