loading...

Learning Rust 2: A Tiny Database is Born

jbachhardie profile image Jae Anne Bach Hardie ・6 min read

Hello again! Tonight we're drinking a Chardonnay from Murcia, Spain, so shoutout to my home country hope you're holding up I hear things have been rough lately. It's pleasantly dry if not particularly complex, tasting mostly like crunchy green apples.

Another thing that won't be particularly complex is my plan for the next steps in this project. Last time I said I was going to attempt to make a VCS — Version Control System, like Git or Mercurial — in Rust. We got as far as getting things running enough to read a file and print out it's contents, so not very far at all. Today I want to get away from just toying with the language and see if I can make something that's at least theoretically useful without being unachievable. When tackling a large project — especially when your only motivation is to learn things and maybe write silly blog posts — it's important to have small milestones that build on each other so you can evaluate your progress and learn as you go.

I've decided that my first milestone is to make a non-branching, client-only VCS. It's like you had a git repository where you only have your main branch and there's no remote to push to. It's still useful in that you can view all previous states of the repository and roll back to them but it has no collaboration features. This is pretty simple to start with but it's also an important component of a more fleshed-out VCS. No matter how big Ribbit gets, it will still have to track a branch's history.

The architecture for this kind of system can be really straightforward: a repository consists of a chronological list of Revisions, each of which consists of one or more Changes. Each Change is a transformation applied to a file. At least to start with we'll model every Change as completely replacing the previous state of the file since diffs and merging aren't necessary when we don't have collaboration features. We could store all this in many different ways but I figure SQLite is a reasonably scalable solution that seems to have bindings for Rust available. I'm not looking forward to manual management of database connections but this is part of what learning Rust is.

As a first step, I install the SQLite crate by adding it to my Cargo.toml:

[dependencies]
rusqlite = "0.24.0"

And I run cargo build. Cargo fetches and builds the crate no problem, so now I should be able to create a database that stores the state of our repository. Taking inspiration from git, I'll store it under .ribbit/repo.db. Let's see if it works:

use rusqlite::Connection;

fn create_database() -> rusqlite::Result<Connection> {
    let path = "./.ribbit/repo.db";
    let db = Connection::open(&path)?;
    return Ok(db);
}

fn main() -> rusqlite::Result<()> {
    let db = create_database()?;

    Ok(())
}

I put the database creation logic into its own function. I want to start gently dividing the logic I'm writing into functions. Eventually I'll be making multiple crates to organise the code into relevant modules and it will be easier if everything isn't in one large main() function.

When I try to run this, the compiler errors, complaining that it can't find "sqlite3.lib". I guess I don't have SQLite installed on my machine. I'm used to it being around but I must not have used it on any projects on this laptop yet. I install it via Chocolatey but that still doesn't work. I don't really want to spend my evening learning how to get C libraries to work on Windows so I follow the advice of the rusqlite README and enable the option to compile SQLite from source in my Cargo.toml:

[dependencies.rusqlite]
version = "0.24.0"
features = ["bundled"]

Ribbit now compiles again but returns an error: cannot open database file. I'm pretty sure Connection::open is meant to create the database file but it probably doesn't create the folder it should be in if that doesn't exist, so I need to figure out how to create a folder. I find the function I need in the std::fs crate but my naive implementation doesn't compile:

fn create_database() -> rusqlite::Result<Connection> {
    fs::create_dir("./.ribbit")?;
    let db = Connection::open("./.ribbit/repo.db")?;
    return Ok(db);
}

It turns out errors potentially returned from the fs crate are different from those returned from the rusqlite crate, so I can't just stick a ? after create_dir and count on it to just short-circuit if an error is returned. I could create a custom Result type that could contain either kind of error but I figure if I can't create the directory to store the repository data there's not much else the program can do anyway so I decide to use the .unwrap() method instead, which panics — aborts program execution — if there's an error. Panics don't need to be handled by the type system because the program terminates, but they do mean the error can't be caught later.

fn create_database() -> rusqlite::Result<Connection> {
    fs::create_dir("./.ribbit").unwrap();
    let db = Connection::open("./.ribbit/repo.db")?;
    return Ok(db);
}

This works! An empty "./.ribbit/repo.db" file has been created, and reminds me I need to add .ribbit to my .gitignore file. However, when I try to run the code again I see a problem. The program panics because we can't create a directory that already exists. That's a silly reason to abort execution because the database could still be created, so we'll have to check for the existence of the directory before trying to create it:

fn create_database() -> rusqlite::Result<Connection> {
    let ribbit_path = "./.ribbit";
    match fs::metadata(&ribbit_path) {
        Ok(_) => (),
        Err(error) => match error.kind() {
            io::ErrorKind::AlreadyExists => fs::create_dir(&ribbit_path).unwrap(),
            other_error => panic!(other_error),
        },
    };
    let db = Connection::open("./.ribbit/repo.db")?;
    return Ok(db);
}

Correction added 2020-08-31

This code is wrong. The error kind we need to match on is the file not existing rather than already existing. These are the perils of coding late at night, I got what I was doing all mixed up and then didn't test it properly. Part of my objective with this series is to show when and why mistakes happen during learning so I'm going to leave this here until I fix it in the next installment but I didn't want anyone to copy this code believing it should work as described.

End correction

This uses a language feature that I learned to love years ago in Elixir: pattern matching. The branches of a "match" block execute only if the input to the match matches the left hand side and if it does any variables declared within the match store the value in that place of the structure. This is hard to explain but intuitive to write and allow us to easily extract the error from the result and match on its kind. Now the only step that is missing is to create the database table we'll need:

fn create_database() -> rusqlite::Result<Connection> {
    let ribbit_path = "./.ribbit";
    match fs::metadata(&ribbit_path) {
        Ok(_) => (),
        Err(error) => match error.kind() {
            io::ErrorKind::AlreadyExists => fs::create_dir(&ribbit_path).unwrap(),
            other_error => panic!(other_error),
        },
    };
    let db = Connection::open("./.ribbit/repo.db")?;
    db.execute(
        "create table if not exists changes (
            id text primary key,
            date_created integer not null,
            revision_id text not null,
            file_path text not null
        )",
        rusqlite::NO_PARAMS,
    )?;
    return Ok(db);
}

In the future I'm pretty sure we'll need revisions at the very least to have their own table so they can have a message associated with them but for now both revisions and files existing simply as ids in the changes table will be enough. I confirm this code works by opening the database in SQLEctron and checking the table has been created.

This is enough for one night. I already feel myself becoming more confident with Rust's syntax, even though most of my code is still written by modifying copy-and-paste example code from various documentation sources. Next time I'll look into how to read command line arguments so I can add commands to ribbit and start not only initialising the repository but saving some revisions to it.

Posted on by:

Discussion

markdown guide