В этой статье мы рассмотрим, как использовать SQLite (вместе с Diesel) и Rust как в файловом режиме, так и в режиме памяти.
Необходимые условия
Кроме Rust и Diesel CLI, понадобится установить SQLite для вашей платформы:
# Linux
$ sudo apt install sqlite3 libsqlite3-0 libsqlite3-dev
# OSX
$ brew install sqlite3
Создаём проект (коммит)
$ cargo new diesel-sqlite
$ cd diesel-sqlite
Добавляем следующие зависимости:
- Diesel, предоставляющую средства объектно-реляционного отображения.
- Actix web для уровня обработки запросов по протоколу HTTP.
- Dotenv для работы с переменными окружения.
- Uuid для генерирования ID.
[dependencies]
actix-rt = "1.0"
actix-web = "2.0"
chrono = { version = "0.4.11", features = ["serde"] }
diesel = { version = "1.4.4", features = ["sqlite", "uuidv07", "chrono"] }
dotenv = "0.15.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
uuid = { version = "0.8", features = ["serde", "v4"] }
Далее создаём файл .env
с DATABASE_URL
:
DATABASE_URL=users.db
Инициализируем Diesel и создаём скрипт миграции (коммит)
$ diesel setup
$ diesel migration generate create_users
Добавляем следующие инструкции SQL в сгенерированные файлы миграции up
и down
:
-- migrations/xxxx_create_users/up.sql
CREATE TABLE IF NOT EXISTS users (
id CHARACTER(36) NOT NULL PRIMARY KEY,
email VARCHAR(60),
phone VARCHAR(20),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
);
CREATE TRIGGER IF NOT EXISTS UpdateTimestamps AFTER UPDATE ON users
FOR EACH ROW WHEN NEW.updated_at <= OLD.updated_at
BEGIN
update users set updated_at=CURRENT_TIMESTAMP where id=OLD.id;
END;
-- migrations/xxxx_create_users/down.sql
DROP TRIGGER IF EXISTS UpdateTimestamps;
DROP TABLE IF EXISTS users;
Создаём таблицу users
, запустив:
$ diesel migration run
После запуска Diesel должен создать для вас схему в src/schema.rs
.
Давайте реорганизуем файлы в нашей базе данных:
- Создадим каталог
db
внутри каталогаsrc
. - Переместим
src/schema.rs
в каталогdb
. - Обновим переменную
file
вdiesel.toml
изsrc/schema.rs
вsrc/db/schema.rs
. - Создадим файл
models.rs
в каталогеdb
.
После этого создаём файл src/db.rs
вот с таким содержимым:
// src/db.rs
pub mod models;
pub mod schema;
Создаём модель (коммит)
Первым делом встроим наши миграции, так как для тестов понадобится использовать режим памяти SQLite. Кроме того, плюсом миграций является то, что они компилируются в наше приложение, создавая один исполняемый файл и удаляя эту зависимость в файловой системе.
Добавим diesel_migrations
к нашим зависимостям:
# Cargo.toml
[dependencies]
# ...
diesel_migrations = "1.4.0"
Добавим их в начало main.rs
:
// src/main.rs
#[macro_use]
extern crate diesel;
#[macro_use]
extern crate diesel_migrations;
mod db;
// ...
В src/db.rs
вставим:
// src/db.rs
// ...
embed_migrations!();
pub fn establish_connection() -> SqliteConnection {
if cfg!(test) {
let conn = SqliteConnection::establish(":memory:")
.unwrap_or_else(|_| panic!("Error creating test database"));
let _result = diesel_migrations::run_pending_migrations(&conn); conn
} else {
dotenv().ok();
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
SqliteConnection::establish(&database_url)
.unwrap_or_else(|_| panic!("Error connecting to {}", database_url))
}
}
В establish_connection()
используем средства условной компиляции Rust для возврата соединения в памяти для тестов и подключения к файлу для обычных запусков.
Вставляем это в src/db/models.rs
:
use uuid::Uuid;
use serde::{Deserialize, Serialize};
use diesel::prelude::*;
use super::schema::users;
use super::schema::users::dsl::users as user_dsl;
#[derive(Debug, Deserialize, Serialize, Queryable, Insertable)]
#[table_name = "users"]
pub struct User {
pub id: String,
pub email: Option<String>,
pub phone: Option<String>,
pub created_at: chrono::NaiveDateTime,
pub updated_at: chrono::NaiveDateTime,
}
impl User {
pub fn list(conn: &SqliteConnection) -> Vec<Self> {
user_dsl.load::<User>(conn).expect("Error loading users")
}
pub fn by_id(id: &str, conn: &SqliteConnection) -> Option<Self> {
if let Ok(record) = user_dsl.find(id).get_result::<User>(conn) {
Some(record)
} else {
None
}
}
pub fn by_email(email_str: &str, conn: &SqliteConnection) -> Option<Self> {
use super::schema::users::dsl::email;
if let Ok(record) = user_dsl.filter(email.eq(email_str)).first::<User>(conn) {
Some(record)
} else {
None
}
}
pub fn by_phone(phone_str: &str, conn: &SqliteConnection) -> Option<Self> {
use super::schema::users::dsl::phone;
if let Ok(record) = user_dsl.filter(phone.eq(phone_str)).first::<User>(conn) {
Some(record)
} else {
None
}
}
pub fn create(email: Option<&str>, phone: Option<&str>, conn: &SqliteConnection) -> Option<Self> {
let new_id = Uuid::new_v4().to_hyphenated().to_string();
if email.is_none() && phone.is_none() {
return None
}
if phone.is_some() {
if let Some(user) = Self::by_phone(&phone.unwrap(), conn) {
return Some(user)
}
}
if email.is_some() {
if let Some(user) = Self::by_email(&email.unwrap(), conn) {
return Some(user)
}
}
let new_user = Self::new_user_struct(&new_id, phone, email);
diesel::insert_into(user_dsl)
.values(&new_user)
.execute(conn)
.expect("Error saving new user");
Self::by_id(&new_id, conn)
}
fn new_user_struct(id: &str, phone: Option<&str>, email: Option<&str>) -> Self {
User {
id: id.into(),
email: email.map(Into::into),
phone: phone.map(Into::into),
created_at: chrono::Local::now().naive_local(),
updated_at: chrono::Local::now().naive_local(),
}
}
}
#[cfg(test)]
mod user_test;
Сначала объявляем импорт и нашу пользовательскую структуру User
.
Каждый из методов User
принимает аргумент SqliteConnection
, поэтому мы можем передать любое соединение (тестовое или другое), не меняя метода.
Для методов запроса Diesel возвращает Result
, который мы преобразуем в Option
с .ok() для подтверждения нашего возвращаемого типа.
Прежде чем создавать новую запись User
в create()
, сначала убеждаемся, что предоставленная электронная почта и/или телефон не существуют в базе данных.
В конце файла объявляем, что тесты объявлены в файле, расположенном в src/db/models/user_test.rs
по соглашению.
Вставляем эти тесты в файл:
use crate::db::{establish_connection, models::User};
#[test]
fn create_user_with_phone_and_email() {
let conn = establish_connection();
let email = Some("[email protected]");
let phone = Some("123456789");
let user = User::create(email, phone, &conn).unwrap();
assert_eq!(user.email.unwrap().as_str(), email.unwrap());
assert_eq!(user.phone.unwrap().as_str(), phone.unwrap());
}
#[test]
fn create_user_with_phone_only() {
let conn = establish_connection();
let email = None;
let phone = Some("123456789");
let user = User::create(email, phone, &conn).unwrap();
assert!(user.email.is_none());
assert_eq!(user.phone.unwrap().as_str(), phone.unwrap());
}
#[test]
fn create_user_with_email_only() {
let conn = establish_connection();
let email = Some("[email protected]");
let phone = None;
let user = User::create(email, phone, &conn).unwrap();
assert_eq!(user.email.unwrap().as_str(), email.unwrap());
assert!(user.phone.is_none());
}
#[test]
fn create_user_with_existing_email() {
let conn = establish_connection();
let email = Some("[email protected]");
let phone = None;
let user = User::create(email, phone, &conn).unwrap();
let existing_user = User::create(email, phone, &conn).unwrap();
assert_eq!(user.id, existing_user.id);
}
#[test]
fn create_user_with_existing_phone() {
let conn = establish_connection();
let email = None;
let phone = Some("123456789");
let user = User::create(email, phone, &conn).unwrap();
let existing_user = User::create(email, phone, &conn).unwrap();
assert_eq!(user.id, existing_user.id);
}
#[test]
fn list_users() {
let conn = establish_connection();
let email = None;
let phone = Some("123456789");
let user = User::create(email, phone, &conn).unwrap();
let existing_user = User::by_phone(&phone.unwrap(), &conn).unwrap();
assert_eq!(user.id, existing_user.id);
}
#[test]
fn get_user_by_email() {
let conn = establish_connection();
let email = Some("[email protected]");
let phone = None;
let user = User::create(email, phone, &conn).unwrap();
let existing_user = User::by_email(&email.unwrap(), &conn).unwrap();
assert_eq!(user.id, existing_user.id);
}
#[test]
fn get_user_by_email() {
let conn = establish_connection();
let email = Some("[email protected]");
let phone = None;
let user = User::create(email, phone, &conn).unwrap();
let existing_user = User::by_email(&email.unwrap(), &conn).unwrap();
assert_eq!(user.id, existing_user.id);
}
#[test]
fn get_user_by_id() {
let conn = establish_connection();
let email = Some("[email protected]");
let phone = Some("123456789");
let user = User::create(email, phone, &conn).unwrap();
let existing_user = User::by_id(&user.id, &conn).unwrap();
assert_eq!(user.id, existing_user.id);
}
Добавляем веб-сервис (коммит)
Обновим нашу зависимость actix-web
, чтобы иметь средства тестирования, недоступные в версии 2.0, и добавим r2d2
для организации набора соединений:
[dependencies]
# ...
actix-web = "3.0.0-alpha.1"
diesel = { version = "1.4.4", features = ["sqlite", "uuidv07", "r2d2", "chrono"] }
r2d2 = "0.8.8"
r2d2-diesel = "1.0.0"
Теперь выполним рефакторинг src/db.rs
для использования набора соединений:
// src/db.rs
// ...
use diesel::sqlite::SqliteConnection;
use r2d2_diesel::ConnectionManager;
use r2d2::Pool;
embed_migrations!();
pub type DbPool = Pool<ConnectionManager<SqliteConnection>>;
pub fn run_migrations(conn: &SqliteConnection) {
let _ = diesel_migrations::run_pending_migrations(&*conn);
}
pub fn establish_connection() -> DbPool {
if cfg!(test) {
let manager = ConnectionManager::<SqliteConnection>::new(":memory:");
let pool = r2d2::Pool::builder().build(manager).expect("Failed to create DB pool.");
run_migrations(&pool.get().unwrap()); pool
} else {
dotenv().ok();
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let manager = ConnectionManager::<SqliteConnection>::new(&database_url);
r2d2::Pool::builder().build(manager).expect("Failed to create DB pool.")
}
}
Кроме того, отделим создание набора соединений для тестов от запуска миграций.
В каждом из наших тестов моделей мы меняем способ получения соединений с
let conn = establish_connection();
на
let conn = establish_connection().get().unwrap();
Создадим сервисный модуль src/services.rs
и вставим:
// src/services.rs
pub mod user;
#[cfg(test)]
mod user_test;
Затем создадим сервисный файл пользователя src/services/user.rs
и вставим:
// src/services/user.rs
use actix_web::{HttpResponse, web};
use serde::{Serialize, Deserialize};
use crate::db::{DbPool, models::User};
#[derive(Serialize, Deserialize)]
pub struct UserForm {
email: Option<String>,
phone: Option<String>,
}
pub fn create(user_form: web::Json<UserForm>, pool: web::Data<DbPool>) -> HttpResponse {
let conn = pool.get().unwrap();
match User::create(user_form.email.as_deref(), user_form.phone.as_deref(), &conn) {
Some(user) => HttpResponse::Ok().json(user),
_ => HttpResponse::InternalServerError().json("Could not create user")
}
}
pub fn index(pool: web::Data<DbPool>) -> HttpResponse {
let conn = pool.get().unwrap();
HttpResponse::Ok().json(User::list(&conn))
}
pub fn get(id: web::Path<String>, pool: web::Data<DbPool>) -> HttpResponse {
let conn = pool.get().unwrap();
match User::by_id(&id, &conn) {
Some(user) => HttpResponse::Ok().json(user),
_ => HttpResponse::NotFound().json("Not Found")
}
}
pub fn init_routes(cfg: &mut web::ServiceConfig) {
/*
* index: curl -i -X GET -H "Content-Type: application/json" http://localhost:5000/users
* get: curl -i -X GET -H "Content-Type: application/json" http://localhost:5000/users/<id>
* post: curl -i -X POST -H "Content-Type: application/json" -d '{"email":"xxx", "phone": "yyy"}' http://localhost:5000/users
*/
cfg.service(
web::resource("/users")
.route(web::post().to(create))
.route(web::get().to(index))
)
.service(
web::scope("/users")
.route("/{id}", web::get().to(get)),
);
}
create()
, index()
и get()
создают, выводят списком и получают пользователя по id. init_routes()
добавляет наши маршруты на веб-сервер.
Затем обновим src/main.rs
, чтобы запустить наш веб-сервер:
// src/main.rs
// ...
#[macro_use]
extern crate serde_json;
extern crate r2d2_diesel;
// ...
mod services;
#[actix_rt::main]
async fn main() -> std::io::Result<()> {
use actix_web::{App, HttpServer, web::JsonConfig};
let conn_pool = db::establish_connection();
HttpServer::new(move || {
App::new()
.data(conn_pool.clone())
.data(JsonConfig::default().limit(4096))
.configure(services::user::init_routes)
})
.bind("0.0.0.0:5000")?
.run()
.await
}
С этим обновлением наш сервис готов принимать и обслуживать запросы.
Теперь добавим тесты, чтобы убедиться в том, что сервис делает то, для чего он был создан. В src/services/user_test.rs
вставим:
// src/services/user_test.rs
use actix_web::{
App,
test::{read_body_json, read_body, init_service, TestRequest}
};
use crate::{db::{models::User, establish_connection}, services::user::init_routes};
#[actix_rt::test]
async fn create_user_from_api() {
let test_email = "[email protected]";
let test_phone = "123456789";
let request_body = json!({ "email": test_email, "phone": test_phone });
let conn_pool = establish_connection();
let mut app = init_service(App::new().data(conn_pool.clone()).configure(init_routes)).await;
let resp = TestRequest::post()
.uri("/users")
.set_json(&request_body)
.send_request(&mut app)
.await;
assert!(resp.status().is_success(), "Failed to create user");
let user: User = read_body_json(resp).await;
assert_eq!(user.email.unwrap(), test_email);
assert_eq!(user.phone.unwrap(), test_phone);
}
let user: User = read_body_json(resp).await;
assert_eq!(user.email.unwrap(), test_email);
assert_eq!(user.phone.unwrap(), test_phone);
}
#[actix_rt::test]
async fn get_user_from_api_by_id() {
let test_email = "[email protected]";
let test_phone = "123456789";
let request_body = json!({ "email": test_email, "phone": test_phone });
let conn_pool = establish_connection();
let mut app = init_service(App::new().data(conn_pool.clone()).configure(init_routes)).await;
let create_resp = TestRequest::post()
.uri("/users")
.set_json(&request_body)
.send_request(&mut app)
.await;
assert!(create_resp.status().is_success(), "Failed to create user");
let created_user: User = read_body_json(create_resp).await;
println!("/users/{}", created_user.id);
let resp = TestRequest::get()
.uri(format!("/users/{}", created_user.id).as_str())
.send_request(&mut app)
.await;
assert!(resp.status().is_success(), "Failed to get user");
let retrieved_user: User = read_body_json(resp).await;
assert_eq!(created_user.id, retrieved_user.id);
}
let retrieved_user: User = read_body_json(resp).await;
assert_eq!(created_user.id, retrieved_user.id);
}
#[actix_rt::test]
async fn list_users_from_api() {
let test_email = "[email protected]";
let test_phone = "123456789";
let request_body = json!({ "email": test_email, "phone": test_phone });
let conn_pool = establish_connection();
let mut app = init_service(App::new().data(conn_pool.clone()).configure(init_routes)).await;
let mut list_resp = TestRequest::get().uri("/users").send_request(&mut app).await;
assert!(list_resp.status().is_success(), "Failed to list users");
let mut body = read_body(list_resp).await;
let mut retrieved_users: Vec<User> = serde_json::from_slice::<Vec<User>>(&body).unwrap();
assert_eq!(retrieved_users.len(), 0);
let create_resp = TestRequest::post()
.uri("/users")
.set_json(&request_body)
.send_request(&mut app)
.await;
assert!(create_resp.status().is_success(), "Failed to create user");
list_resp = TestRequest::get().uri("/users").send_request(&mut app).await;
assert!(list_resp.status().is_success(), "Failed to list users");
body = read_body(list_resp).await;
retrieved_users = serde_json::from_slice::<Vec<User>>(&body).unwrap();
assert_eq!(retrieved_users.len(), 1);
}
Полный код можно найти здесь.
Читайте также:
- Как использовать Flutter с SQLite
- NoSQL убивает SQL?
- Кросс-компиляция программ Rust для запуска на маршрутизаторе
Перевод статьи Uk Chukundah: Using SQLite with Rust and Actix Web (with Tests)