This is where I will be blogging about my projects! Zola 2024-11-24T00:00:00+00:00 https://blog.lucasholten.com/atom.xml Announcing rust-query 2024-11-24T00:00:00+00:00 2024-11-24T00:00:00+00:00 Unknown https://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&lt;Rating&gt;</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;">&amp;</span><span style="color:#fb4934;">mut </span><span style="color:#fbf1c7;">TransactionMut&lt;Schema&gt;) { </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: &quot;</span><span style="color:#b8bb26;">alice</span><span style="color:#fbf1c7;">&quot;, </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: &quot;</span><span style="color:#b8bb26;">bob</span><span style="color:#fbf1c7;">&quot;, </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: &quot;</span><span style="color:#b8bb26;">My crazy dream</span><span style="color:#fbf1c7;">&quot;, </span><span style="color:#fbf1c7;"> content: &quot;</span><span style="color:#b8bb26;">A dinosaur and a bird...</span><span style="color:#fbf1c7;">&quot;, </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;">(&quot;</span><span style="color:#b8bb26;">no rating for this user and story exists yet</span><span style="color:#fbf1c7;">&quot;); </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;">&amp;</span><span style="color:#fbf1c7;">Transaction&lt;Schema&gt;) { </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;">&amp;</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!(&quot;</span><span style="color:#b8bb26;">story &#39;</span><span style="color:#8ec07c;">{title}</span><span style="color:#b8bb26;">&#39; has avg rating </span><span style="color:#8ec07c;">{avg_rating:?}</span><span style="color:#fbf1c7;">&quot;); </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!(&quot;</span><span style="color:#8ec07c;">{name}</span><span style="color:#b8bb26;">@example.com</span><span style="color:#fbf1c7;">&quot;)), </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>