Announcing rust-query

Safe relational database queries using the Rust type system

Do you want to persist your data safely without migration issues and easily write complicated queries? All of this without writing a single line of SQL? If so, then I am making rust-query for you!

This is my first blog post about rust-query, a project I've been working on for many months. I hope you like it!

Rust and Databases

There is only one reason why I made this library and it is because I don't like the current options for interacting with a database from Rust. The existing libraries don't provide the compile time guarantees that I want and are verbose or awkward like SQL.

The reason I care so much is that databases are really cool. They solve a huge problem of making crash-resistant software with support for atomic transactions.

Structured Query Language (SQL) is a protocol

For those who don't know, SQL is the standard when it comes to interacting with databases. So much so that almost all databases only accept queries in some dialect of SQL.

My opinion is that SQL should be for computers to write. This would put it firmly in the same category as LLVM IR. The fact that it is human-readable is useful for debugging and testing, but I don't think it's how you want to write queries.

Introducing rust-query

rust-query is my answer to relational database queries in Rust. It's an opinionated library that deeply integrates with Rust's type system to make database operations feel Rust-native.

Key Features and Design Decisions

I could write a blog post about each one of these, but let's keep it short for now:

Let's see it!

You always start by defining a schema. With rust-query it's easy to migrate to a different schema later.

#[schema]
enum Schema {
    User {
        name: String,
    },
    Story {
        author: User,
        title: String,
        content: String
    },
    #[unique(user, story)]
    Rating {
        user: User,
        story: Story,
        stars: i64
    },
}
use v0::*;

Schema defintions in rust-query use enum syntax, but no actual enum is defined here. This schema defines three tables with specified columns and relationships:

Writing Queries

First, let's see how to insert some data into our schema:

fn insert_data(txn: &mut TransactionMut<Schema>) {
    // Insert users
    let alice = txn.insert(User {
        name: "alice",
    });
    let bob = txn.insert(User {
        name: "bob",
    });
    
    // Insert a story
    let dream = txn.insert(Story {
        author: alice,
        title: "My crazy dream",
        content: "A dinosaur and a bird...",
    });
    
    // Insert a rating - note the try_insert due to the unique constraint
    let rating = txn.try_insert(Rating {
        user: bob,
        story: dream,
        stars: 5,
    }).expect("no rating for this user and story exists yet");
}

A few important points about insertions:

Now let's query this data:

fn query_data(txn: &Transaction<Schema>) {
    let results = txn.query(|rows| {
        let story = Story::join(rows);
        let avg_rating = aggregate(|rows| {
            let rating = Rating::join(rows);
            rows.filter_on(rating.story(), &story);
            rows.avg(rating.stars().as_float())
        });
        rows.into_vec((story.title(), avg_rating))
    });

    for (title, avg_rating) in results {
        println!("story '{title}' has avg rating {avg_rating:?}");
    }
}

Key points about queries:

Schema Evolution and Migrations

Let's say you want to add an email address to each user. Here's how you'd create the new schema version:

#[schema]
#[version(0..=1)]
enum Schema {
    User {
        name: String,
        #[version(1..)]
        email: String,
    },
    // ... rest of schema ...
}
use v1::*;

And here's how you'd migrate the data:

let m = m.migrate(v1::update::Schema {
    user: Box::new(|old_user| {
        Alter::new(v1::update::UserMigration {
            email: old_user
                .name()
                .map_dummy(|name| format!("{name}@example.com")),
        })
    }),
});

Conclusion

rust-query represents a fresh approach to database interactions in Rust, prioritizing:

While still in development, the library already allows building experimental database-backed applications in Rust. I encourage you to try it out and provide feedback through GitHub issues!

The library currently uses SQLite as its only backend, chosen for its embedded nature. This will not change anytime soon, as one backend is most practical while rust-query is in development.