Работа с базами данных в Rust с помощью Diesel

c8c033c4293f545673f4124f7b8f5561.png

Привет, Хабр!

Сегодня мы поговорим о Diesel ORM — инструменте, который превращает работу с базами данных в Rust в настоящее удовольствие. Diesel ORM — это расширяемый и безопасный объектно-реляционный маппер и конструктор запросов для Rust. Он имеет высокоуровневый API для взаимодействия с различными СУБД: PostgreSQL, MySQL и SQLite.

Начнем с установки и настройки!

Установка / настройка

Создаем новый проект на Rust:

cargo new my_diesel_project
cd my_diesel_project

Открываем файлик Cargo.toml проекта и добавляем зависимости для Diesel и необходимых библиотек:

[dependencies]
diesel = { version = "2.0.0", features = ["postgres"] }
dotenv = "0.15"

Файл указывает Cargo установить Diesel ORM с поддержкой PostgreSQL (выбрали постгрес для примера), а также библиотеку dotenv. Также допустим то, что Postqre уже установлен.

Для работы с Diesel также нужен Diesel CLI:

sudo apt-get update
sudo apt-get install postgresql postgresql-contrib

Это установит Diesel CLI с поддержкой PostgreSQL. Далее, инициализируем Diesel в проекте:

brew install postgresql
brew services start postgresql

Команда создаст файл diesel.toml и папку migrations в проекте. Теперь создаем файл .env в корне вашего проекта и добавляем в него строку подключения к БД:

sudo -u postgres createuser myuser -s
sudo -u postgres createdb mydatabase

Теперь создаем файлик src/schema.rs для хранения схемы БД:

touch src/schema.rs

В main.rs подключаем библиотеки Diesel и dotenv:

#[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))
}

Так мы загрузили переменные окружения из файла .env, устанавили соединение с базой данных и возвращаем его.

CRUD операции

Создание

Создаем файл src/models.rs и определяем структуру модели данных:

#[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,
}

В src/schema.rs добавляем схему таблицы:

table! {
    posts (id) {
        id -> Int4,
        title -> Varchar,
        body -> Text,
        published -> Bool,
    }
}

Для вставки новых данных, в src/lib.rs создадим функцию для вставки новых записей:

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")
}

Чтение

Для чтения данных создадим функцию в src/lib.rs:

pub fn get_posts(conn: &PgConnection) -> Vec {
    use crate::schema::posts::dsl::*;

    posts
        .load::(conn)
        .expect("Error loading posts")
}

Например, фильтрация опубликованных постов и сортировка по ID выглядит так:

pub fn get_published_posts(conn: &PgConnection) -> Vec {
    use crate::schema::posts::dsl::*;

    posts
        .filter(published.eq(true))
        .order(id.desc())
        .load::(conn)
        .expect("Error loading published posts")
}

Обновление и удаление

Для обновления записи создадим функцию:

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::(conn)
        .expect("Error updating post title")
}

Для удаления записи есть такая функция:

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")
}

Миграции

Миграции в Diesel очень удобны и легки в использовании.

Чтобы создать новую миграцию есть команда migration:

diesel migration generate create_posts

Команда создаст новую миграцию с именем create_posts и создаст две SQL файла: up.sql и down.sql в папке migrations.

В файле up.sql определим SQL команды для создания таблицы. Например, для создания таблицы posts:

CREATE TABLE posts (
    id SERIAL PRIMARY KEY,
    title VARCHAR NOT NULL,
    body TEXT NOT NULL,
    published BOOLEAN NOT NULL DEFAULT 'f'
);

В файле down.sql определим SQL команды для отката изменений, например, удаления таблицы posts:

DROP TABLE posts;

Для применения миграций используем команду:

diesel migration run

Команда выполнит все миграции, которые еще не были применены, и обновит структуру базы данных.

Если хочется откатить последнюю миграцию есть такая команда:

diesel migration revert

Эта команда выполнит команды из файла down.sql последней миграции и вернет базу данных в предыдущее состояние.

Когда миграции применены, Diesel автоматом обновляет файл src/schema.rs, который содержит описание схемы базы данных в виде Rust кода. Этот файл генерируется Diesel CLI и не должен изменяться вручную.

Обработка ошибок

В Rust тип Result используется для обработки операций, которые могут завершиться ошибкой. Этот тип либо содержит значение успешной операции Ok, либо описание ошибки Err). В Diesel ORM этот механизм используется довольно часто.

Рассмотрим функцию, которая извлекает пост из базы данных по его 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 {
    posts.find(post_id).first(conn)
}

Функция возвращает Result, т.е она либо успешно возвращает объект Post, либо ошибку типа diesel::result::Error.

При вызове этой функции, можно использовать выражение match для обработки возможных ошибок:

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),
    }
}

Так можно обрабатывать ошибки на месте вызова функции.

Тип Option в Rust используется для обработки случаев, когда значение может быть отсутствующим. В контексте БД, это, например, может быть полезно для операций, которые могут не вернуть ни одной строки:

pub fn find_post_by_title(conn: &PgConnection, post_title: &str) -> Option {
    posts.filter(title.eq(post_title)).first(conn).ok()
}

Функция возвращает Option, что означает, что она либо возвращает объект Post, либо None, если пост с указанным заголовком не найден.

Diesel ORM помимо вышеописанных функций автоматом использует параметризованные запросы. Например, напишем функцию, которая выполняет параметризованный запрос для поиска постов по заголовку:

pub fn search_posts_by_title(conn: &PgConnection, search: &str) -> Vec {
    posts.filter(title.like(format!("%{}%", search)))
        .load::(conn)
        .expect("Error loading posts")
}

Метод filter автоматически использует параметры вместо прямой вставки значений в SQL-запрос, что предотвращает возможность SQL-инъекций.

Когда работаешь с пользовательским вводом, всегда важно проверять и очищать данные. Например:

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")
}

С методом trim() можно удалять лишние пробелы из пользовательского ввода.

Подробнее с Diesel можно ознакомиться здесь.

А с другими языками программирования инструментами можно ознакомиться в рамках практических онлайн-курсов от моих друзей из OTUS. Подробнее в каталоге.

© Habrahabr.ru