Working with Databases in Rust Using Diesel
Installation/configuration
Create a new project in Rust:
cargo new my_diesel_project
cd my_diesel_project
Open the file Cargo.toml
project and add dependencies for Diesel and the necessary libraries:
[dependencies]
diesel = { version = "2.0.0", features = ["postgres"] }
dotenv = "0.15"
The file tells Cargo to install Diesel ORM with PostgreSQL support (we chose Postgres for the example), as well as the library dotenv
. Let's also assume that Postqre is already installed.
To work with Diesel you also need the Diesel CLI:
sudo apt-get update
sudo apt-get install postgresql postgresql-contrib
This will install Diesel CLI with PostgreSQL support. Next, initialize Diesel in the project:
brew install postgresql
brew services start postgresql
The command will create a file diesel.toml
and folder migrations
in project. Now let's create a file .env
in the root of your project and add the database connection string to it:
sudo -u postgres createuser myuser -s
sudo -u postgres createdb mydatabase
Now let's create a file src/schema.rs
to store the database schema:
touch src/schema.rs
IN main.rs
connect the Diesel and dotenv libraries:
#[macro_use]
extern crate diesel;
extern crate dotenv;
pub mod schema;
pub mod models;
use diesel::prelude::*;
use dotenv::dotenv;
use std::env;
pub fn establish_connection() -> PgConnection {
dotenv().ok();
let database_url = env::var("DATABASE_URL")
.expect("DATABASE_URL must be set");
PgConnection::establish(&database_url)
.expect(&format!("Error connecting to {}", database_url))
}
This is how we loaded environment variables from the file .env
established a connection to the database and return it.
CRUD operations
Creation
Create a file src/models.rs
and we define the structure of the data model:
#[derive(Queryable)]
pub struct Post {
pub id: i32,
pub title: String,
pub body: String,
pub published: bool,
}
#[derive(Insertable)]
#[table_name="posts"]
pub struct NewPost<'a> {
pub title: &'a str,
pub body: &'a str,
}
IN src/schema.rs
add table schema:
table! {
posts (id) {
id -> Int4,
title -> Varchar,
body -> Text,
published -> Bool,
}
}
To insert new data, src/lib.rs
Let's create a function to insert new records:
use diesel::prelude::*;
use diesel::pg::PgConnection;
use crate::schema::posts;
use crate::models::{Post, NewPost};
pub fn create_post<'a>(conn: &PgConnection, title: &'a str, body: &'a str) -> Post {
let new_post = NewPost {
title,
body,
};
diesel::insert_into(posts::table)
.values(&new_post)
.get_result(conn)
.expect("Error saving new post")
}
Reading
To read the data, we will create a function in src/lib.rs
:
pub fn get_posts(conn: &PgConnection) -> Vec<Post> {
use crate::schema::posts::dsl::*;
posts
.load::<Post>(conn)
.expect("Error loading posts")
}
For example, filtering published posts and sorting by ID looks like this::
pub fn get_published_posts(conn: &PgConnection) -> Vec<Post> {
use crate::schema::posts::dsl::*;
posts
.filter(published.eq(true))
.order(id.desc())
.load::<Post>(conn)
.expect("Error loading published posts")
}
Update and Delete
To update a record, let's create a function:
pub fn update_post_title(conn: &PgConnection, post_id: i32, new_title: &str) -> Post {
use crate::schema::posts::dsl::{posts, id, title};
diesel::update(posts.find(post_id))
.set(title.eq(new_title))
.get_result::<Post>(conn)
.expect("Error updating post title")
}
To delete a record there is this function:
pub fn delete_post(conn: &PgConnection, post_id: i32) -> usize {
use crate::schema::posts::dsl::posts;
diesel::delete(posts.find(post_id))
.execute(conn)
.expect("Error deleting post")
}
Migrations
Migrations in Diesel are very convenient and easy to use.
To create a new migration, there is a migration command:
diesel migration generate create_posts
The command will create a new migration named create_posts
and will create two SQL files: up.sql
And down.sql
in folder migrations
.
In file up.sql
define SQL commands to create a table. For example, to create a table posts
:
CREATE TABLE posts (
id SERIAL PRIMARY KEY,
title VARCHAR NOT NULL,
body TEXT NOT NULL,
published BOOLEAN NOT NULL DEFAULT 'f'
);
In file down.sql
let's define SQL commands to roll back changes, for example, deleting a table posts
:
DROP TABLE posts;
To apply migrations use the command:
diesel migration run
The command will perform any migrations that have not yet been applied and update the database structure.
If you want to roll back the last migration, there is this command:
diesel migration revert
This command will execute the commands from the file down.sql
last migration and return the database to its previous state.
When migrations are applied, Diesel automatically updates the file src/schema.rs
, which contains a description of the database schema in the form of Rust code. This file is generated by the Diesel CLI and should not be modified manually.
Error processing
In Rust type Result
used to handle operations that may fail. This type either contains the value of a successful operation Ok
or a description of the error Err
). In Diesel ORM this mechanism is used quite often.
Consider a function that retrieves a post from the database by its ID:
use diesel::prelude::*;
use crate::models::Post;
use crate::schema::posts::dsl::*;
pub fn find_post_by_id(conn: &PgConnection, post_id: i32) -> Result<Post, diesel::result::Error> {
posts.find(post_id).first(conn)
}
The function returns Result<Post, diesel::result::Error>
i.e. it either successfully returns the object Post
or a type error diesel::result::Error
.
When calling this function, you can use the expression match
to handle possible errors:
fn main() {
let connection = establish_connection();
match find_post_by_id(&connection, 1) {
Ok(post) => println!("Found post: {}", post.title),
Err(err) => println!("Error finding post: {:?}", err),
}
}
This way you can handle errors at the place where the function is called.
Type Option
in Rust is used to handle cases where a value may be missing. In a database context, this can be useful, for example, for operations that may not return any rows:
pub fn find_post_by_title(conn: &PgConnection, post_title: &str) -> Option<Post> {
posts.filter(title.eq(post_title)).first(conn).ok()
}
The function returns Option<Post>
which means that it either returns an object Post
or None
if a post with the specified title is not found.
In addition to the functions described above, Diesel ORM automatically uses parameterized queries. For example, let's write a function that executes a parameterized query to search for posts by title:
pub fn search_posts_by_title(conn: &PgConnection, search: &str) -> Vec<Post> {
posts.filter(title.like(format!("%{}%", search)))
.load::<Post>(conn)
.expect("Error loading posts")
}
Method filter
automatically uses parameters instead of directly inserting values into the SQL query, which prevents the possibility of SQL injections.
When working with user input, it is always important to validate and clean up the data. For example:
pub fn create_post(conn: &PgConnection, new_title: &str, new_body: &str) -> Post {
let new_post = NewPost {
title: new_title.trim(),
body: new_body.trim(),
};
diesel::insert_into(posts::table)
.values(&new_post)
.get_result(conn)
.expect("Error saving new post")
}
With method trim()
You can remove extra spaces from user input.
More details from Diesel can be found here.
You can learn about other programming languages and tools in practical online courses from my friends from OTUS. More details in the catalog.