Базы данных в 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 сопоставлены с этими крейтами.
Сначала форма: репозиторий на чистом Rust
Заголовок раздела «Сначала форма: репозиторий на чистом Rust»Прежде чем браться за базу данных, посмотрим на шаблон, который используется почти в каждом приложении: репозиторий. Это одно место, которое знает, как сохранять и загружать данные. Остальная программа только вызывает методы вроде 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:
cargo new todo-dbcd todo-dbcargo add sqlx --features runtime-tokio,sqlitecargo 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 1Inserted task 2Inserted task 3[x] 1: Buy milk[ ] 2: Learn sqlx[ ] 3: Walk the dogTask 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
Заголовок раздела «Подключаемся к Postgres»Postgres (PostgreSQL) — самый популярный выбор для веб-приложений. Он работает как сервер, так что нужен запущенный экземпляр: установи его с postgresql.org, возьми облачную базу или запусти через Docker:
docker run --name todo-pg -e POSTGRES_PASSWORD=secret -p 5432:5432 -d postgresКод почти не меняется. Включи feature postgres (и feature для TLS, например tls-rustls, если база облачная — там обычно требуется шифрование):
cargo add sqlx --features runtime-tokio,postgres,tls-rustlsЗатем подключись по строке подключения (connection string) — это URL, в котором сказано, где база и как в неё войти:
postgres://USER:PASSWORD@HOST:PORT/DATABASEpostgres://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.
Миграции с sqlx-cli
Заголовок раздела «Миграции с sqlx-cli»Таблицы со временем меняются: тут новый столбец, там новая таблица. Миграции — это пронумерованные SQL-файлы, каждый из которых описывает одно изменение. Каждая база (твоя, твоего коллеги, боевая) выполняет одни и те же файлы в одном и том же порядке и помнит, какие уже выполнила. Один раз установи инструмент командной строки:
# все базы данныхcargo install sqlx-cli
# или только то, что нужно, — так сборка быстрееcargo install sqlx-cli --no-default-features --features sqlite,rustlsЗатем в папке проекта:
echo "DATABASE_URL=sqlite:todos.db" > .envsqlx database createsqlx migrate add create_tasksCreating migrations/20261018035858_create_tasks.sql(У тебя число будет другим: это дата и время.) Запиши изменение в этот файл:
CREATE TABLE tasks ( id INTEGER PRIMARY KEY, title TEXT NOT NULL, done BOOLEAN NOT NULL DEFAULT FALSE);И примени его:
sqlx migrate runApplied 20261018035858/migrate create tasks (1.449995ms)Если запустить ещё раз, ничего не произойдёт: эта миграция уже применена. sqlx migrate info показывает, какие миграции применены, а какие ждут. Никогда не редактируй миграцию, которая уже где-то выполнилась, — добавь новую.
Можно также поручить программе применять миграции при запуске. Макрос migrate! встраивает файлы из ./migrations прямо в бинарник:
let pool = SqlitePool::connect("sqlite::memory:").await?;sqlx::migrate!().run(&pool).await?; // выполняет только те миграции, которых эта база ещё не виделаDiesel в двух словах
Заголовок раздела «Diesel в двух словах»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=true2 Learn Diesel done=false1 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 в двух словах
Заголовок раздела «SeaORM в двух словах»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 SeaORMModel — это строка, которую ты читаешь. ActiveModel — строка, которую ты меняешь: Set(...) помечает поля для сохранения, а ..Default::default() оставляет id на заполнение базе данных. Для больших проектов sea-orm-cli генерирует сущности по существующей базе и управляет миграциями. См. документацию SeaORM (на английском).
SQL-инъекции: всегда используй параметры
Заголовок раздела «SQL-инъекции: всегда используй параметры»Это самое важное правило на странице. Никогда не собирай 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, чтобы, когда все соединения заняты, запрос завершался понятной ошибкой, а не ждал вечно.
Практика
Заголовок раздела «Практика»1. Поиск в репозитории
Заголовок раздела «1. Поиск в репозитории»Возьми идею репозитория в памяти и добавь метод 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: Молоко для блиноврезультатов по запросу "кот": 0to_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: truemark 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-инъекции и как их останавливают параметризованные запросы — для любого языка.