Build with Naz : Diesel ORM, SQLite, and Rust
- Introduction
- rusqlite and SQLite example
- diesel and SQLite example
- Why Diesel and not SQLx?
- YouTube videos for this article
- 1. Add the Cargo dependencies
- 2. Add Linux packages (sqlite-dev) and Diesel CLI
- 3. Use the Diesel CLI to create database file and migrations
- 4. Write SQL migrations, then run them to create tables and generate schema.rs
- 5. Use the script, Luke
- 6. Add a new migration that changes existing tables by adding a new column and preserve data
- 7. Diesel and Rust
- VSCode and SQLite extension
- History
- Build with Naz video series on developerlife.com YouTube channel
Introduction #
This crate is an exploration of SQL in Rust. All the examples use SQLite as the database. However, the driver / ORM used is different in each example.
The code in the video and this tutorial are all in this GitHub repo.
rusqlite and SQLite example #
The rusqlite
library is a low-level SQLite driver for Rust.
- It is a thin wrapper around the SQLite C API.
- And it bundles the SQLite C library, so there is no need to install
sqlite3
on the system.
The primary use case that this example addresses is storing an application’s settings that are a mix of binary and JSON formatted text data. Using the filesystem naively where we have a separate file for each, can cause problems in scenarios where multiple processes of this binary run concurrently. Instead we will use a SQLite database to store this data.
This example works with 2 tables:
- One contains JSON Text formatted data, and the
- The other contains binary data that’s read for a file.
This is meant to demonstrate how to work with JSON encoded data and binary data in SQLite using Rust. The example here does the following:
- This will create a
rusqlite.db
file in the current directory. - It will use the
rusqlite
Rust crate to interact with it to perform some simple CRUD operations on the database. - The code is very simple, there is no ORM, or SQL syntax checking, or migrations. The SQL is just written as Rust strings.
- There are 2 tables, one containing JSON text data, and the other containing binary data.
To run this example, use:
cargo run --bin rusqlite
diesel and SQLite example #
The diesel
library is a high-level ORM for Rust.
The main instructions are from the
diesel
official getting started guide for use with SQLite.
In this example we will work with Rust, Diesel, and SQLite to setup databases, using migrations, and do CRUD operations in Rust code. Here are the details:
- We will create a migration to setup the database.
- Then create the models.
- And write some code to do CRUD operations in Rust.
- Then we will add another migration to alter the database, then migrate any existing data, and then update the models to reflect the changes.
- Finally, we will automate these migrations so that they don’t have to be run manually. And they will be done in your binary target when it starts.
The example here does the following:
- This will create a
diesel.db
file in the current directory. It runs migrations as well programmatically when the binary runs, at the very start. - It will use the
diesel
Rust crate (and ORM) to interact with it to perform some simple CRUD operations on the database. - There are 2 tables, one containing JSON text data, and the other containing binary data.
To run this example, use:
cargo run --bin diesel
Why Diesel and not SQLx? #
Here are some reasons to use Diesel over SQLx.
YouTube videos for this article #
If you like to learn via video, please watch the companion video on the developerlife.com YouTube channel. You can follow along with the video, this article, and code in the repo.
The code in the video and this tutorial are all in this GitHub repo.
1. Add the Cargo dependencies #
Here are the commands to add the required dependencies in Cargo.toml
:
diesel = { version = "2.2.0", features = ["sqlite", "returning_clauses_for_sqlite_3_35"] }
dotenvy = "0.15"
2. Add Linux packages (sqlite-dev) and Diesel CLI #
All the steps between 2 and 5 can be automated by running the
diesel_setup.sh
script.
Here are the commands to setup the Diesel CLI for SQLite for Linux:
sudo apt install libsqlite3-dev
cargo install diesel_cli --no-default-features --features sqlite
3. Use the Diesel CLI to create database file and migrations #
There are a few ways to run diesel setup
. The path/to/your/database.db
is the value
for DATABASE_URL
.
-
Use the
.env
file to store theDATABASE_URL
environment variable. Runecho DATABASE_URL=diesel.db > .env
. Then you can rundiesel setup
. -
If you don’t want to set this environment variable, you can just pass it inline to the shell
DATABASE_URL=diesel.db diesel setup
. -
You can use the
--database-url
flag to specify the path to the database directly. For example:diesel --database-url=diesel.db setup
.
We are going to use the 3rd option. Here are the commands to setup the database file.
diesel --database-url=diesel.db setup
This command actually creates the database file. The diesel.db
file is created in the
current directory. If the migrations are already present (as can be gleaned from the
diesel.toml
file), then the schema.rs
file is generated and the diesel.db
file is
generated.
4. Write SQL migrations, then run them to create tables and generate schema.rs #
Now that the database file is created, we have to define our migration, that will actually
run some SQL that we provide and generate the schema.rs
file. This process can also
happen in reverse, where we can write the schema.rs
file first and ask the Diesel CLI to
generate the SQL migrations folder, and the up.sql
and down.sql
files.
The following commands will create a folder called migrations
at the top level of the
project. Inside this folder, there will be one folder, for the migration called
create_tables
. The folder will look like <timestamp>_create_tables
and will contain an
up.sql
and down.sql
file.
diesel --database-url=diesel.db migration generate create_tables
Migrations allow us to evolve the database schema over time. Each migration consists of an
up.sql
file to apply the changes and a down.sql
file to revert them. Applying and
immediately reverting a migration should leave your database schema unchanged.
If you have multiple migrations, they will be applied in the order they were created. They are additive. In this example, we create a single migration, and the
up.sql
in it creates two tables. However, we could have split this into two migrations, one for each table. Thedown.sql
does not get run when there are multiple migrations. It only gets run when you rundiesel migration redo
ordiesel migration revert
.
Once migrations are created they can be checked into version control. The folder structure
for each table has a timestamp in it, and contains a up.sql
and down.sql
file.
Here’s the up.sql
:
create table if not exists data_table (
id text primary key not null,
name text not null,
data text not null
);
create table if not exists file_table (
id text primary key not null,
name text not null,
data blob not null
);
Here’s the down.sql
:
drop table if exists data_table;
drop table if exists file_table;
Then execute the migrations:
# This executes the `up.sql` file.
diesel --database-url=diesel.db migration run
# This executes the `down.sql`, then `up.sql`.
diesel --database-url=diesel.db migration redo
The commands above will create a diesel.db
file in the current directory if it does not
exist.
Note that the redo
command does not work unless the run
command has been executed at
least once. Just to be safe, it is also best to delete the diesel.db
file in order to do
a total reset; then run run
and then redo
.
4.1 What is the difference between redo and run? #
redo
will run thedown.sql
and then theup.sql
file.run
will only run theup.sql
file.
Both commands will preserve any existing data in the
diesel.db
file. Migrations will not destroy the data in the tables, unless you explicitly write SQL to do so.
Let’s say you have run
the migration and then you make a change to up.sql
above, and
add a new column. If you run run
again you will not see these changes in your
schema.rs
file!
You could run redo
, which will run down.sql
and then up.sql
, and this should drop
the table from the diesel.db
file and then recreate it with the new column.
However, in this scenario it might be best to create a new migration and not modify the
existing one. This way you can keep track of the changes you made to the database schema
over time. Once you create the
new migration,
you can run diesel --database-url=diesel.db migration run
to apply the changes.
4.2. Location of the generated schema.rs file #
This will also generate the schema.rs
file in the src
directory. This file will have
the Rust representation of the tables in the database. You can change the location of this
file by changing the diesel.toml
file and setting the path for the print_schema:file
key. Here’s an example:
[print_schema]
file = "src/diesel_sqlite_ex/schema.rs"
4.3. For the current migration, change the up.sql file and run it again #
If you want to change the current migration, you can edit the up.sql
file and then run
the migration again. You can do this as many times as you want, without having to create a
new migration. This will simply regenerate the schema.rs
file.
Here’s how you can do that:
# Edit the up.sql file.
# Run the migration again.
diesel --database-url=diesel.db migration run
5. Use the script, Luke #
In steps 1 through 5, there are a lot of manual steps. Use the script as follows:
- Remove the following files & folders:
diesel.toml
,diesel.db
,migrations
,src/schema.rs
. - Run
./diesel_setup.sh
and it will create the database file, create a migration, which will generate theup.sql
anddown.sql
files. However, the migration will not be run and no.db
file will be created. - Edit the
up.sql
anddown.sql
files to add the SQL for creating and dropping tables. - Run
./diesel_setup.sh
again, and tell it to run the migrations and generate thediesel.db
andschema.rs
file.- This runs the
diesel migration run
command to exercise theup.sql
file. - And then runs the
diesel migration redo
command to exercise thedown.sql
file, and then theup.sql
file. - If you want to manually generate the
schema.rs
file, you can rundiesel print-schema > src/schema.rs
.
- This runs the
5.1. Instead of raw SQL, write Rust for migrations #
Alternatively, if you don’t want to write raw SQL to do the migrations, you can just start
with writing the src/schema.rs
file instead and then run
diesel migration generate --diff-schema create_tables
to have it generate the up.sql
and down.sql
files for you. The script does not currently support this.
5.2. Include migrations in the final binary #
When preparing your app for use in production, you may want to run your migrations during the application’s initialization phase. You may also want to include the migration scripts as a part of your code, to avoid having to copy them to your deployment location/image etc.
You can also include the migrations in the final binary of the application you’re building
by using the diesel_migration
crate’s embed_migrations!
macro.
This way there is no manual setup required to run the migrations and can be handled by the
binary itself.
6. Add a new migration that changes existing tables by adding a new column and preserve data #
Let’s say you have everything working so far, and you want to alter the existing tables by adding a new column, there are few things to keep in mind:
- There’s might be data in the tables, which are in the
diesel.db
file. - You want to preserve this data when you add a new column.
- When you add a new column, you have to backfill the data in the existing rows which were created when this column didn’t exist.
Here are the steps to create a new migration to alter existing tables by adding a new
column created_at
:
-
Create a new migration using:
diesel --database-url=diesel.db migration generate add_new_column_to_both_tables
-
Populate
up.sql
file in the new migration with the following SQL:-- Add a new column created_at to data_table. This can't be current_timestamp because -- SQLite doesn't support that. The default value must be a constant. alter table data_table add column created_at timestamp not null default '1900-01-01 12:12:12'; -- Add a new column created_at to file_table. This can't be current_timestamp because -- SQLite doesn't support that. The default value must be a constant. alter table file_table add column created_at timestamp not null default '1900-01-01 12:12:12'; -- Update the created_at column in data_table & file_table if needed (it is needed if the -- row's date is hard coded to '1900-01-01 12:12:12'. update data_table set created_at = current_timestamp where created_at is '1900-01-01 12:12:12'; update file_table set created_at = current_timestamp where created_at is '1900-01-01 12:12:12';
-
Populate the
down.sql
file in the new migration with the following SQL:-- Drop the created_at column from data_table. alter table data_table drop column created_at; -- Drop the created_at column from file_table. alter table file_table drop column created_at;
-
Finally run:
diesel --database-url=diesel.db migration run
Once all this is done, your diesel.db
file will have the new column created_at
in both
tables. However, the models
are still not updated to reflect this change. You can update
the structs in the models
module manually to accommodate these changes. Since the change
is SQL column type TIMESTAMP
related, you can add the following field:
pub struct DataTableRecord<'a> {
pub created_at: chrono::NaiveDateTime,
...
}
pub struct FileTableRecord<'a> {
pub created_at: chrono::NaiveDateTime,
...
}
See the timestamps section for more information on handling timestamps in Diesel, SQLite and Rust.
7. Diesel and Rust #
Create a connection #
We can just specify the path to the database directly when needed, instead of using the
DATABASE_URL
environment variable (and using .env
and and dotenvy
crate). There are
a few ways in which you can specify the database URL:
path/to/your/file.db
- Save the database file in the given path.file://file.db
- Save the database file in given path.:memory:
- Create an in-memory database.
Here’s an example of this in Rust:
use diesel::prelude::*;
use miette::*;
/// Specify your database URL, eg:
/// - `path/to/your/file.db` - Save the database file in the given path.
/// - `file://file.db` - Save the database file in given path.
/// - `:memory:` - Create an in-memory database.
///
/// See [SqliteConnection] for more details.
pub fn create_connection(database_url: &str) -> Result<SqliteConnection> {
SqliteConnection::establish(database_url).into_diagnostic()
}
CRUD operations #
This example demonstrates how to do CRUD operations with Diesel and Sqlite. The example provides examples of implementing CRUD on two different tables, one that holds structured JSON text data, and another that holds binary data.
Timestamps #
To handle SQLite TIMESTAMP
column type in Rust, add the chrono
feature in Diesel (in
your Cargo.toml
). Also add a dependency on the chrono
crate. Here’s a full listing of
the required dependencies for your Cargo.toml
:
diesel = { version = "2.2.4", features = [
# For SQLite support.
"sqlite",
# The enables returning clauses for SQLite 3.35 and later.
"returning_clauses_for_sqlite_3_35",
# For timestamp support.
"chrono",
] }
chrono = "0.4"
In the code, you can handle timestamps as follows:
- Save - Get the timestamp for current time in UTC. Do this in Rust, to create a new
timestamp that will be inserted or updated into the database.
- chrono::Utc::now() returns a chrono::DateTime::naive_utc()s which is a chrono::NaiveDateTime.
- Load - Convert the timestamp in the database to a
chrono::DateTime. Do this
in Rust, to read the timestamp from the database.
- Use
chrono::DateTime::from_naive_utc_and_offset
with the following args:
- chrono::NaiveDateTime from the previous step.
0
offset for the timezone.
- Use
chrono::DateTime::from_naive_utc_and_offset
with the following args:
- Human readable format - Convert a
chrono::NaiveDateTime
to a human readable string.
- Use the format options in
chrome::NaiveDateTime::format()
to format the timestamp. To get the output
around 10:44pm UTC on Nov 11
, you can use:record.created_at.format("around %I:%M%P UTC on %b %-d")
- Use the format options in
chrome::NaiveDateTime::format()
to format the timestamp. To get the output
Automatically run migrations #
Let’s say that the diesel.db
file is not present, since you have NOT done any of the
following:
- Run the
diesel_setup.fish
script file. - Run the
diesel setup
command. - Run the
diesel migration run
command.
Or a diesel.db
file is present, and you just added a new migration BUT you didn’t
run it yet.
In this case your application will not work, since the database file is not present, or it is out of date 🤦.
Thankfully, you can have the migrations run automatically when the application starts, if the database file is not present, it will be created. If the database file is old, it will be updated to the latest version 🎉.
In order to make this happen you have to do the following things.
-
Add the
diesel_migrations
crate to yourCargo.toml
file:# For automatic migrations. diesel_migrations = { version = "2.2.0", features = ["sqlite"] }
-
Add a procedural macro and this function:
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!(); pub fn try_run_migrations( connection: &mut SqliteConnection, ) -> std::result::Result< Vec<diesel::migration::MigrationVersion<'_>>, Box<dyn std::error::Error + Send + Sync>, > { connection.run_pending_migrations(MIGRATIONS) }
-
Finally, to your
main.rs
file, or whatever file and function you want to run before any database operations are run in your binary, call the function above. For example:let connection = &mut general_ops::create_connection(DATABASE_URL)?; if migration_ops::try_run_migrations(connection).is_err() { println!("Error running migrations"); miette::bail!("Error running migrations"); }
-
Optionally, you can add a
build.rs
file at the root of your project to get around current limitations in Rust’sproc-macro
API. There is currently no way to signal that a specific proc macro should be rerun if some external file changes or is added. Which means thatembed_migrations!
cannot regenerate the list of embedded migrations if ONLY the migrations are changed. To get around this you can add the following to yourbuild.rs
file:fn main() { println!("cargo:rerun-if-changed=migrations"); }
That’s it! Now your application will automatically run migrations when it starts 🚀.
VSCode and SQLite extension #
-
You can install
qwtel.sqlite-viewer
to view SQLite databases in VSCode. Alternatively you can use RustRover as db explorer is built in. -
You can install
adpyke.vscode-sql-formatter
to format SQL queries in VSCode.
History #
This tracking bug has lots of background information regarding the exploration of SQL, Rust, the best database to use, and the best driver & ORM combo.
Build with Naz video series on developerlife.com YouTube channel #
If you have comments and feedback on this content, or would like to request new content (articles & videos) on developerlife.com, please join our discord server.
You can watch a video series on building this crate with Naz on the developerlife.com YouTube channel.
- YT channel
- Playlists
👀 Watch Rust 🦀 live coding videos on our YouTube Channel.
📦 Install our useful Rust command line apps usingcargo install r3bl-cmdr
(they are from the r3bl-open-core project):
- 🐱
giti
: run interactive git commands with confidence in your terminal- 🦜
edi
: edit Markdown with style in your terminalgiti in action
edi in action