Перейти к содержимому

Базы данных в Rust: SQLite и Postgres с sqlx, Diesel и SeaORM

Рано или поздно программе нужно что-то запоминать: пользователей, заказы, рекорды, списки дел. Когда программа завершается, Vec или HashMap исчезают. База данных надёжно хранит данные на диске, позволяет нескольким программам пользоваться ими сообща и очень быстро отвечает на вопросы вроде «покажи все открытые задачи, сначала новые».

У Rust очень хорошие крейты для баз данных. На этой странице ты выберешь один из них, а потом напишешь настоящий код: маленький «репозиторий» на чистом Rust (он запускается прямо здесь, в Playground), затем SQLite с sqlx, Postgres, миграции и короткие экскурсии по Diesel и SeaORM. Крейтов для баз данных в Playground нет, поэтому эти примеры — для твоего компьютера, через cargo run. Каждый из них был собран и запущен, и показанный вывод — настоящий.

  • Слой данных веб-API. Возьми сервер списка дел из Проекта: веб-API и храни его данные в Postgres, а не в памяти.
  • Большие нагруженные сайты. crates.io — сайт, на котором лежат все крейты Rust, — хранит данные в Postgres через Diesel. Так же устроен Lemmy (на английском), платформа сообществ с открытым кодом.
  • Настольные программы и утилиты командной строки с локальным файлом SQLite: заметки, учёт расходов, кеш.
  • Фоновые обработчики и задачи с данными, которые читают строки, обрабатывают их и записывают результат обратно.

Где сложнее: у Rust нет единого «официального» стека вроде Entity Framework в .NET или ActiveRecord в Rails, так что выбирать придётся из нескольких хороших крейтов. Первая сборка крейта для баз данных занимает минуту-другую. А проверка SQL во время компиляции (отличная возможность) требует небольшой настройки.

Крейт Синхронный или async Проверяет запросы при компиляции? ORM? Когда выбирать
rusqlite синхронный нет нет, обычный SQL Маленькая утилита или настольная программа с одним файлом SQLite. Async не нужен.
sqlx async да, с макросами query! (по желанию) нет, обычный SQL Тебе нравится писать SQL, и нужен async. SQLite, Postgres, MySQL. Обычно первый выбор для веб-приложений.
Diesel синхронный (diesel-async добавляет async) да, весь построитель запросов проверяется типами ORM и построитель запросов Хочешь, чтобы компилятор проверял каждый запрос, и готов освоить его стиль запросов. Очень зрелый.
SeaORM async (есть sea-orm-sync) типы — да, SQL — нет (проверяется при выполнении) да, полноценная ORM Нужны сущности, связи и «сохрани этот объект», как в Entity Framework. Построен на sqlx.

Все четыре зрелые и широко используются (документация у всех на английском). Если сомневаешься, начни с sqlx: ты пишешь обычный SQL, и всё, что узнаешь, пригодится в любом языке. Пришёл из C#? На странице Rust для разработчиков на C# Entity Framework, Dapper и ADO.NET сопоставлены с этими крейтами.

Прежде чем браться за базу данных, посмотрим на шаблон, который используется почти в каждом приложении: репозиторий. Это одно место, которое знает, как сохранять и загружать данные. Остальная программа только вызывает методы вроде add и list_open и не задумывается, где лежат данные: в HashMap, в SQLite или в Postgres.

В Rust трейт описывает, что умеет репозиторий, а структура делает работу. Здесь данные хранятся в HashMap в памяти:

use std::collections::HashMap;
#[derive(Debug, Clone)]
struct Task {
id: u32,
title: String,
done: bool,
}
// Что приложению нужно от хранилища. Здесь ничего не сказано о том, КАК оно хранит.
trait TaskRepository {
fn add(&mut self, title: &str) -> u32;
fn mark_done(&mut self, id: u32) -> Result<(), String>;
fn list_open(&self) -> Vec<Task>;
}
struct InMemoryRepo {
tasks: HashMap<u32, Task>,
next_id: u32,
}
impl TaskRepository for InMemoryRepo {
fn add(&mut self, title: &str) -> u32 {
let id = self.next_id;
self.tasks.insert(id, Task { id, title: title.to_string(), done: false });
self.next_id += 1;
id
}
fn mark_done(&mut self, id: u32) -> Result<(), String> {
match self.tasks.get_mut(&id) {
Some(task) => {
task.done = true;
Ok(())
}
None => Err(format!("нет задачи с id {id}")),
}
}
fn list_open(&self) -> Vec<Task> {
let mut open: Vec<Task> = self.tasks.values().filter(|t| !t.done).cloned().collect();
open.sort_by_key(|t| t.id); // у HashMap нет порядка, поэтому сортируем
open
}
}
// Работает с ЛЮБЫМ репозиторием: сегодня в памяти, завтра в SQLite или Postgres.
fn print_open(repo: &impl TaskRepository) {
for task in repo.list_open() {
println!("[ ] {}: {}", task.id, task.title);
}
}
fn main() {
let mut repo = InMemoryRepo { tasks: HashMap::new(), next_id: 1 };
repo.add("Купить молоко");
repo.add("Изучить sqlx");
repo.add("Выгулять собаку");
repo.mark_done(1).unwrap();
print_open(&repo);
println!("{:?}", repo.mark_done(99));
}

Вывод:

[ ] 2: Изучить sqlx
[ ] 3: Выгулять собаку
Err("нет задачи с id 99")
▶ Попробуй сам

Посмотри, что здесь даст база данных. HashMap превращается в таблицу, каждая Task — в строку (row), а next_id — в столбец id с автоматической нумерацией. list_open становится запросом с WHERE done = FALSE ORDER BY id. И версия в памяти потом тоже пригодится: из неё получается быстрая подделка для тестов.

sqlx и SQLite: твоя первая настоящая база данных

Заголовок раздела «sqlx и SQLite: твоя первая настоящая база данных»

SQLite — это целая база данных в одном файле (или просто в памяти). Сервер ставить не нужно, поэтому она идеальна для учёбы, для тестов и для многих настоящих приложений. Создай проект и добавь sqlx и tokio:

Terminal window
cargo new todo-db
cd todo-db
cargo add sqlx --features runtime-tokio,sqlite
cargo add tokio --features full
[dependencies]
sqlx = { version = "0.9", features = ["runtime-tokio", "sqlite"] }
tokio = { version = "1", features = ["full"] }

Feature sqlite встраивает SQLite прямо в твою программу, так что больше ничего устанавливать не нужно. sqlx — асинхронный, поэтому ему нужен tokio. Теперь замени src/main.rs:

use sqlx::sqlite::SqlitePool;
#[derive(Debug, sqlx::FromRow)]
struct Task {
id: i64,
title: String,
done: bool,
}
#[tokio::main]
async fn main() -> Result<(), sqlx::Error> {
// База данных в памяти: идеально для учёбы, исчезает, когда программа завершается.
let pool = SqlitePool::connect("sqlite::memory:").await?;
sqlx::query(
"CREATE TABLE tasks (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
done BOOLEAN NOT NULL DEFAULT FALSE
)",
)
.execute(&pool)
.await?;
for title in ["Buy milk", "Learn sqlx", "Walk the dog"] {
let result = sqlx::query("INSERT INTO tasks (title) VALUES (?)")
.bind(title)
.execute(&pool)
.await?;
println!("Inserted task {}", result.last_insert_rowid());
}
sqlx::query("UPDATE tasks SET done = TRUE WHERE id = ?")
.bind(1_i64)
.execute(&pool)
.await?;
let tasks: Vec<Task> = sqlx::query_as("SELECT id, title, done FROM tasks ORDER BY id")
.fetch_all(&pool)
.await?;
for task in &tasks {
let mark = if task.done { "x" } else { " " };
println!("[{mark}] {}: {}", task.id, task.title);
}
let open: Option<Task> = sqlx::query_as("SELECT id, title, done FROM tasks WHERE id = ?")
.bind(42_i64)
.fetch_optional(&pool)
.await?;
println!("Task 42: {open:?}");
Ok(())
}

Запусти через cargo run:

Inserted task 1
Inserted task 2
Inserted task 3
[x] 1: Buy milk
[ ] 2: Learn sqlx
[ ] 3: Walk the dog
Task 42: None

Код и его настоящий вывод мы оставили как есть, поэтому строки здесь на английском. Разберём по шагам:

  • sqlx::query("...") хранит SQL. Каждый ? — это заполнитель (placeholder), а .bind(value) подставляет значения по порядку.
  • .execute(&pool) выполняет команду, которая не возвращает строк (CREATE, INSERT, UPDATE, DELETE). Результат сообщает rows_affected(), а для SQLite ещё и last_insert_rowid().
  • #[derive(sqlx::FromRow)] позволяет sqlx превращать каждую строку в Task. Имена столбцов должны совпадать с именами полей.
  • query_as + fetch_all дают Vec<Task>. Используй fetch_one, когда строка должна быть ровно одна, и fetch_optional, когда её может не быть. Заметь: отсутствующая строка превратилась в None, то есть в Option, а не в падение.
  • Каждый вызов возвращает Result, поэтому ? передаёт ошибки базы данных наверх, в main.

Чтобы данные сохранялись после завершения программы, используй файл: SqlitePool::connect("sqlite:todos.db?mode=rwc"). Часть mode=rwc значит «читать, писать и создать файл, если его нет».

Запросы с проверкой при компиляции. У sqlx есть и макросы вроде sqlx::query_as!(Task, "SELECT id, title, done FROM tasks WHERE done = ?", done). Во время сборки макрос подключается к твоей базе для разработки (из DATABASE_URL) и проверяет SQL. Опечатка вроде titel останавливает сборку с ошибкой no such column: titel («нет такого столбца»), прежде чем её увидит хоть один пользователь. Для этого во время сборки нужна база с твоими таблицами, так что начни с обычных функций query, а макросы добавь, когда появятся миграции (о них ниже).

Postgres (PostgreSQL) — самый популярный выбор для веб-приложений. Он работает как сервер, так что нужен запущенный экземпляр: установи его с postgresql.org, возьми облачную базу или запусти через Docker:

Terminal window
docker run --name todo-pg -e POSTGRES_PASSWORD=secret -p 5432:5432 -d postgres

Код почти не меняется. Включи feature postgres (и feature для TLS, например tls-rustls, если база облачная — там обычно требуется шифрование):

Terminal window
cargo add sqlx --features runtime-tokio,postgres,tls-rustls

Затем подключись по строке подключения (connection string) — это URL, в котором сказано, где база и как в неё войти:

postgres://USER:PASSWORD@HOST:PORT/DATABASE
postgres://postgres:secret@localhost:5432/postgres

Никогда не пиши пароль в коде. Положи URL в переменную окружения DATABASE_URL и читай его при запуске (см. Переменные окружения):

use sqlx::postgres::PgPoolOptions;
use std::time::Duration;
let url = std::env::var("DATABASE_URL").expect("set DATABASE_URL first");
let pool = PgPoolOptions::new()
.max_connections(5)
.acquire_timeout(Duration::from_secs(3))
.connect(&url)
.await?;
// В Postgres заполнители пронумерованы: $1, $2, ...
let tasks: Vec<Task> = sqlx::query_as("SELECT id, title, done FROM tasks WHERE done = $1")
.bind(false)
.fetch_all(&pool)
.await?;

Два отличия от SQLite, которые стоит запомнить: в Postgres заполнители — это $1, $2 вместо ?, а id с автонумерацией обычно объявляют как BIGINT GENERATED ALWAYS AS IDENTITY (или BIGSERIAL), и он помещается в i64. Если в пароле есть символы вроде @ или /, закодируй их в URL процентами (@ превращается в %40). Инструменты вроде sqlx-cli читают DATABASE_URL и из файла .env в папке проекта. Добавь .env в .gitignore, чтобы он никогда не попал на GitHub.

Таблицы со временем меняются: тут новый столбец, там новая таблица. Миграции — это пронумерованные SQL-файлы, каждый из которых описывает одно изменение. Каждая база (твоя, твоего коллеги, боевая) выполняет одни и те же файлы в одном и том же порядке и помнит, какие уже выполнила. Один раз установи инструмент командной строки:

Terminal window
# все базы данных
cargo install sqlx-cli
# или только то, что нужно, — так сборка быстрее
cargo install sqlx-cli --no-default-features --features sqlite,rustls

Затем в папке проекта:

Terminal window
echo "DATABASE_URL=sqlite:todos.db" > .env
sqlx database create
sqlx migrate add create_tasks
Creating migrations/20261018035858_create_tasks.sql

(У тебя число будет другим: это дата и время.) Запиши изменение в этот файл:

CREATE TABLE tasks (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
done BOOLEAN NOT NULL DEFAULT FALSE
);

И примени его:

Terminal window
sqlx migrate run
Applied 20261018035858/migrate create tasks (1.449995ms)

Если запустить ещё раз, ничего не произойдёт: эта миграция уже применена. sqlx migrate info показывает, какие миграции применены, а какие ждут. Никогда не редактируй миграцию, которая уже где-то выполнилась, — добавь новую.

Можно также поручить программе применять миграции при запуске. Макрос migrate! встраивает файлы из ./migrations прямо в бинарник:

let pool = SqlitePool::connect("sqlite::memory:").await?;
sqlx::migrate!().run(&pool).await?; // выполняет только те миграции, которых эта база ещё не видела

Diesel идёт другим путём: вместо строк SQL ты собираешь запросы методами Rust, а компилятор сверяет их с описанием твоих таблиц. Например, done — столбец типа bool, поэтому tasks::done.eq(true) компилируется, а tasks::done.eq("yes") — ошибка компиляции. Diesel синхронный, и для утилит и многих серверов это нормально.

[dependencies]
diesel = { version = "2.3", features = ["sqlite"] }
# Встраивает SQLite в программу. Нужно, только если SQLite у тебя не установлен:
libsqlite3-sys = { version = "0.38", features = ["bundled"] }
use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;
// Обычно `diesel migration run` сам генерирует это в src/schema.rs.
diesel::table! {
tasks (id) {
id -> Integer,
title -> Text,
done -> Bool,
}
}
#[derive(Queryable, Selectable, Debug)]
#[diesel(table_name = tasks)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
struct Task {
id: i32,
title: String,
done: bool,
}
#[derive(Insertable)]
#[diesel(table_name = tasks)]
struct NewTask<'a> {
title: &'a str,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut conn = SqliteConnection::establish(":memory:")?;
// Обычно это делает миграция.
diesel::sql_query(
"CREATE TABLE tasks (
id INTEGER PRIMARY KEY NOT NULL,
title TEXT NOT NULL,
done BOOLEAN NOT NULL DEFAULT FALSE
)",
)
.execute(&mut conn)?;
let new_tasks = [NewTask { title: "Buy milk" }, NewTask { title: "Learn Diesel" }];
diesel::insert_into(tasks::table)
.values(&new_tasks[..])
.execute(&mut conn)?;
diesel::update(tasks::table.find(1))
.set(tasks::done.eq(true))
.execute(&mut conn)?;
let all: Vec<Task> = tasks::table
.order(tasks::id)
.select(Task::as_select())
.load(&mut conn)?;
for task in &all {
println!("{} {} done={}", task.id, task.title, task.done);
}
let open: i64 = tasks::table
.filter(tasks::done.eq(false))
.count()
.get_result(&mut conn)?;
println!("{open} task(s) still open");
Ok(())
}
1 Buy milk done=true
2 Learn Diesel done=false
1 task(s) still open

В настоящем проекте ты ставишь diesel_cli (cargo install diesel_cli --no-default-features --features sqlite), а потом выполняешь diesel setup, diesel migration generate create_tasks и diesel migration run. CLI сам пишет src/schema.rs с кодом table!, так что он всегда совпадает с базой. Всё это по шагам разобрано в руководстве Getting Started (на английском).

SeaORM — асинхронная ORM: каждую таблицу ты описываешь как сущность (entity) — структуру с атрибутами — и работаешь с объектами, а не с SQL. По ощущениям это близко к Entity Framework. Версия 2.0 умеет даже создавать таблицы по твоим сущностям.

[dependencies]
sea-orm = { version = "2", features = ["sqlx-sqlite", "runtime-tokio", "entity-registry", "schema-sync"] }
tokio = { version = "1", features = ["full"] }
mod task {
use sea_orm::entity::prelude::*;
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "task")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub title: String,
pub done: bool,
}
impl ActiveModelBehavior for ActiveModel {}
}
use sea_orm::{entity::*, query::*, Database, DbErr};
#[tokio::main]
async fn main() -> Result<(), DbErr> {
let db = &Database::connect("sqlite::memory:").await?;
// Создаём таблицы для всех сущностей этого крейта. "seaorm_demo" — это
// имя пакета из Cargo.toml, где - заменён на _.
db.get_schema_registry("seaorm_demo::*").sync(db).await?;
for title in ["Buy milk", "Learn SeaORM"] {
task::ActiveModel {
title: Set(title.to_owned()),
done: Set(false),
..Default::default()
}
.insert(db)
.await?;
}
let mut milk = task::Entity::find_by_id(1).one(db).await?.unwrap().into_active_model();
milk.done = Set(true);
milk.update(db).await?;
let open = task::Entity::find()
.filter(task::Column::Done.eq(false))
.all(db)
.await?;
for t in &open {
println!("open: {} {}", t.id, t.title);
}
Ok(())
}
open: 2 Learn SeaORM

Model — это строка, которую ты читаешь. ActiveModel — строка, которую ты меняешь: Set(...) помечает поля для сохранения, а ..Default::default() оставляет id на заполнение базе данных. Для больших проектов sea-orm-cli генерирует сущности по существующей базе и управляет миграциями. См. документацию SeaORM (на английском).

Это самое важное правило на странице. Никогда не собирай SQL, вклеивая пользовательский ввод в строку через format!. Вот почему — на чистом Rust (база данных не нужна):

// Что получит база данных, если собрать запрос двумя разными способами.
fn pasted_query(name: &str) -> String {
format!("SELECT * FROM users WHERE name = '{name}'")
}
fn bound_query(name: &str) -> (&'static str, Vec<String>) {
("SELECT * FROM users WHERE name = ?", vec![name.to_string()])
}
fn main() {
for input in ["ana", "x' OR '1'='1"] {
println!("ввод: {input}");
println!(" склейка: {}", pasted_query(input));
let (sql, params) = bound_query(input);
println!(" bind: {sql} параметры: {params:?}");
}
}

Вывод:

ввод: ana
склейка: SELECT * FROM users WHERE name = 'ana'
bind: SELECT * FROM users WHERE name = ? параметры: ["ana"]
ввод: x' OR '1'='1
склейка: SELECT * FROM users WHERE name = 'x' OR '1'='1'
bind: SELECT * FROM users WHERE name = ? параметры: ["x' OR '1'='1"]
▶ Попробуй сам

При склейке кавычка во вводе досрочно закрывает строку, и OR '1'='1' становится частью SQL. Это условие всегда истинно, поэтому запрос возвращает всех пользователей. Тем же трюком злоумышленник может прочитать чужие данные или удалить таблицы. Это называется SQL-инъекцией (SQL injection), и это одна из самых частых уязвимостей в вебе.

С параметром (bind parameter) текст SQL никогда не меняется. Ввод передаётся отдельно, и база данных считает его только значением. Вот обе версии на sqlx, запущенные на настоящей таблице SQLite с пользователями ana и root:

let user_input = "nobody' OR '1'='1"; // представь, что это пришло из формы входа
// НЕЛЬЗЯ: вклеивать ввод в текст SQL.
let sql = format!("SELECT name FROM users WHERE name = '{user_input}'");
let rows: Vec<(String,)> = sqlx::query_as(AssertSqlSafe(sql)).fetch_all(&pool).await?;
println!("format!: {rows:?}");
// НУЖНО: неизменный текст SQL с заполнителем ?, а ввод передаётся отдельно.
let rows: Vec<(String,)> = sqlx::query_as("SELECT name FROM users WHERE name = ?")
.bind(user_input)
.fetch_all(&pool)
.await?;
println!("bind: {rows:?}");
format!: [("ana",), ("root",)]
bind: []

Склеенный запрос выдал обоих пользователей. Запрос с параметром правильно не нашёл никого с таким странным именем. И обрати внимание на обёртку AssertSqlSafe(...) в плохой версии: sqlx 0.9 по умолчанию тебя защищает. sqlx::query и query_as принимают только неизменную &'static str, поэтому строка из format! без этой обёртки не скомпилируется:

error[E0277]: dynamic SQL strings should be audited for possible injections
= help: the trait `SqlSafeStr` is not implemented for `&std::string::String`
= note: prefer literal SQL strings with bind parameters or `QueryBuilder` to add dynamic data to a query.

Компилятор говорит, что динамические строки SQL нужно проверять на возможные инъекции, и советует использовать литералы с параметрами или QueryBuilder. Так когда же вообще нужен AssertSqlSafe? Только когда SQL действительно приходится собирать во время работы программы — например, столбец для сортировки, который выбирает пользователь. Сначала сверь значение со своим фиксированным списком допустимых имён, а потом оберни строку, чтобы сказать sqlx, что ты её проверил. Для значений всегда используй .bind(). Diesel и SeaORM подставляют значения как параметры сами — если пользоваться их построителями запросов, а не сырыми строками SQL.

Открывать соединение с базой медленно: нужен путь по сети, вход и кое-какая настройка. Пул (pool) один раз открывает несколько соединений и выдаёт их во временное пользование. Твой код берёт соединение на один запрос и сразу возвращает его. Вот почему все примеры выше передавали &pool, а не одно соединение.

use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
let options = SqliteConnectOptions::new()
.filename("visits.db")
.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(4) // никогда не больше 4 одновременно
.connect_with(options)
.await?;
sqlx::query("CREATE TABLE IF NOT EXISTS visits (page TEXT NOT NULL)")
.execute(&pool)
.await?;
// 20 задач делят один пул. Клонировать пул дёшево: это просто дескриптор.
let mut handles = Vec::new();
for n in 0..20 {
let pool = pool.clone();
handles.push(tokio::spawn(async move {
sqlx::query("INSERT INTO visits (page) VALUES (?)")
.bind(format!("/page/{}", n % 3))
.execute(&pool)
.await
}));
}
for handle in handles {
handle.await.expect("task panicked")?; // ждём все 20 вставок
}

20 задач работают одновременно, но с базой в каждый момент общаются не больше 4. Остальные чуть-чуть ждут свободного соединения. Если потом посчитать строки (в свежем visits.db), получится:

/page/0: 7 visits
/page/1: 7 visits
/page/2: 6 visits

Несколько практических правил:

  • Создавай один пул при запуске программы и делись им. В приложении на axum положи его в состояние приложения (см. проект веб-API).
  • Не создавай новый пул на каждый запрос. Так теряется весь смысл.
  • max_connections должен быть заметно меньше того, что разрешает база. Сервер Postgres по умолчанию допускает 100 соединений, а у каждого экземпляра твоего приложения свой пул.
  • Задай acquire_timeout, чтобы, когда все соединения заняты, запрос завершался понятной ошибкой, а не ждал вечно.

Возьми идею репозитория в памяти и добавь метод search: он возвращает задачи, в заголовке которых есть слово, без учёта регистра, отсортированные по id. Проверь его на слове «молоко». Подсказка: title.to_lowercase().contains(&word.to_lowercase()), а хранилищем здесь вполне может быть Vec<Task>.

Показать решение
#[derive(Debug, Clone)]
struct Task {
id: u32,
title: String,
}
trait TaskRepository {
fn add(&mut self, title: &str) -> u32;
fn search(&self, word: &str) -> Vec<Task>;
}
struct VecRepo {
tasks: Vec<Task>,
}
impl TaskRepository for VecRepo {
fn add(&mut self, title: &str) -> u32 {
let id = self.tasks.len() as u32 + 1;
self.tasks.push(Task { id, title: title.to_string() });
id
}
fn search(&self, word: &str) -> Vec<Task> {
let word = word.to_lowercase();
let mut found: Vec<Task> = self
.tasks
.iter()
.filter(|t| t.title.to_lowercase().contains(&word))
.cloned()
.collect();
found.sort_by_key(|t| t.id);
found
}
}
fn main() {
let mut repo = VecRepo { tasks: Vec::new() };
repo.add("Купить молоко");
repo.add("Выгулять собаку");
repo.add("Молоко для блинов");
for task in repo.search("молоко") {
println!("{}: {}", task.id, task.title);
}
println!("результатов по запросу \"кот\": {}", repo.search("кот").len());
}

Вывод:

1: Купить молоко
3: Молоко для блинов
результатов по запросу "кот": 0
▶ Попробуй сам

to_lowercase() понимает Юникод, поэтому «Молоко» с большой буквы тоже находится. Хранилище сменилось с HashMap на Vec, но коду, который вызывает трейт, знать об этом не нужно. В SQL такой же поиск — это WHERE title LIKE ? с .bind(format!("%{word}%")). Обрати внимание: знаки % идут в передаваемое значение, а не в текст SQL.

2. Настоящий репозиторий на sqlx (на твоём компьютере)

Заголовок раздела «2. Настоящий репозиторий на sqlx (на твоём компьютере)»

Напиши структуру SqliteTaskRepo, которая хранит SqlitePool и имеет три async-метода: add(title) возвращает id новой задачи, mark_done(id) возвращает true, если строка изменилась, а list_open() возвращает открытые задачи. Создай таблицу в main, добавь три задачи и отметь выполненными задачи 1 и 99. Подсказка: rows_affected() у результата execute сообщает, сколько строк изменил UPDATE.

Показать решение
use sqlx::sqlite::SqlitePool;
#[derive(Debug, sqlx::FromRow)]
struct Task {
id: i64,
title: String,
done: bool,
}
struct SqliteTaskRepo {
pool: SqlitePool,
}
impl SqliteTaskRepo {
async fn add(&self, title: &str) -> Result<i64, sqlx::Error> {
let result = sqlx::query("INSERT INTO tasks (title) VALUES (?)")
.bind(title)
.execute(&self.pool)
.await?;
Ok(result.last_insert_rowid())
}
async fn mark_done(&self, id: i64) -> Result<bool, sqlx::Error> {
let result = sqlx::query("UPDATE tasks SET done = TRUE WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() == 1)
}
async fn list_open(&self) -> Result<Vec<Task>, sqlx::Error> {
sqlx::query_as("SELECT id, title, done FROM tasks WHERE done = FALSE ORDER BY id")
.fetch_all(&self.pool)
.await
}
}
#[tokio::main]
async fn main() -> Result<(), sqlx::Error> {
let pool = SqlitePool::connect("sqlite::memory:").await?;
sqlx::query("CREATE TABLE tasks (id INTEGER PRIMARY KEY, title TEXT NOT NULL, done BOOLEAN NOT NULL DEFAULT FALSE)")
.execute(&pool)
.await?;
let repo = SqliteTaskRepo { pool };
repo.add("Buy milk").await?;
repo.add("Learn sqlx").await?;
repo.add("Walk the dog").await?;
println!("mark 1 done: {}", repo.mark_done(1).await?);
println!("mark 99 done: {}", repo.mark_done(99).await?);
for task in repo.list_open().await? {
println!("[{}] {}: {}", if task.done { "x" } else { " " }, task.id, task.title);
}
Ok(())
}
mark 1 done: true
mark 99 done: false
[ ] 2: Learn sqlx
[ ] 3: Walk the dog

Форма та же, что и у версии в памяти, только методы async и возвращают Result, потому что настоящая база может дать сбой (переполненный диск, пропавшая сеть). Следующий шаг: замени Vec в своём проекте веб-API на этот репозиторий.

  • rusqlite — для простого синхронного SQLite, sqlx — для асинхронного обычного SQL (обычно первый выбор), Diesel — для построителя запросов с проверкой при компиляции, SeaORM — для асинхронной ORM в духе Entity Framework.
  • Репозиторий прячет место хранения данных за несколькими методами. Начни с HashMap, а базу данных подставь потом.
  • В sqlx: query + .bind() + .execute() для изменений, query_as + fetch_all / fetch_one / fetch_optional для чтения строк в структуру с #[derive(FromRow)].
  • Храни строку подключения в DATABASE_URL, а не в коде. В SQLite заполнители ?, в Postgres — $1, $2.
  • Меняй таблицы миграциями (sqlx migrate add / run), а не вручную.
  • Всегда передавай пользовательский ввод параметрами. Никогда не вклеивай его в SQL через format!. Создавай один пул при запуске и делись им.

Проверь себя

1. Ты хочешь писать обычный SQL, использовать async и иметь возможность проверять запросы при компиляции. Какой крейт подходит лучше всего?

2. Какой метод sqlx использовать, когда запрос может вернуть ноль или одну строку?

3. Чем опасен format!("... WHERE name = '{name}'")?

4. Что делает пул соединений?

Результат: 0 / 4

  • sqlx на docs.rs (на английском): справочник по API — query, query_as и макросы query!, пулы и все поддерживаемые базы данных.
  • sqlx-cli на docs.rs (на английском): как установить CLI, создавать базы, выполнять и откатывать миграции и использовать офлайн-режим для макросов query!.
  • Getting Started with Diesel (на английском): официальное пошаговое руководство — от diesel setup до вставки, изменения и удаления строк.
  • Документация SeaORM (на английском): сущности, связи, миграции и генератор кода sea-orm-cli.
  • OWASP SQL Injection Prevention Cheat Sheet (на английском): как работают SQL-инъекции и как их останавливают параметризованные запросы — для любого языка.