This is where I will be blogging about my projects!Zola2024-11-24T00:00:00+00:00https://blog.lucasholten.com/atom.xmlAnnouncing rust-query2024-11-24T00:00:00+00:002024-11-24T00:00:00+00:00Unknownhttps://blog.lucasholten.com/rust-query-announcement/<h1 id="safe-relational-database-queries-using-the-rust-type-system">Safe relational database queries using the Rust type system</h1>
<p>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 <a href="https://github.com/LHolten/rust-query">I am making <code>rust-query</code></a> for you!</p>
<blockquote>
<p>This is my first blog post about <code>rust-query</code>, a project I've been working on for many months. I hope you like it!</p>
</blockquote>
<h3 id="rust-and-databases">Rust and Databases</h3>
<p>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.</p>
<p>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.</p>
<h2 id="structured-query-language-sql-is-a-protocol">Structured Query Language (SQL) is a protocol</h2>
<p>For those who don't know, SQL is <strong>the</strong> standard when it comes to interacting with databases. So much so that almost all databases only accept queries in some dialect of SQL.</p>
<p>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.</p>
<h1 id="introducing-rust-query">Introducing <code>rust-query</code></h1>
<p><a href="https://crates.io/crates/rust-query"><code>rust-query</code></a> 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.</p>
<h2 id="key-features-and-design-decisions">Key Features and Design Decisions</h2>
<p>I could write a blog post about each one of these, but let's keep it short for now:</p>
<ul>
<li><strong>Explicit table aliasing</strong>: Joining a table gives back a dummy representing that table <code>let user = User::join(rows);</code>.</li>
<li><strong>Null safety</strong>: Optional values in queries have <code>Option</code> type, requiring special care to handle.</li>
<li><strong>Intuitive aggregates</strong>: Our aggregates are guaranteed to give a single result for every row they're joined on. After trying it, you'll see this is much more intuitive than traditional <code>GROUP BY</code> operations.</li>
<li><strong>Type-safe foreign key navigation</strong>: Database constraints are like type signatures, so you can rely on them for your queries with easy-to-use implicit joins by foreign key (e.g., <code>track.album().artist().name()</code>).</li>
<li><strong>Type-safe unique lookups</strong>: For example, you can get an <code>Option<Rating></code> dummy with <code>Rating::unique(my_user, my_story)</code>.</li>
<li><strong>Multi-versioned schema</strong>: It's declarative and you can see the differences between all past versions of the schema at once!</li>
<li><strong>Type-safe migrations</strong>: Migrations have all the power of queries and can use arbitrary Rust code to process rows. Ever had to consult something outside the database for use in a migration? Now you can!</li>
<li><strong>Type-safe unique conflicts</strong>: Inserting and updating rows in tables with unique constraints results in specialized error types.</li>
<li><strong>Row references tied to transaction lifetime</strong>: Row references can only be used while the row is guaranteed to exist.</li>
<li><strong>Encapsulated typed row IDs</strong>: The actual row numbers are never exposed from the library API. Application logic should not need to know about them.</li>
</ul>
<h2 id="let-s-see-it">Let's see it!</h2>
<p>You always start by defining a schema. With <code>rust-query</code> it's easy to migrate to a different schema later.</p>
<pre data-lang="rust" style="background-color:#282828;color:#ebdbb280;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#fbf1c7;">#[</span><span style="color:#83a598;">schema</span><span style="color:#fbf1c7;">]
</span><span style="color:#fb4934;">enum </span><span style="color:#fbf1c7;">Schema {
</span><span style="color:#fbf1c7;"> User {
</span><span style="color:#fbf1c7;"> name: </span><span style="color:#fabd2f;">String</span><span style="color:#fbf1c7;">,
</span><span style="color:#fbf1c7;"> },
</span><span style="color:#fbf1c7;"> Story {
</span><span style="color:#fbf1c7;"> author: User,
</span><span style="color:#fbf1c7;"> title: </span><span style="color:#fabd2f;">String</span><span style="color:#fbf1c7;">,
</span><span style="color:#fbf1c7;"> content: </span><span style="color:#fabd2f;">String
</span><span style="color:#fbf1c7;"> },
</span><span style="color:#fbf1c7;"> #[</span><span style="color:#83a598;">unique</span><span style="color:#fbf1c7;">(user, story)]
</span><span style="color:#fbf1c7;"> Rating {
</span><span style="color:#fbf1c7;"> user: User,
</span><span style="color:#fbf1c7;"> story: Story,
</span><span style="color:#fbf1c7;"> stars: </span><span style="color:#fb4934;">i64
</span><span style="color:#fbf1c7;"> },
</span><span style="color:#fbf1c7;">}
</span><span style="color:#fb4934;">use </span><span style="color:#fbf1c7;">v0::</span><span style="color:#8ec07c;">*</span><span style="color:#fbf1c7;">;
</span></code></pre>
<p>Schema defintions in <code>rust-query</code> use enum syntax, but no actual enum is defined here.
This schema defines three tables with specified columns and relationships:</p>
<ul>
<li>Using another table name as a column type creates a foreign key constraint.</li>
<li>The <code>#[unique]</code> attribute creates named unique constraints.</li>
<li>The <code>#[schema]</code> macro parses the enum syntax and generates a module <code>v0</code> that contains the database API.</li>
</ul>
<h3 id="writing-queries">Writing Queries</h3>
<p>First, let's see how to insert some data into our schema:</p>
<pre data-lang="rust" style="background-color:#282828;color:#ebdbb280;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#8ec07c;">fn </span><span style="color:#b8bb26;">insert_data</span><span style="color:#fbf1c7;">(txn: </span><span style="color:#8ec07c;">&</span><span style="color:#fb4934;">mut </span><span style="color:#fbf1c7;">TransactionMut<Schema>) {
</span><span style="color:#fbf1c7;"> </span><span style="font-style:italic;color:#928374;">// Insert users
</span><span style="color:#fbf1c7;"> </span><span style="color:#fb4934;">let</span><span style="color:#fbf1c7;"> alice </span><span style="color:#8ec07c;">=</span><span style="color:#fbf1c7;"> txn.</span><span style="color:#8ec07c;">insert</span><span style="color:#fbf1c7;">(User {
</span><span style="color:#fbf1c7;"> name: "</span><span style="color:#b8bb26;">alice</span><span style="color:#fbf1c7;">",
</span><span style="color:#fbf1c7;"> });
</span><span style="color:#fbf1c7;"> </span><span style="color:#fb4934;">let</span><span style="color:#fbf1c7;"> bob </span><span style="color:#8ec07c;">=</span><span style="color:#fbf1c7;"> txn.</span><span style="color:#8ec07c;">insert</span><span style="color:#fbf1c7;">(User {
</span><span style="color:#fbf1c7;"> name: "</span><span style="color:#b8bb26;">bob</span><span style="color:#fbf1c7;">",
</span><span style="color:#fbf1c7;"> });
</span><span style="color:#fbf1c7;">
</span><span style="color:#fbf1c7;"> </span><span style="font-style:italic;color:#928374;">// Insert a story
</span><span style="color:#fbf1c7;"> </span><span style="color:#fb4934;">let</span><span style="color:#fbf1c7;"> dream </span><span style="color:#8ec07c;">=</span><span style="color:#fbf1c7;"> txn.</span><span style="color:#8ec07c;">insert</span><span style="color:#fbf1c7;">(Story {
</span><span style="color:#fbf1c7;"> author: alice,
</span><span style="color:#fbf1c7;"> title: "</span><span style="color:#b8bb26;">My crazy dream</span><span style="color:#fbf1c7;">",
</span><span style="color:#fbf1c7;"> content: "</span><span style="color:#b8bb26;">A dinosaur and a bird...</span><span style="color:#fbf1c7;">",
</span><span style="color:#fbf1c7;"> });
</span><span style="color:#fbf1c7;">
</span><span style="color:#fbf1c7;"> </span><span style="font-style:italic;color:#928374;">// Insert a rating - note the try_insert due to the unique constraint
</span><span style="color:#fbf1c7;"> </span><span style="color:#fb4934;">let</span><span style="color:#fbf1c7;"> rating </span><span style="color:#8ec07c;">=</span><span style="color:#fbf1c7;"> txn.</span><span style="color:#8ec07c;">try_insert</span><span style="color:#fbf1c7;">(Rating {
</span><span style="color:#fbf1c7;"> user: bob,
</span><span style="color:#fbf1c7;"> story: dream,
</span><span style="color:#fbf1c7;"> stars: </span><span style="color:#d3869b;">5</span><span style="color:#fbf1c7;">,
</span><span style="color:#fbf1c7;"> }).</span><span style="color:#8ec07c;">expect</span><span style="color:#fbf1c7;">("</span><span style="color:#b8bb26;">no rating for this user and story exists yet</span><span style="color:#fbf1c7;">");
</span><span style="color:#fbf1c7;">}
</span></code></pre>
<p>A few important points about insertions:</p>
<ul>
<li>We need a mutable transaction (<code>TransactionMut</code>) to modify the database.</li>
<li>Insert operations return references to the newly inserted rows.</li>
<li>When inserting into tables with unique constraints, use <code>try_insert</code> to handle potential conflicts.</li>
<li>The error type of <code>try_insert</code> is based on how many unique constraints the table has.</li>
</ul>
<p>Now let's query this data:</p>
<pre data-lang="rust" style="background-color:#282828;color:#ebdbb280;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#8ec07c;">fn </span><span style="color:#b8bb26;">query_data</span><span style="color:#fbf1c7;">(txn: </span><span style="color:#8ec07c;">&</span><span style="color:#fbf1c7;">Transaction<Schema>) {
</span><span style="color:#fbf1c7;"> </span><span style="color:#fb4934;">let</span><span style="color:#fbf1c7;"> results </span><span style="color:#8ec07c;">=</span><span style="color:#fbf1c7;"> txn.</span><span style="color:#8ec07c;">query</span><span style="color:#fbf1c7;">(|rows| {
</span><span style="color:#fbf1c7;"> </span><span style="color:#fb4934;">let</span><span style="color:#fbf1c7;"> story </span><span style="color:#8ec07c;">= </span><span style="color:#fbf1c7;">Story::join(rows);
</span><span style="color:#fbf1c7;"> </span><span style="color:#fb4934;">let</span><span style="color:#fbf1c7;"> avg_rating </span><span style="color:#8ec07c;">= aggregate</span><span style="color:#fbf1c7;">(|rows| {
</span><span style="color:#fbf1c7;"> </span><span style="color:#fb4934;">let</span><span style="color:#fbf1c7;"> rating </span><span style="color:#8ec07c;">= </span><span style="color:#fbf1c7;">Rating::join(rows);
</span><span style="color:#fbf1c7;"> rows.</span><span style="color:#8ec07c;">filter_on</span><span style="color:#fbf1c7;">(rating.</span><span style="color:#8ec07c;">story</span><span style="color:#fbf1c7;">(), </span><span style="color:#8ec07c;">&</span><span style="color:#fbf1c7;">story);
</span><span style="color:#fbf1c7;"> rows.</span><span style="color:#8ec07c;">avg</span><span style="color:#fbf1c7;">(rating.</span><span style="color:#8ec07c;">stars</span><span style="color:#fbf1c7;">().</span><span style="color:#8ec07c;">as_float</span><span style="color:#fbf1c7;">())
</span><span style="color:#fbf1c7;"> });
</span><span style="color:#fbf1c7;"> rows.</span><span style="color:#8ec07c;">into_vec</span><span style="color:#fbf1c7;">((story.</span><span style="color:#8ec07c;">title</span><span style="color:#fbf1c7;">(), avg_rating))
</span><span style="color:#fbf1c7;"> });
</span><span style="color:#fbf1c7;">
</span><span style="color:#fbf1c7;"> </span><span style="color:#fb4934;">for </span><span style="color:#fbf1c7;">(title, avg_rating) </span><span style="color:#8ec07c;">in</span><span style="color:#fbf1c7;"> results {
</span><span style="color:#fbf1c7;"> println!("</span><span style="color:#b8bb26;">story '</span><span style="color:#8ec07c;">{title}</span><span style="color:#b8bb26;">' has avg rating </span><span style="color:#8ec07c;">{avg_rating:?}</span><span style="color:#fbf1c7;">");
</span><span style="color:#fbf1c7;"> }
</span><span style="color:#fbf1c7;">}
</span></code></pre>
<p>Key points about queries:</p>
<ul>
<li><code>rows</code> represents the current set of rows in the query.</li>
<li>Joins can add rows and filters can remove rows. <details>By joining a table like <code>Story</code>, the <code>rows</code> set is mutated to be the Cartesian product of itself and the rows from the joined table. The query above only has a single <code>join</code>, so we know it will give exactly one result for each row in the <code>Story</code> table.</details> </li>
<li>Using <code>aggregate</code> to calculate an aggregate, does not change the number of rows in the query.</li>
<li><code>rows.filter_on</code> can be used to filter rows in the aggregate to match a value from the outer scope.</li>
<li>The <code>rows.avg</code> method returns the average of the rows in the aggregate, if there are no rows then the average will evaluate to <code>None</code>.</li>
<li>Results can be collected into vectors of tuples or structs.</li>
</ul>
<h3 id="schema-evolution-and-migrations">Schema Evolution and Migrations</h3>
<p>Let's say you want to add an email address to each user. Here's how you'd create the new schema version:</p>
<pre data-lang="rust" style="background-color:#282828;color:#ebdbb280;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#fbf1c7;">#[</span><span style="color:#83a598;">schema</span><span style="color:#fbf1c7;">]
</span><span style="color:#fbf1c7;">#[</span><span style="color:#83a598;">version</span><span style="color:#fbf1c7;">(0..</span><span style="color:#8ec07c;">=</span><span style="color:#fbf1c7;">1)]
</span><span style="color:#fb4934;">enum </span><span style="color:#fbf1c7;">Schema {
</span><span style="color:#fbf1c7;"> User {
</span><span style="color:#fbf1c7;"> name: </span><span style="color:#fabd2f;">String</span><span style="color:#fbf1c7;">,
</span><span style="color:#fbf1c7;"> #[</span><span style="color:#83a598;">version</span><span style="color:#fbf1c7;">(1..)]
</span><span style="color:#fbf1c7;"> email: </span><span style="color:#fabd2f;">String</span><span style="color:#fbf1c7;">,
</span><span style="color:#fbf1c7;"> },
</span><span style="color:#fbf1c7;"> </span><span style="font-style:italic;color:#928374;">// ... rest of schema ...
</span><span style="color:#fbf1c7;">}
</span><span style="color:#fb4934;">use </span><span style="color:#fbf1c7;">v1::</span><span style="color:#8ec07c;">*</span><span style="color:#fbf1c7;">;
</span></code></pre>
<p>And here's how you'd migrate the data:</p>
<pre data-lang="rust" style="background-color:#282828;color:#ebdbb280;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#fb4934;">let</span><span style="color:#fbf1c7;"> m </span><span style="color:#8ec07c;">=</span><span style="color:#fbf1c7;"> m.</span><span style="color:#8ec07c;">migrate</span><span style="color:#fbf1c7;">(v1::update::Schema {
</span><span style="color:#fbf1c7;"> user: </span><span style="color:#fabd2f;">Box</span><span style="color:#fbf1c7;">::new(|old_user| {
</span><span style="color:#fbf1c7;"> Alter::new(v1::update::UserMigration {
</span><span style="color:#fbf1c7;"> email: old_user
</span><span style="color:#fbf1c7;"> .</span><span style="color:#8ec07c;">name</span><span style="color:#fbf1c7;">()
</span><span style="color:#fbf1c7;"> .</span><span style="color:#8ec07c;">map_dummy</span><span style="color:#fbf1c7;">(|name| format!("</span><span style="color:#8ec07c;">{name}</span><span style="color:#b8bb26;">@example.com</span><span style="color:#fbf1c7;">")),
</span><span style="color:#fbf1c7;"> })
</span><span style="color:#fbf1c7;"> }),
</span><span style="color:#fbf1c7;">});
</span></code></pre>
<ul>
<li>The <code>v1::update</code> module contains structs defining the difference between schema <code>v0</code> and schema <code>v1</code>.</li>
<li>We use these structs to implement the migration. This way the migration is type checked against both the old and new schemas.</li>
<li>Note that inside migrations we can execute all the single-row queries we want: aggregates, unique constraint lookups etc.!</li>
<li>We can also use <code>map_dummy</code> with arbitrary Rust to process rows further.</li>
</ul>
<h2 id="conclusion">Conclusion</h2>
<p><code>rust-query</code> represents a fresh approach to database interactions in Rust, prioritizing:</p>
<ul>
<li>Checking everything possible at compile time.</li>
<li>Making it possible to compose queries with each other and arbitrary Rust.</li>
<li>Enabling schema evolution with type-checked migrations.</li>
</ul>
<p>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 <a href="https://github.com/LHolten/rust-query">GitHub</a> issues!</p>
<blockquote>
<p>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 <code>rust-query</code> is in development.</p>
</blockquote>