Глава 32: Интеграция с базами данных

Содержание:
  1. SQL с использованием rusqlite и diesel
    1. Введение в интеграцию с базами данных в Rust
    2. Работа с rusqlite
    3. Работа с diesel
    4. Сравнение rusqlite и diesel
    5. Заключение
  2. NoSQL: redis-rs, mongodb
    1. Что такое NoSQL базы данных?
    2. Redis
    3. MongoDB
    4. Заключение
  3. CRUD-операции
    1. Что такое CRUD-операции?
    2. CRUD-операции в SQL базах данных (на примере SQLite с rusqlite)
    3. CRUD-операции в NoSQL базах данных (на примере MongoDB)
    4. Лучшие практики и "подводные камни"
    5. Заключение
  4. Миграции и ORM
    1. Что такое миграции и ORM?
    2. Установка и настройка Diesel
    3. Миграции в Diesel
    4. ORM в Diesel: модели и запросы
    5. Лучшие практики и "подводные камни"
    6. Заключение
  5. Примеры: база данных задач
    1. Пример 1: База данных задач с SQLite (rusqlite)
    2. Пример 2: База данных задач с MongoDB (mongodb)
    3. Сравнение SQL и NoSQL на примере базы данных задач
    4. Лучшие практики и "подводные камни"
    5. Заключение
  6. Упражнение: Реализовать список дел с базой
    1. Настройка проекта
    2. Создание таблицы
    3. Добавление задачи
    4. Просмотр задач
    5. Обновление задачи
    6. Удаление задачи
    7. Реализация консольного приложения
    8. Задание

Добро пожаловать в главу 32 нашего курса по Rust, состоящего из 40 разделов. Эта глава посвящена интеграции с базами данных — ключевому аспекту разработки приложений, где данные играют центральную роль. Мы подробно разберём, как Rust позволяет взаимодействовать с различными типами баз данных, начиная с реляционных (SQL) и заканчивая нереляционными (NoSQL). Глава разделена на шесть частей, и в этом документе мы сосредоточимся на первом разделе: работе с SQL базами данных через библиотеки rusqlite и diesel. Ожидайте глубокое погружение в тему с примерами кода, комментариями, практическими советами и разбором нюансов.


Раздел 1: SQL с использованием rusqlite и diesel

SQL (Structured Query Language) — это стандартный язык для работы с реляционными базами данных. В Rust интеграция с SQL позволяет создавать надёжные и производительные приложения, сохраняя преимущества языка, такие как безопасность памяти и отсутствие гонок данных. В этом разделе мы рассмотрим две популярные библиотеки: rusqlite для работы с SQLite и diesel как более мощный инструмент с поддержкой PostgreSQL, MySQL и SQLite. Мы начнём с основ, постепенно углубляясь в детали, чтобы вы могли уверенно применять эти инструменты в своих проектах.

Введение в интеграцию с базами данных в Rust

Rust — это язык, который сочетает производительность C++ с безопасностью на уровне компиляции. Эти качества делают его идеальным для работы с базами данных, где важны как скорость, так и надёжность. Интеграция с базами данных позволяет приложениям хранить, извлекать и обрабатывать данные, будь то списки пользователей, записи транзакций или сложные аналитические запросы.

SQL базы данных, такие как SQLite, PostgreSQL или MySQL, используют таблицы для структурирования данных, что обеспечивает строгую схему и мощные возможности запросов. В Rust для работы с ними существуют специализированные библиотеки, которые мы и разберём:

Но зачем использовать SQL в Rust? Вот ключевые преимущества:

  1. Структурированность: Реляционные базы данных организуют данные в таблицы с чёткой схемой.
  2. Запросы: SQL позволяет выполнять сложные операции фильтрации, объединения и агрегации.
  3. Транзакции: Гарантируют атомарность операций, что критично для целостности данных.
  4. Экосистема: Широкая поддержка инструментов и библиотек.

Работа с rusqlite

Что такое SQLite и rusqlite?

SQLite — это легковесная встраиваемая база данных, которая хранит все данные в одном файле. Она идеально подходит для небольших приложений, тестирования или встраиваемых систем, где не требуется отдельный сервер. Библиотека rusqlite предоставляет Rust-интерфейс для работы с SQLite, позволяя выполнять SQL-запросы, управлять транзакциями и обрабатывать результаты.

Установка rusqlite

Для начала добавьте зависимость в ваш Cargo.toml:

[dependencies]
rusqlite = "0.29.0"

После этого импортируйте библиотеку в коде:

 use rusqlite::{Connection, Result}; 

Если вы хотите использовать дополнительные возможности, например, функции JSON, добавьте соответствующую фичу: rusqlite = { version = "0.29.0", features = ["bundled", "json"] }. Фича bundled встраивает SQLite в ваш проект, устраняя зависимость от системной библиотеки.

Основные операции с rusqlite

Давайте разберём базовые операции: создание базы данных, таблиц, вставка данных, запросы, обновление и удаление.

1. Подключение к базе данных

SQLite использует файл для хранения данных. Если файла нет, он создаётся автоматически:

fn main() -> Result<()> {
    let conn = Connection::open("my_database.db")?;
    println!("База данных успешно открыта!");
    Ok(())
}

Метод Connection::open возвращает Result, что позволяет обработать ошибки, например, если файл недоступен из-за прав доступа.

2. Создание таблицы

Создадим таблицу users с полями id, name и age:

conn.execute(
    "CREATE TABLE IF NOT EXISTS users (
	id INTEGER PRIMARY KEY,
	name TEXT NOT NULL,
	age INTEGER NOT NULL
    )",
    [],
)?; 

Ключевое слово IF NOT EXISTS предотвращает ошибку, если таблица уже существует. Второй аргумент — массив параметров, здесь пустой, так как запрос статичен.

3. Вставка данных

Добавим пользователя "Alice" с возрастом 30:

conn.execute(
    "INSERT INTO users (name, age) VALUES (?1, ?2)",
    ["Alice", 30],
)?;

Здесь используются placeholders ?1 и ?2 для безопасной передачи параметров, что защищает от SQL-инъекций. Параметры передаются как массив с типом, реализующим ToSql.

4. Выполнение запросов

Извлечём всех пользователей из таблицы:

#[derive(Debug)]
struct User {
    id: i32,
    name: String,
    age: i32,
}

let mut stmt = conn.prepare("SELECT id, name, age FROM users")?;
let users_iter = stmt.query_map([], |row| {
    Ok(User {
        id: row.get(0)?,
        name: row.get(1)?,
        age: row.get(2)?,
    })
})?;
for user in users_iter {
    println!("Найден пользователь {:?}", user?);
}

Мы определили структуру User с атрибутом #[derive(Debug)] для вывода. Метод prepare готовит запрос, а query_map преобразует строки результата в экземпляры User. Оператор ? передаёт ошибки вверх.

5. Обновление данных

Обновим возраст пользователя с id=1:

conn.execute(
    "UPDATE users SET age = ?1 WHERE id = ?2",
    [35, 1],
)?;  

Запрос обновляет только записи, соответствующие условию WHERE.

6. Удаление данных

Удалим пользователя с id=1:

conn.execute(
    "DELETE FROM users WHERE id = ?1",
    [1],
)?;

Без условия WHERE удалятся все записи, так что будьте осторожны!

Транзакции в rusqlite

Транзакции обеспечивают атомарность операций. Пример вставки двух пользователей:

let mut tx = conn.transaction()?;
tx.execute(
    "INSERT INTO users (name, age) VALUES (?1, ?2)",
    ["Bob", 25]
)?;
tx.execute(
    "INSERT INTO users (name, age) VALUES (?1, ?2)",
    ["Charlie", 28]
)?;
tx.commit()?;

Если произойдёт ошибка, вызовите tx.rollback()? для отката изменений. Без commit изменения не сохранятся.

Полный пример приложения

Соберём всё вместе:

use rusqlite::{Connection, Result};
use std::error::Error;

#[derive(Debug)]
struct User {
    id: i32,
    name: String,
    age: i32,
}

fn main() -> Result<(), Box> {
    let conn = Connection::open("users.db")?;
    conn.execute(
        "CREATE TABLE IF NOT EXISTS users (
            id INTEGER PRIMARY KEY,
            name TEXT NOT NULL,
            age INTEGER NOT NULL
        )",
        [],
    )?;
    conn.execute(
	"INSERT INTO users (name, age) VALUES (?1, ?2)",
	["Alice", 30]
    )?;
    conn.execute(
	"INSERT INTO users (name, age) VALUES (?1, ?2)",
	["Bob", 25]
    )?;
    let mut stmt = conn.prepare( "SELECT id, name, age FROM users" )?;
    let users_iter = stmt.query_map([], |row| {
        Ok(User {
            id: row.get(0)?,
            name: row.get(1)?,
            age: row.get(2)?,
        })
    })?;
    for user in users_iter {
        println!("Найден пользователь {:?}", user?);
    }
    Ok(())
}

Этот код создаёт таблицу, добавляет данные и выводит их. Ошибки обрабатываются через Box<dyn Error> для гибкости.

Нюансы и лучшие практики

Работа с diesel

Что такое diesel?

diesel — это ORM и query builder для Rust, поддерживающий PostgreSQL, MySQL и SQLite. Он обеспечивает типобезопасность, генерацию кода из схемы базы данных и мощный DSL для запросов.

Преимущества:

Установка diesel

Добавьте в Cargo.toml:

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

Установите CLI для миграций:

cargo install diesel_cli --no-default-features --features "postgres"

Для PostgreSQL требуется libpq-dev (Linux) или аналог в вашей системе.

Настройка проекта

Создайте файл .env:

DATABASE_URL=postgres://username:password@localhost/mydb

Инициализируйте проект:

diesel setup 

Это создаёт папку migrations и файл diesel.toml.

Создание схемы

Сгенерируйте миграцию:

diesel migration generate create_users 

В migrations/.../up.sql:

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    name VARCHAR NOT NULL,
    age INTEGER NOT NULL
)

В migrations/.../down.sql:

DROP TABLE users

Примените миграцию:

diesel migration run 

Это создаёт файл src/schema.rs.

Основные операции с diesel

Подключение
use diesel::prelude::*;
use diesel::pg::PgConnection;
fn establish_connection() -> PgConnection {
    let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
    PgConnection::establish(&database_url).expect("Ошибка подключения")
}
Модели

Создайте файл models.rs:

use diesel::prelude::*;
use crate::schema::users;
#[derive(Queryable, Debug)]
pub struct User {
    pub id: i32,
    pub name: String,
    pub age: i32,
}
#[derive(Insertable)]
[diesel(table_name = users)]
pub struct NewUser<'a> {
    pub name: &'a str,
    pub age: i32,
} 
Вставка данных
use crate::schema::users;
let new_user = NewUser { name: "Alice", age: 30 }; 
diesel::insert_into(users::table).values(&new_user).execute(&connection)?;  
Запросы
use crate::schema::users::dsl::*;
let results = users.load::<User>(&connection)?;
for user in results {
    println!("{:?}", user);
}
// С фильтром
let filtered = users.filter(age.gt(25)).load::<User>(&connection)?;  
Обновление
diesel::update(users.filter(id.eq(1))).set(age.eq(35)).execute(&connection)?;  
Удаление
diesel::delete(users.filter(id.eq(1))).execute(&connection)?; 

Транзакции

connection.transaction(|conn| {
    diesel::insert_into(users::table).values(&new_user1).execute(conn)?; 
    diesel::insert_into(users::table).values(&new_user2).execute(conn)?;
    Ok(())
})?; 

Полный пример

use diesel::prelude::*;
use diesel::pg::PgConnection;
use std::env;
mod schema;
mod models; 
use models::{User, NewUser};
use schema::users;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let connection = establish_connection();
    let new_user = NewUser { name: "Alice", age: 30 };
    diesel::insert_into(users::table).values(&new_user).execute(&connection)?;
    let results = users::table.load::<User>(&connection)?;
    for user in results {
	println!("{:?}", user);
    }
    Ok(())
} 

Нюансы и лучшие практики

Сравнение rusqlite и diesel

Аспект rusqlite diesel
Базы данных Только SQLite PostgreSQL, MySQL, SQLite
Уровень абстракции Низкий (ручные SQL-запросы) Высокий (ORM, query builder)
Типобезопасность Ограниченная Полная
Сложность Простая Требует настройки

Выбирайте rusqlite для простоты и SQLite, а diesel — для сложных проектов с типобезопасностью.

Заключение

В этом разделе мы изучили основы работы с SQL в Rust через rusqlite и diesel. Вы теперь можете создавать таблицы, управлять данными и использовать транзакции. В следующих разделах мы рассмотрим NoSQL, CRUD, миграции и примеры.


Раздел 2. NoSQL: redis-rs, mongodb

В этом разделе мы углубимся в интеграцию языка программирования Rust с NoSQL базами данных, а именно с Redis и MongoDB, используя библиотеки redis-rs и mongodb. NoSQL базы данных отличаются от традиционных SQL баз своей гибкостью, масштабируемостью и подходом к хранению данных, что делает их идеальными для современных приложений, требующих высокой производительности и работы с неструктурированными данными. Мы разберем, как подключаться к этим базам, выполнять основные операции, рассмотрим примеры кода, лучшие практики и потенциальные "подводные камни".

Что такое NoSQL базы данных?

NoSQL (Not Only SQL) базы данных — это класс систем управления базами данных, которые не используют строгую реляционную модель, характерную для SQL баз данных. Они предназначены для работы с неструктурированными или полуструктурированными данными и предлагают гибкость в структуре данных, высокую производительность и горизонтальную масштабируемость. В отличие от SQL баз, где данные организованы в таблицы с фиксированной схемой, NoSQL базы могут использовать различные модели данных:

В этом разделе мы сосредоточимся на двух популярных NoSQL базах: Redis (ключ-значение) и MongoDB (документоориентированная).

Redis

Redis — это высокопроизводительная база данных, работающая в оперативной памяти (in-memory), что обеспечивает молниеносный доступ к данным. Она поддерживает различные структуры данных, такие как строки, хеши, списки, множества и отсортированные множества. Redis часто используется для кэширования, управления сессиями, очередей сообщений и других задач, где важна скорость.

Для работы с Redis в Rust мы будем использовать библиотеку redis-rs, которая предоставляет удобный интерфейс для синхронного и асинхронного взаимодействия с Redis.

Установка

Чтобы начать использовать redis-rs, добавьте зависимость в ваш файл Cargo.toml:

[dependencies]
redis = "0.21.0"

После этого выполните cargo build, чтобы загрузить и скомпилировать библиотеку.

Основные операции с Redis

Давайте разберем основные операции с Redis, начиная с подключения к серверу и заканчивая работой с различными структурами данных.

Подключение к Redis

Для взаимодействия с Redis сначала нужно установить соединение с сервером. Это делается с помощью структуры redis::Client:

use redis::Client;

fn main() -> redis::RedisResult<()> {
    let client = Client::open("redis://127.0.0.1/")?;
    let mut con = client.get_connection()?;
    println!("Подключение к Redis установлено!");
    Ok(())
}

Здесь redis://127.0.0.1/ — это строка подключения, указывающая на локальный сервер Redis, работающий на порту по умолчанию (6379). Метод get_connection возвращает объект соединения, который мы будем использовать для выполнения команд.

Внимание: Убедитесь, что сервер Redis запущен, иначе вы получите ошибку соединения. Запустить Redis можно командой redis-server в терминале.

Работа со строками

Простейшая операция в Redis — это установка и получение строковых значений с помощью команд SET и GET:

<(CRLF)code>
use redis::Commands;

fn main() -> redis::RedisResult<()> {
    let client = Client::open("redis://127.0.0.1/")?;
    let mut con = client.get_connection()?;

    // Установка значения
    con.set("my_key", "my_value")?;

    // Получение значения
    let value: String = con.get("my_key")?;
    println!("Значение: {}", value);

    Ok(())
}

Трейт redis::Commands предоставляет методы для выполнения команд Redis. Тип возвращаемого значения (String в данном случае) указывается явно, так как get может возвращать разные типы данных в зависимости от контекста.

Работа с хешами

Хеши позволяют хранить несколько пар "поле-значение" под одним ключом. Это полезно, например, для представления объектов:

use redis::Commands;

fn main() -> redis::RedisResult<()> {
    let client = Client::open("redis://127.0.0.1/")?;
    let mut con = client.get_connection()?;

    // Установка значений в хеше
    con.hset("user:1", "name", "Alice")?;
    con.hset("user:1", "age", "25")?;

    // Получение одного значения
    let name: String = con.hget("user:1", "name")?;
    println!("Имя: {}", name);

    // Получение всех значений хеша
    let user: std::collections::HashMap<String, String> = con.hgetall("user:1")?;
    for (field, value) in user {
        println!("{}: {}", field, value);
    }

    Ok(())
}

Метод hset добавляет поля в хеш, hget извлекает конкретное значение, а hgetall возвращает весь хеш как HashMap.

Работа со списками

Списки в Redis — это упорядоченные коллекции строк, которые можно использовать как очереди или стеки:

use redis::Commands;

fn main() -> redis::RedisResult<()> {
    let client = Client::open("redis://127.0.0.1/")?;
    let mut con = client.get_connection()?;

    // Добавление элементов в список
    con.lpush("tasks", "task1")?;
    con.lpush("tasks", "task2")?;

    // Получение всех элементов списка
    let tasks: Vec<String> = con.lrange("tasks", 0, -1)?;
    for task in tasks {
        println!("Задача: {}", task);
    }

    Ok(())
}

lpush добавляет элементы в начало списка, а lrange извлекает элементы по заданному диапазону (от 0 до -1 означает весь список).

Асинхронное взаимодействие

Для асинхронных приложений redis-rs поддерживает работу с Tokio или async-std. Добавьте зависимости в Cargo.toml:

[dependencies]
redis = { version = "0.21.0", features = ["tokio-comp"] }
tokio = { version = "1.0", features = ["full"] }

Пример асинхронного кода:

use redis::AsyncCommands;
use tokio;

#[tokio::main]
async fn main() -> redis::RedisResult<()> {
    let client = redis::Client::open("redis://127.0.0.1/")?;
    let mut con = client.get_async_connection().await?;

    // Асинхронная установка и получение значения
    con.set("async_key", "async_value").await?;
    let value: String = con.get("async_key").await?;
    println!("Значение: {}", value);

    Ok(())
}

Метод get_async_connection возвращает асинхронное соединение, а команды вызываются с использованием .await.

Лучшие практики и "подводные камни" для Redis

MongoDB

MongoDB — это документоориентированная NoSQL база данных, которая хранит данные в формате BSON (Binary JSON). Она идеально подходит для приложений с гибкими схемами данных, где структура может меняться со временем. MongoDB поддерживает вложенные документы, массивы и мощные запросы.

Для работы с MongoDB в Rust используется библиотека mongodb, предоставляющая асинхронный клиент (синхронный API не поддерживается).

Установка

Добавьте зависимость в Cargo.toml:

[dependencies]
mongodb = "2.0.0"
tokio = { version = "1.0", features = ["full"] }

Библиотека требует аснхронный runtime, например Tokio, так как все операции асинхронные.

Основные операции с MongoDB

Давайте рассмотрим базовые операции CRUD (Create, Read, Update, Delete) с MongoDB.

Подключение к MongoDB

Подключение осуществляется через mongodb::Client:

use mongodb::Client;

#[tokio::main]
async fn main() -> mongodb::error::Result<()> {
    let client = Client::with_uri_str("mongodb://localhost:27017/").await?;
    let db = client.database("mydb");
    println!("Подключение к MongoDB установлено!");
    Ok(())
}

Строка mongodb://localhost:27017/ указывает на локальный сервер MongoDB. Метод database возвращает объект базы данных.

Внимание: Убедитесь, что MongoDB запущена (mongod), иначе подключение завершится ошибкой.

Вставка документов

Для вставки данных используется метод insert_one:

use mongodb::bson::doc;
use mongodb::Collection;

#[tokio::main]
async fn main() -> mongodb::error::Result<()> {
    let client = Client::with_uri_str("mongodb://localhost:27017/").await?;
    let db = client.database("mydb");
    let collection: Collection<mongodb::bson::Document> = db.collection("users");

    let doc = doc! { "name": "Bob", "age": 30 };
    collection.insert_one(doc, None).await?;
    println!("Документ вставлен!");
    Ok(())
}

Макрос doc! создает BSON-документ, который затем вставляется в коллекцию users.

Поиск документов

Для поиска используется метод find, возвращающий курсор:

use mongodb::bson::doc;
use mongodb::Collection;

#[tokio::main]
async fn main() -> mongodb::error::Result<()> {
    let client = Client::with_uri_str("mongodb://localhost:27017/").await?;
    let db = client.database("mydb");
    let collection: Collection<mongodb::bson::Document> = db.collection("users");

    let filter = doc! { "name": "Bob" };
    let mut cursor = collection.find(filter, None).await?;
    while let Some(result) = cursor.next().await {
        match result {
            Ok(document) => println!("Найден документ: {:?}", document),
            Err(e) => return Err(e),
        }
    }
    Ok(())
}

Фильтр doc! { "name": "Bob" } указывает, какие документы искать. Курсор позволяет итерироваться по результатам.

Обновление документов

Для обновления используется update_one:

use mongodb::bson::doc;
use mongodb::Collection;

#[tokio::main]
async fn main() -> mongodb::error::Result<()> {
    let client = Client::with_uri_str("mongodb://localhost:27017/").await?;
    let db = client.database("mydb");
    let collection: Collection<mongodb::bson::Document> = db.collection("users");

    let filter = doc! { "name": "Bob" };
    let update = doc! { "$set": { "age": 31 } };
    collection.update_one(filter, update, None).await?;
    println!("Документ обновлен!");
    Ok(())
}

Оператор $set изменяет только указанные поля, оставляя остальную часть документа нетронутой.

Удаление документов

Для удаления используется delete_one:

use mongodb::bson::doc;
use mongodb::Collection;

#[tokio::main]
async fn main() -> mongodb::error::Result<()> {
    let client = Client::with_uri_str("mongodb://localhost:27017/").await?;
    let db = client.database("mydb");
    let collection: Collection<mongodb::bson::Document> = db.collection("users");

    let filter = doc! { "name": "Bob" };
    collection.delete_one(filter, None).await?;
    println!("Документ удален!");
    Ok(())
}

Лучшие практики и "подводные камни" для MongoDB

Заключение

Интеграция Rust с NoSQL базами данных, такими как Redis и MongoDB, открывает широкие возможности для создания быстрых и масштабируемых приложений. redis-rs идеально подходит для задач, требующих высокой скорости (кэширование, очереди), а mongodb — для работы с гибкими и сложными данными. Обе библиотеки предоставляют мощные инструменты для выполнения операций, но требуют внимания к обработке ошибок, управлению соединениями и оптимизации производительности.

В следующих разделах мы рассмотрим SQL базы данных, CRUD-операции, миграции, ORM и практические примеры. Оставайтесь с нами!


Раздел 3. CRUD-операции

В этом разделе мы подробно разберем основы работы с базами данных через CRUD-операции: Create (создание), Read (чтение), Update (обновление) и Delete (удаление). Эти операции составляют фундамент взаимодействия с любыми базами данных, будь то реляционные SQL или документоориентированные NoSQL. Мы рассмотрим реализацию CRUD-операций в Rust на примере SQLite (с использованием библиотеки rusqlite) для SQL и MongoDB (с использованием библиотеки mongodb) для NoSQL. Вас ждут подробные объяснения, примеры кода, лучшие практики, а также разбор потенциальных проблем и нюансов, которые могут встретиться на пути.

Что такое CRUD-операции?

CRUD — это акроним, который расшифровывается как:

Эти операции лежат в основе большинства приложений, работающих с данными. Их реализация может варьироваться в зависимости от типа базы данных (реляционная или нереляционная) и используемых инструментов. В Rust мы можем эффективно работать с обеими категориями баз данных благодаря мощным библиотекам.

CRUD-операции в SQL базах данных (на примере SQLite с rusqlite)

SQLite — это легковесная реляционная база данных, которая не требует отдельного серверного процесса и хранит все данные в одном файле. Библиотека rusqlite предоставляет удобный и безопасный интерфейс для работы с SQLite в Rust.

Установка

Для начала работы добавьте зависимость в ваш файл Cargo.toml:

[dependencies]
rusqlite = "0.26.3"

После этого выполните команду cargo build, чтобы скачать и скомпилировать библиотеку.

Подключение к базе данных

Первый шаг — установить соединение с базой данных. Если указанный файл базы данных не существует, SQLite создаст его автоматически.

use rusqlite::{Connection, Result};

fn main() -> Result<()> {
    let conn = Connection::open("mydb.db")?;
    println!("Подключение к SQLite установлено!");
    Ok(())
}

Функция Connection::open("mydb.db") открывает или создает файл базы данных с именем mydb.db. Возвращаемый тип Result позволяет обработать возможные ошибки, такие как проблемы с доступом к файлу.

Create: создание таблицы и вставка данных

Перед вставкой данных необходимо создать таблицу. Рассмотрим пример создания таблицы users с полями id, name и age.

use rusqlite::{Connection, Result};

fn main() -> Result<()> {
    let conn = Connection::open("mydb.db")?;

    // Создание таблицы, если она еще не существует
    conn.execute(
        "CREATE TABLE IF NOT EXISTS users (
            id INTEGER PRIMARY KEY,
            name TEXT NOT NULL,
            age INTEGER NOT NULL
        )",
        [],
    )?;

    // Вставка данных
    conn.execute(
        "INSERT INTO users (name, age) VALUES (?1, ?2)",
        ["Alice", 25],
    )?;
    println!("Данные успешно вставлены!");
    Ok(())
}
    

Метод execute выполняет SQL-запрос. Плейсхолдеры ?1 и ?2 используются для безопасной передачи параметров, что предотвращает SQL-инъекции. Поле id автоматически заполняется благодаря INTEGER PRIMARY KEY.

Read: чтение данных

Для чтения данных из базы можно использовать методы query_row (для одной строки) или prepare с query_map (для нескольких строк).

use rusqlite::{Connection, Result};

fn main() -> Result<()> {
    let conn = Connection::open("mydb.db")?;

    // Чтение одной строки
    let mut stmt = conn.prepare("SELECT id, name, age FROM users WHERE id = ?1")?;
    let user = stmt.query_row([1], |row| {
        Ok((
            row.get::<_, i32>(0)?,
            row.get::<_, String>(1)?,
            row.get::<_, i32>(2)?,
        ))
    })?;
    println!("Найден пользователь: {:?}", user);

    // Чтение нескольких строк
    let mut stmt = conn.prepare("SELECT id, name, age FROM users")?;
    let users_iter = stmt.query_map([], |row| {
        Ok((
            row.get::<_, i32>(0)?,
            row.get::<_, String>(1)?,
            row.get::<_, i32>(2)?,
        ))
    })?;

    for user in users_iter {
        println!("Пользователь: {:?}", user?);
    }
    Ok(())
}
    

Метод query_row возвращает кортеж с данными одной строки, а query_map создает итератор по всем строкам результата запроса. Обратите внимание на использование row.get с указанием типа данных для извлечения значений из столбцов.

Update: обновление данных

Для обновления данных используется SQL-запрос UPDATE с параметрами.

use rusqlite::{Connection, Result};

fn main() -> Result<()> {
    let conn = Connection::open("mydb.db")?;

    conn.execute(
        "UPDATE users SET age = ?1 WHERE id = ?2",
        [30, 1],
    )?;
    println!("Данные успешно обновлены!");
    Ok(())
}
    

Этот запрос изменяет возраст пользователя с id = 1 на 30. Плейсхолдеры обеспечивают безопасность и читаемость кода.

Delete: удаление данных

Удаление данных выполняется с помощью SQL-запроса DELETE.

use rusqlite::{Connection, Result};

fn main() -> Result<()> {
    let conn = Connection::open("mydb.db")?;

    conn.execute(
        "DELETE FROM users WHERE id = ?1",
        [1],
    )?;
    println!("Данные успешно удалены!");
    Ok(())
}
    

Запрос удаляет пользователя с id = 1. Если запись с таким id не существует, операция завершится без ошибок, но ничего не удалит.

CRUD-операции в NoSQL базах данных (на примере MongoDB)

MongoDB — это документоориентированная NoSQL база данных, которая хранит данные в формате BSON (бинарный JSON). В отличие от SQL, здесь нет строгой схемы, а данные могут быть вложенными и гибкими по структуре.

Установка

Для работы с MongoDB в Rust используется библиотека mongodb. Добавьте зависимости в Cargo.toml:

[dependencies]
mongodb = "2.0.0"
tokio = { version = "1.0", features = ["full"] }
    

Библиотека mongodb работает асинхронно и требует runtime, такой как Tokio.

Подключение к MongoDB

Подключение осуществляется через mongodb::Client с использованием строки подключения.

use mongodb::Client;

#[tokio::main]
async fn main() -> mongodb::error::Result<()> {
    let client = Client::with_uri_str("mongodb://localhost:27017/").await?;
    let db = client.database("mydb");
    println!("Подключение к MongoDB установлено!");
    Ok(())
}
    

Строка mongodb://localhost:27017/ указывает на локальный сервер MongoDB. Метод database выбирает базу данных mydb.

Create: вставка документов

Для вставки данных используется метод insert_one.

use mongodb::bson::doc;
use mongodb::Collection;

#[tokio::main]
async fn main() -> mongodb::error::Result<()> {
    let client = Client::with_uri_str("mongodb://localhost:27017/").await?;
    let db = client.database("mydb");
    let collection: Collection<mongodb::bson::Document> = db.collection("users");

    let doc = doc! { "name": "Alice", "age": 25 };
    collection.insert_one(doc, None).await?;
    println!("Документ успешно вставлен!");
    Ok(())
}
    

Макрос doc! создает документ в формате BSON, который затем вставляется в коллекцию users. Поле _id автоматически добавляется MongoDB, если не указано явно.

Read: чтение документов

Для чтения данных используется метод find, который возвращает асинхронный курсор.

use mongodb::bson::doc;
use mongodb::Collection;

#[tokio::main]
async fn main() -> mongodb::error::Result<()> {
    let client = Client::with_uri_str("mongodb://localhost:27017/").await?;
    let db = client.database("mydb");
    let collection: Collection<mongodb::bson::Document> = db.collection("users");

    let filter = doc! { "name": "Alice" };
    let mut cursor = collection.find(filter, None).await?;
    while let Some(result) = cursor.next().await {
        match result {
            Ok(document) => println!("Найден документ: {:?}", document),
            Err(e) => return Err(e),
        }
    }
    Ok(())
}
    

Фильтр doc! { "name": "Alice" } указывает, какие документы нужно найти. Курсор позволяет итерироваться по всем подходящим документам.

Update: обновление документов

Для обновления используется метод update_one.

use mongodb::bson::doc;
use mongodb::Collection;

#[tokio::main]
async fn main() -> mongodb::error::Result<()> {
    let client = Client::with_uri_str("mongodb://localhost:27017/").await?;
    let db = client.database("mydb");
    let collection: Collection<mongodb::bson::Document> = db.collection("users");

    let filter = doc! { "name": "Alice" };
    let update = doc! { "$set": { "age": 26 } };
    collection.update_one(filter, update, None).await?;
    println!("Документ успешно обновлен!");
    Ok(())
}
    

Оператор $set обновляет только указанные поля, оставляя остальную часть документа неизменной.

Delete: удаление документов

Для удаления используется метод delete_one.

use mongodb::bson::doc;
use mongodb::Collection;

#[tokio::main]
async fn main() -> mongodb::error::Result<()> {
    let client = Client::with_uri_str("mongodb://localhost:27017/").await?;
    let db = client.database("mydb");
    let collection: Collection<mongodb::bson::Document> = db.collection("users");

    let filter = doc! { "name": "Alice" };
    collection.delete_one(filter, None).await?;
    println!("Документ успешно удален!");
    Ok(())
}
    

Этот запрос удаляет первый документ, соответствующий фильтру {"name": "Alice"}.

Лучшие практики и "подводные камни"

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

Предупреждение: SQLite не поддерживает высокую конкурентность записи, поэтому для многопоточных приложений рассмотрите другие базы данных, такие как PostgreSQL.

Заключение

CRUD-операции — это основа взаимодействия с базами данных в любом приложении. В этом разделе мы подробно рассмотрели, как выполнять эти операции в Rust, используя SQLite для SQL и MongoDB для NoSQL. Вы узнали, как подключаться к базам данных, создавать, читать, обновлять и удалять данные, а также как избегать распространенных ошибок. Эти знания помогут вам строить надежные и эффективные приложения, независимо от типа используемой базы данных.

В следующих разделах мы разберем миграции, ORM, практические примеры (например, базу данных задач) и упражнение по созданию списка дел. Продолжайте изучение!


Раздел 4. Миграции и ORM

В этом разделе мы углубимся в темы миграций и объектно-реляционного отображения (ORM) при работе с базами данных в Rust. Миграции позволяют управлять изменениями схемы базы данных, а ORM упрощает взаимодействие с базой данных, предоставляя объектно-ориентированный интерфейс. Мы сосредоточимся на библиотеке diesel, которая является одной из самых популярных и мощных ORM для Rust, поддерживающей PostgreSQL, MySQL и SQLite. Вы узнаете, как настраивать миграции, создавать модели, выполнять запросы и управлять схемой базы данных. Также мы рассмотрим лучшие практики, потенциальные "подводные камни" и нюансы работы с diesel.

Что такое миграции и ORM?

В Rust библиотека diesel предоставляет как ORM, так и инструменты для управления миграциями, что делает её отличным выбором для работы с SQL базами данных.

Установка и настройка Diesel

Для начала работы с diesel необходимо установить CLI-инструмент и добавить зависимости в проект.

Установка Diesel CLI

Diesel CLI — это утилита командной строки, которая помогает управлять миграциями и генерировать схему. Установите её с помощью Cargo:

cargo install diesel_cli --no-default-features --features "postgres"
    

В этом примере мы устанавливаем Diesel CLI с поддержкой PostgreSQL. Для MySQL или SQLite замените "postgres" на "mysql" или "sqlite" соответственно.

Заметка: Для SQLite также потребуется установить библиотеку libsqlite3-dev (или аналогичную для вашей ОС), чтобы Cargo мог скомпилировать rusqlite.

Настройка проекта

Добавьте зависимости в ваш файл Cargo.toml. Для примера с PostgreSQL:

[dependencies]
diesel = { version = "1.4.8", features = ["postgres", "r2d2"] }
dotenv = "0.15.0"
    

Здесь r2d2 — это пул соединений, а dotenv поможет управлять переменными окружения, такими как строка подключения к базе данных.

Инициализация Diesel

Создайте файл .env в корне проекта с переменной DATABASE_URL, указывающей на вашу базу данных. Например, для PostgreSQL:

DATABASE_URL=postgres://username:password@localhost/mydb
    

Затем выполните команду для инициализации Diesel:

diesel setup
    

Эта команда создаст директорию migrations для хранения миграций и файл diesel.toml с конфигурацией.

Миграции в Diesel

Миграции в Diesel представляют собой пары SQL-файлов: up.sql (для применения изменений) и down.sql (для отката изменений). Давайте создадим миграцию для таблицы users.

Создание миграции

Выполните команду:

diesel migration generate create_users
    

Это создаст новую директорию в migrations с файлами up.sql и down.sql. Отредактируйте up.sql:

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    name VARCHAR NOT NULL,
    age INTEGER NOT NULL
);
    

И down.sql:

DROP TABLE users;
    

Применение миграции

Для применения миграции выполните:

diesel migration run
    

Это выполнит up.sql и создаст таблицу users. Если нужно откатить миграцию, используйте diesel migration redo, что выполнит down.sql, а затем снова up.sql.

ORM в Diesel: модели и запросы

Diesel позволяет определять модели, которые соответствуют таблицам в базе данных, и выполнять типобезопасные запросы.

Определение модели

Сначала сгенерируйте схему базы данных. После применения миграции выполните:

diesel print-schema > src/schema.rs
    

Это создаст файл schema.rs с определением схемы. Теперь определите модель User в src/models.rs:

use diesel::prelude::*;
use crate::schema::users;

#[derive(Queryable, Insertable)]
#[table_name="users"]
pub struct User {
    pub id: i32,
    pub name: String,
    pub age: i32,
}
    

Атрибуты #[derive(Queryable, Insertable)] позволяют использовать эту структуру для запросов и вставки данных. #[table_name="users"] связывает структуру с таблицей users.

Подключение к базе данных

Для подключения к базе данных используйте diesel::PgConnection (или аналог для другой СУБД). Рекомендуется использовать пул соединений, например, r2d2.

use diesel::r2d2::{ConnectionManager, Pool};
use dotenv::dotenv;
use std::env;

pub type DbPool = Pool>;

pub fn establish_connection() -> DbPool {
    dotenv().ok();
    let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
    let manager = ConnectionManager::::new(database_url);
    Pool::builder().build(manager).expect("Failed to create pool")
}
    

Этот код создает пул соединений, который можно использовать в приложении для выполнения запросов.

CRUD-операции с Diesel

Давайте рассмотрим, как выполнять CRUD-операции с использованием моделей и запросов Diesel.

Create: вставка данных

Для вставки нового пользователя:

use diesel::prelude::*;
use crate::models::User;
use crate::schema::users;

fn create_user(conn: &PgConnection, name: &str, age: i32) -> Result {
    let new_user = User { id: 0, name: name.to_string(), age }; // id будет сгенерирован базой
    diesel::insert_into(users::table)
        .values(&new_user)
        .get_result(conn)
}
    

Метод insert_into выполняет вставку, а get_result возвращает вставленную запись (с сгенерированным id).

Read: чтение данных

Для чтения всех пользователей:

use diesel::prelude::*;
use crate::models::User;
use crate::schema::users::dsl::*;

fn get_users(conn: &PgConnection) -> Result, diesel::result::Error> {
    users.load::(conn)
}
    

Для чтения с фильтром, например, по имени:

fn get_user_by_name(conn: &PgConnection, user_name: &str) -> Result {
    users.filter(name.eq(user_name)).first(conn)
}
    

Метод filter добавляет условие WHERE, а first возвращает первую запись или ошибку, если запись не найдена.

Update: обновление данных

Для обновления возраста пользователя по id:

use diesel::prelude::*;
use crate::schema::users::dsl::*;

fn update_user_age(conn: &PgConnection, user_id: i32, new_age: i32) -> Result {
    diesel::update(users.find(user_id))
        .set(age.eq(new_age))
        .execute(conn)
}
    

Метод update с find обновляет запись по первичному ключу. Возвращаемое значение — количество обновленных строк.

Delete: удаление данных

Для удаления пользователя по id:

use diesel::prelude::*;
use crate::schema::users::dsl::*;

fn delete_user(conn: &PgConnection, user_id: i32) -> Result {
    diesel::delete(users.find(user_id)).execute(conn)
}
    

Метод delete удаляет запись и возвращает количество удаленных строк.

Лучшие практики и "подводные камни"

Предупреждение: Diesel не поддерживает автоматическую генерацию миграций на основе моделей. Вы должны вручную писать SQL для миграций, что требует хорошего знания SQL.

Заключение

Миграции и ORM — это мощные инструменты для управления базами данных в Rust. С помощью diesel вы можете эффективно управлять схемой базы данных, выполнять типобезопасные запросы и работать с данными через объектно-ориентированный интерфейс. В этом разделе мы рассмотрели, как настраивать миграции, создавать модели, выполнять CRUD-операции и избегать распространенных ошибок. Эти знания помогут вам строить надежные и масштабируемые приложения с использованием SQL баз данных.

В следующих разделах мы рассмотрим примеры баз данных задач и упражнение по созданию списка дел с базой данных. Продолжайте изучение!


Раздел 5. Примеры: база данных задач

В этом разделе мы рассмотрим практические примеры интеграции Rust с базами данных на примере базы данных задач (To-Do List). Мы реализуем простое приложение для управления задачами, используя как SQL (SQLite с библиотекой rusqlite), так и NoSQL (MongoDB с библиотекой mongodb). Вы увидите, как выполнять CRUD-операции, управлять соединениями и обрабатывать данные в обоих типах баз данных. Этот раздел поможет вам понять различия и особенности работы с реляционными и нереляционными базами данных в Rust.

Пример 1: База данных задач с SQLite (rusqlite)

SQLite — это легковесная реляционная база данных, идеально подходящая для небольших приложений или прототипов. Мы создадим таблицу tasks с полями id, description и completed, а затем реализуем функции для добавления, чтения, обновления и удаления задач.

Шаг 1: Настройка проекта

Добавьте зависимость rusqlite в ваш Cargo.toml:

[dependencies]
rusqlite = "0.26.3"
    

Шаг 2: Создание базы данных и таблицы

Сначала создадим базу данных и таблицу tasks, если она еще не существует.

use rusqlite::{Connection, Result};

fn init_db() -> Result<Connection> {
    let conn = Connection::open("tasks.db")?;
    conn.execute(
        "CREATE TABLE IF NOT EXISTS tasks (
            id INTEGER PRIMARY KEY,
            description TEXT NOT NULL,
            completed BOOLEAN NOT NULL DEFAULT 0
        )",
        [],
    )?;
    Ok(conn)
}
    

Функция init_db открывает соединение с базой данных tasks.db и создает таблицу tasks, если она не существует. Поле completed типа BOOLEAN хранит статус завершения задачи (0 — не завершена, 1 — завершена).

Шаг 3: Добавление задачи (Create)

Функция для добавления новой задачи:

fn add_task(conn: &Connection, description: &str) -> Result<()> {
    conn.execute(
        "INSERT INTO tasks (description) VALUES (?1)",
        [description],
    )?;
    Ok(())
}
    

Мы используем параметризованный запрос для безопасной вставки описания задачи.

Шаг 4: Чтение задач (Read)

Функция для получения всех задач:

fn get_tasks(conn: &Connection) -> Result<Vec<(i32, String, bool)>> {
    let mut stmt = conn.prepare("SELECT id, description, completed FROM tasks")?;
    let tasks_iter = stmt.query_map([], |row| {
        Ok((
            row.get(0)?,
            row.get(1)?,
            row.get(2)?,
        ))
    })?;

    let mut tasks = Vec::new();
    for task in tasks_iter {
        tasks.push(task?);
    }
    Ok(tasks)
}
    

Мы используем query_map для извлечения всех строк из таблицы и преобразования их в кортежи (id, description, completed).

Шаг 5: Обновление задачи (Update)

Функция для обновления статуса завершения задачи по id:

fn complete_task(conn: &Connection, id: i32) -> Result<()> {
    conn.execute(
        "UPDATE tasks SET completed = 1 WHERE id = ?1",
        [id],
    )?;
    Ok(())
}
    

Шаг 6: Удаление задачи (Delete)

Функция для удаления задачи по id:

fn delete_task(conn: &Connection, id: i32) -> Result<()> {
    conn.execute(
        "DELETE FROM tasks WHERE id = ?1",
        [id],
    )?;
    Ok(())
}
    

Шаг 7: Пример использования

Теперь соберем все вместе в функции main:

fn main() -> Result<()> {
    let conn = init_db()?;

    // Добавляем задачи
    add_task(&conn, "Купить молоко")?;
    add_task(&conn, "Позвонить другу")?;

    // Получаем и выводим все задачи
    let tasks = get_tasks(&conn)?;
    for task in tasks {
        println!("ID: {}, Описание: {}, Завершена: {}", task.0, task.1, task.2);
    }

    // Завершаем задачу с id=1
    complete_task(&conn, 1)?;

    // Удаляем задачу с id=2
    delete_task(&conn, 2)?;

    Ok(())
}
    

Этот пример демонстрирует базовые CRUD-операции с SQLite. Вы можете расширить его, добавив, например, фильтрацию задач по статусу или дате.

Пример 2: База данных задач с MongoDB (mongodb)

MongoDB — это документоориентированная NoSQL база данных, которая хранит данные в гибком формате, похожем на JSON. Мы создадим коллекцию tasks и реализуем те же CRUD-операции, что и в примере с SQLite.

Шаг 1: Настройка проекта

Добавьте зависимости в Cargo.toml:

[dependencies]
mongodb = "2.0.0"
tokio = { version = "1.0", features = ["full"] }
    

Поскольку mongodb работает асинхронно, мы используем Tokio как runtime.

Шаг 2: Подключение к MongoDB

Установим соединение с MongoDB и выберем базу данных и коллекцию.

use mongodb::{Client, Collection};
use mongodb::bson::doc;

#[tokio::main]
async fn main() -> mongodb::error::Result<()> {
    let client = Client::with_uri_str("mongodb://localhost:27017/").await?;
    let db = client.database("tasks_db");
    let collection: Collection<mongodb::bson::Document> = db.collection("tasks");

    // Дальнейшие операции...
    Ok(())
}
    

Шаг 3: Добавление задачи (Create)

Для добавления новой задачи используем insert_one:

async fn add_task(collection: &Collection<mongodb::bson::Document>, description: &str) -> mongodb::error::Result<()> {
    let doc = doc! { "description": description, "completed": false };
    collection.insert_one(doc, None).await?;
    Ok(())
}
    

Мы создаем документ с полями description и completed и вставляем его в коллекцию.

Шаг 4: Чтение задач (Read)

Для получения всех задач используем find:

async fn get_tasks(collection: &Collection<mongodb::bson::Document>) -> mongodb::error::Result<Vec<mongodb::bson::Document>> {
    let mut cursor = collection.find(None, None).await?;
    let mut tasks = Vec::new();
    while let Some(result) = cursor.next().await {
        tasks.push(result?);
    }
    Ok(tasks)
}
    

Метод find без фильтра возвращает все документы в коллекции. Мы собираем их в вектор для дальнейшего использования.

Шаг 5: Обновление задачи (Update)

Для обновления статуса задачи по _id используем update_one. Поскольку _id в MongoDB является уникальным идентификатором, нам нужно его знать. Для простоты, предположим, что мы знаем _id задачи.

use mongodb::bson::oid::ObjectId;

async fn complete_task(collection: &Collection<mongodb::bson::Document>, id: &str) -> mongodb::error::Result<()> {
    let obj_id = ObjectId::with_string(id)?;
    let filter = doc! { "_id": obj_id };
    let update = doc! { "$set": { "completed": true } };
    collection.update_one(filter, update, None).await?;
    Ok(())
}
    

Здесь ObjectId::with_string преобразует строковый id в ObjectId, который используется в MongoDB.

Шаг 6: Удаление задачи (Delete)

Для удаления задачи по _id используем delete_one:

async fn delete_task(collection: &Collection<mongodb::bson::Document>, id: &str) -> mongodb::error::Result<()> {
    let obj_id = ObjectId::with_string(id)?;
    let filter = doc! { "_id": obj_id };
    collection.delete_one(filter, None).await?;
    Ok(())
}
    

Шаг 7: Пример использования

Соберем все вместе в асинхронной функции main. Для упрощения, мы будем использовать фиктивные _id, но в реальном приложении вы бы получали их из базы данных.

#[tokio::main]
async fn main() -> mongodb::error::Result<()> {
    let client = Client::with_uri_str("mongodb://localhost:27017/").await?;
    let db = client.database("tasks_db");
    let collection: Collection<mongodb::bson::Document> = db.collection("tasks");

    // Добавляем задачи
    add_task(&collection, "Купить молоко").await?;
    add_task(&collection, "Позвонить другу").await?;

    // Получаем и выводим все задачи
    let tasks = get_tasks(&collection).await?;
    for task in tasks {
        println!("Задача: {:?}", task);
    }

    // Предположим, что у нас есть _id задачи, например, "507f1f77bcf86cd799439011"
    let task_id = "507f1f77bcf86cd799439011";
    complete_task(&collection, task_id).await?;

    // Удаляем задачу по _id
    delete_task(&collection, task_id).await?;

    Ok(())
}
    

Этот пример показывает, как выполнять CRUD-операции с MongoDB в Rust. Обратите внимание на асинхронный характер операций и необходимость управления _id.

Сравнение SQL и NoSQL на примере базы данных задач

Выбор между SQL и NoSQL зависит от требований вашего приложения. SQL базы данных лучше подходят для структурированных данных с четкими отношениями, в то время как NoSQL — для гибких, неструктурированных данных и горизонтального масштабирования.

Лучшие практики и "подводные камни"

Заметка: В MongoDB можно использовать insert_many для пакетной вставки нескольких задач, что может быть более эффективно.

Предупреждение: В SQLite одновременная запись из нескольких потоков может привести к блокировкам. Для высоконагруженных приложений рассмотрите другие базы данных, такие как PostgreSQL.

Заключение

В этом разделе мы рассмотрели практические примеры интеграции Rust с базами данных на примере базы данных задач. Вы увидели, как реализовать CRUD-операции как в SQL (SQLite), так и в NoSQL (MongoDB), и узнали о различиях между этими подходами. Эти знания помогут вам выбрать подходящую базу данных для вашего приложения и эффективно работать с ней в Rust.

В следующем разделе мы предложим упражнение по созданию списка дел с базой данных, где вы сможете закрепить полученные знания на практике. Продолжайте изучение!


Раздел 6: Упражнение: Реализовать список дел с базой

В этом упражнении вы научитесь интегрировать базу данных в ваше Rust-приложение на примере простого списка дел. Вы создадите консольное приложение, которое позволяет управлять задачами, хранящимися в базе данных SQLite. Мы будем использовать crate rusqlite для работы с SQLite, так как это лёгкий и популярный выбор для начинающих, не требующий внешнего сервера базы данных. Цель — реализовать полный цикл CRUD-операций (создание, чтение, обновление, удаление) и предоставить пользователю удобный интерфейс через командную строку.

Настройка проекта

Сначала создайте новый проект Rust с помощью Cargo. Откройте терминал и выполните следующие команды:

cargo new todo_list
cd todo_list

Эти команды создадут новый проект с именем todo_list и перейдут в его директорию. Далее необходимо добавить зависимость от crate rusqlite в файл Cargo.toml, чтобы ваш проект мог взаимодействовать с SQLite:

[dependencies]
rusqlite = "0.25.3"

После добавления зависимости Cargo автоматически загрузит и подключит rusqlite при следующей сборке проекта. Указанная версия (0.25.3) проверена и стабильна на момент написания, но вы можете проверить crates.io для использования более новой версии, если она доступна.

Совет: Если вы хотите добавить дополнительные возможности, такие как поддержка типов времени или JSON, изучите документацию rusqlite для подключения соответствующих feature-флагов в Cargo.toml, например, rusqlite = { version = "0.25.3", features = ["chrono"] }.

Создание таблицы

Ваше приложение будет хранить задачи в таблице базы данных. Каждая задача должна иметь уникальный идентификатор (id), описание (description) и статус выполнения (done). Мы определим функцию для создания такой таблицы в SQLite:

use rusqlite::{Connection, Result};

fn create_table(conn: &Connection) -> Result<()> {
    conn.execute(
        "CREATE TABLE IF NOT EXISTS tasks (
            id INTEGER PRIMARY KEY,
            description TEXT NOT NULL,
            done BOOLEAN NOT NULL DEFAULT 0
        )",
        [],
    )?;
    Ok(())
}

Разберём этот код:

Подводный камень: SQLite не имеет настоящего типа BOOLEAN — он интерпретирует его как INTEGER (0 или 1). Убедитесь, что вы учитываете это при работе с данными.

Добавление задачи

Теперь реализуем функцию для добавления новой задачи в таблицу:

fn add_task(conn: &Connection, description: &str) -> Result<()> {
    conn.execute(
        "INSERT INTO tasks (description) VALUES (?1)",
        [description],
    )?;
    Ok(())
}

Что здесь происходит:

Лучшая практика: Всегда используйте параметризованные запросы (как ?1), чтобы избежать SQL-инъекций. Например, если пользователь введёт "DROP TABLE tasks; --", параметризация предотвратит выполнение этого как команды.

Просмотр задач

Для отображения всех задач в списке реализуем функцию, которая возвращает их в виде вектора:

use rusqlite::Row;

fn list_tasks(conn: &Connection) -> Result<Vec<(i32, String, bool)>> {
    let mut stmt = conn.prepare("SELECT id, description, done FROM tasks")?;
    let tasks = stmt.query_map([], |row| {
        Ok((
            row.get(0)?,
            row.get(1)?,
            row.get(2)?,
        ))
    })?.collect();
    tasks
}

Пошаговый разбор:

Нюанс: Если таблица пуста, функция вернёт пустой вектор. Убедитесь, что ваш код обработки вывода корректно обрабатывает этот случай.

Обновление задачи

Чтобы пометить задачу как выполненную, добавим функцию обновления:

fn complete_task(conn: &Connection, id: i32) -> Result<()> {
    conn.execute(
        "UPDATE tasks SET done = 1 WHERE id = ?1",
        [id],
    )?;
    Ok(())
}

Здесь:

Подводный камень: Если id не существует, запрос выполнится успешно, но не изменит ни одной строки. Вы можете проверить количество затронутых строк с помощью conn.changes(), чтобы убедиться, что обновление прошло.

Удаление задачи

Для удаления задачи реализуем следующую функцию:

fn delete_task(conn: &Connection, id: i32) -> Result<()> {
    conn.execute(
        "DELETE FROM tasks WHERE id = ?1",
        [id],
    )?;
    Ok(())
}

Этот код удаляет строку из таблицы tasks по указанному id. Как и в случае с обновлением, стоит проверить, была ли строка удалена, если это критично для логики приложения.

Реализация консольного приложения

Теперь соберём все функции в полноценное консольное приложение. Оно будет поддерживать следующие команды:

Вот полный код приложения:

use std::env;
use rusqlite::{Connection, Result};

fn main() -> Result<()> {
    let args: Vec<String> = env::args().collect();
    let conn = Connection::open("tasks.db")?;
    create_table(&conn)?;

    if args.len() < 2 {
        println!("Usage: todo_list [add \"description\" | list | complete id | delete id]");
        return Ok(());
    }

    match args[1].as_str() {
        "add" => {
            if args.len() < 3 {
                println!("Please provide a description");
                return Ok(());
            }
            add_task(&conn, &args[2])?;
            println!("Task added");
        }
        "list" => {
            let tasks = list_tasks(&conn)?;
            for task in tasks {
                println!("{}: {} [{}]", task.0, task.1, if task.2 { "done" } else { "not done" });
            }
        }
        "complete" => {
            if args.len() < 3 {
                println!("Please provide a task id");
                return Ok(());
            }
            let id: i32 = args[2].parse().expect("Invalid id");
            complete_task(&conn, id)?;
            println!("Task completed");
        }
        "delete" => {
            if args.len() < 3 {
                println!("Please provide a task id");
                return Ok(());
            }
            let id: i32 = args[2].parse().expect("Invalid id");
            delete_task(&conn, id)?;
            println!("Task deleted");
        }
        _ => println!("Invalid command"),
    }

    Ok(())
}

fn create_table(conn: &Connection) -> Result<()> {
    conn.execute(
        "CREATE TABLE IF NOT EXISTS tasks (
            id INTEGER PRIMARY KEY,
            description TEXT NOT NULL,
            done BOOLEAN NOT NULL DEFAULT 0
        )",
        [],
    )?;
    Ok(())
}

fn add_task(conn: &Connection, description: &str) -> Result<()> {
    conn.execute("INSERT INTO tasks (description) VALUES (?1)", [description])?;
    Ok(())
}

fn list_tasks(conn: &Connection) -> Result<Vec<(i32, String, bool)>> {
    let mut stmt = conn.prepare("SELECT id, description, done FROM tasks")?;
    let tasks = stmt.query_map([], |row| {
        Ok((row.get(0)?, row.get(1)?, row.get(2)?))
    })?.collect();
    tasks
}

fn complete_task(conn: &Connection, id: i32) -> Result<()> {
    conn.execute("UPDATE tasks SET done = 1 WHERE id = ?1", [id])?;
    Ok(())
}

fn delete_task(conn: &Connection, id: i32) -> Result<()> {
    conn.execute("DELETE FROM tasks WHERE id = ?1", [id])?;
    Ok(())
}

Как это работает:

Совет: Для улучшения приложения добавьте обработку ошибок ввода (например, если id не число) с помощью match args[2].parse() вместо expect.

Задание

Реализуйте описанное выше консольное приложение для управления списком дел, используя базу данных SQLite. Убедитесь, что все CRUD-операции работают корректно. Проверьте приложение, добавив несколько задач, просмотрев их, завершив одну и удалив другую. Для дополнительного вызова:

После выполнения сохраните код в файл src/main.rs, соберите проект с помощью cargo build и запустите его командой cargo run -- add "Купить молоко". Удачи!