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 .envestablished 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 Okor 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 Postor 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 Postor Noneif 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.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *