コンテンツにスキップ

Rustのデータベース:sqlx・Diesel・SeaORMでSQLiteとPostgres

遅かれ早かれ、プログラムはものを覚えておく必要が出てきます。ユーザー、注文、ハイスコア、ToDoなどです。プログラムが止まると、VecやHashMapの中身は消えてしまいます。データベースはデータをディスクに安全に保存し、複数のプログラムで共有できるようにし、「未完了のタスクを新しい順に全部見せて」のような問いにとても速く答えてくれます。

Rustには、とても良いデータベース用のクレートがあります。このページでは、まずクレートを1つ選び、それから実際のコードを書きます。最初はふつうのRustで書く小さな「リポジトリ」(このページからPlaygroundで動かせます)、次にsqlxでSQLite、Postgres、マイグレーション、そしてDieselとSeaORMを駆け足で紹介します。データベースのクレートはPlaygroundでは使えないので、それらの例は自分のパソコンで cargo run して試してください。どの例も実際にビルドして実行したもので、載せている実行結果は本物です。

  • Web APIのデータ層。 プロジェクト:Web APIを作るのToDoサーバーのデータを、メモリではなくPostgresに保存できます。
  • 大きくて忙しいWebサイト。 すべてのRustクレートを公開しているサイトcrates.ioは、Dieselを通してPostgresにデータを保存しています。オープンソースのコミュニティプラットフォームLemmyも同じです。
  • デスクトップやコマンドラインのツール。 ローカルのSQLiteファイルを使って、メモアプリ、家計簿、キャッシュなどが作れます。
  • バックグラウンドワーカーやデータ処理のジョブ。 行を読み、処理して、結果を書き戻します。

難しいところもあります。Rustには、.NETのEntity FrameworkやRailsのActiveRecordのような「公式」の定番セットが1つではないので、いくつかある良いクレートの中から選ぶことになります。データベースのクレートの最初のビルドには1〜2分かかります。そして、コンパイル時にSQLをチェックする機能(とても便利です)を使うには、少し準備が必要です。

クレート 同期か非同期か クエリをコンパイル時にチェック? ORM? こんなときに
rusqlite 同期 しない いいえ。ふつうのSQL SQLiteのファイル1つを使う小さなツールやデスクトップアプリ。asyncは不要。
sqlx 非同期 する。query! マクロを使えば(任意) いいえ。ふつうのSQL SQLを書くのが好きで、asyncを使いたいとき。SQLite、Postgres、MySQLに対応。Webアプリではふつうまずこれ。
Diesel 同期(diesel-async で非同期も可) する。クエリビルダー全体が型チェックされる ORM兼クエリビルダー すべてのクエリをコンパイラにチェックしてほしくて、独自のクエリの書き方を覚えるのも苦にならないとき。とても成熟している。
SeaORM 非同期(sea-orm-sync もある) 型はする。SQLはしない(実行時にチェック) はい。本格的なORM Entity Frameworkのように、エンティティ、リレーション、「このオブジェクトを保存」を使いたいとき。sqlxの上に作られている。

4つとも成熟していて、広く使われています。迷ったら、sqlxから始めましょう。ふつうのSQLを書くので、覚えたことはどの言語でも役に立ちます。C#から来た人は、C#開発者のためのRustのページで、Entity Framework、Dapper、ADO.NETがこれらのどのクレートにあたるのかを確認できます。

まずは形から:ふつうのRustで書くリポジトリ

Section titled “まずは形から:ふつうのRustで書くリポジトリ”

データベースを使う前に、ほとんどのアプリで使われているパターンを見てみましょう。リポジトリ(repository)です。データの保存と読み込みの方法を知っている、ただ1つの場所です。プログラムのほかの部分は 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("Buy milk");
repo.add("Learn sqlx");
repo.add("Walk the dog");
repo.mark_done(1).unwrap();
print_open(&repo);
println!("{:?}", repo.mark_done(99));
}

実行結果:

[ ] 2: Learn sqlx
[ ] 3: Walk the dog
Err("ID 99 のタスクはありません")
▶ 実際に試してみよう

ここでデータベースが何をしてくれるのかを見てみましょう。HashMap はテーブル(table)に、それぞれの Task は行(row)に、next_id は自動で番号が振られる id 列になります。list_open は WHERE done = FALSE ORDER BY id を使ったクエリになります。(タスクのタイトルを英語のままにしているのは、このあとのsqlxの例と同じデータにするためです。)メモリ版はあとでも役に立ちます。テストのための速い偽物(フェイク)になるからです。

sqlxとSQLite:はじめての本物のデータベース

Section titled “sqlxとSQLite:はじめての本物のデータベース”

SQLiteは、1つのファイルの中(またはメモリの中だけ)に収まったデータベースです。サーバーをインストールする必要がないので、学習にも、テストにも、多くの本物のアプリにもぴったりです。プロジェクトを作って、sqlxとtokioを追加しましょう。

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

sqlite フィーチャーはSQLiteをプログラムの中に組み込むので、ほかに何もインストールする必要はありません。sqlxは非同期なので、tokioが必要です。では、src/main.rs を次の内容に置き換えてください。

use sqlx::sqlite::SqlitePool;
#[derive(Debug, sqlx::FromRow)]
struct Task {
id: i64,
title: String,
done: bool,
}
#[tokio::main]
async fn main() -> Result<(), sqlx::Error> {
// メモリ上のデータベース。学習にぴったりで、プログラムが終わると消える。
let pool = SqlitePool::connect("sqlite::memory:").await?;
sqlx::query(
"CREATE TABLE tasks (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
done BOOLEAN NOT NULL DEFAULT FALSE
)",
)
.execute(&pool)
.await?;
for title in ["Buy milk", "Learn sqlx", "Walk the dog"] {
let result = sqlx::query("INSERT INTO tasks (title) VALUES (?)")
.bind(title)
.execute(&pool)
.await?;
println!("Inserted task {}", result.last_insert_rowid());
}
sqlx::query("UPDATE tasks SET done = TRUE WHERE id = ?")
.bind(1_i64)
.execute(&pool)
.await?;
let tasks: Vec<Task> = sqlx::query_as("SELECT id, title, done FROM tasks ORDER BY id")
.fetch_all(&pool)
.await?;
for task in &tasks {
let mark = if task.done { "x" } else { " " };
println!("[{mark}] {}: {}", task.id, task.title);
}
let open: Option<Task> = sqlx::query_as("SELECT id, title, done FROM tasks WHERE id = ?")
.bind(42_i64)
.fetch_optional(&pool)
.await?;
println!("Task 42: {open:?}");
Ok(())
}

cargo run で実行します。

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

一歩ずつ見ていきましょう。

  • sqlx::query("...") がSQLを持ちます。それぞれの ? はプレースホルダー(placeholder)で、.bind(value) が順番に値を埋めます。
  • .execute(&pool) は、行を返さない文(CREATE、INSERT、UPDATE、DELETE)を実行します。結果から rows_affected() がわかり、SQLiteなら last_insert_rowid() もわかります。
  • #[derive(sqlx::FromRow)] を付けると、sqlxが各行を Task に変換できるようになります。列の名前はフィールドの名前と一致していなければなりません。
  • query_as と fetch_all で Vec<Task> が得られます。行がちょうど1つでなければならないときは fetch_one を、行がないかもしれないときは fetch_optional を使います。見つからなかった行が、クラッシュではなく None、つまりOptionになっていることに注目してください。
  • どの呼び出しもResultを返すので、? でデータベースのエラーを main まで渡せます。

プログラムが終わったあともデータを残したいときは、ファイルを使います。SqlitePool::connect("sqlite:todos.db?mode=rwc") です。mode=rwc は「読み込み、書き込み、ファイルがなければ作成」という意味です。

コンパイル時にチェックされるクエリ。 sqlxには sqlx::query_as!(Task, "SELECT id, title, done FROM tasks WHERE done = ?", done) のようなマクロもあります。ビルドのときに、このマクロが開発用のデータベース(DATABASE_URL で指定)に接続して、SQLをチェックします。titel のようなタイプミスがあれば、ユーザーの目に触れる前に no such column: titel でビルドが止まります。ビルド中にテーブルの入ったデータベースが必要になるので、最初はふつうの query 関数から始めて、マイグレーション(後述)を用意してからマクロを加えましょう。

Postgres(PostgreSQL)は、Webアプリでいちばん人気のある選択肢です。サーバーとして動くので、動いているサーバーが1つ必要です。postgresql.org(英語)からインストールするか、クラウドのデータベースを使うか、Dockerで起動します。

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

コードはほとんど変わりません。postgres フィーチャーを有効にします(クラウドのデータベースはたいてい暗号化が必須なので、tls-rustls のようなTLSのフィーチャーも追加します)。

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

そして接続文字列(connection string)で接続します。データベースがどこにあり、どうログインするのかを表すURLです。

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

パスワードは決してコードに書かないでください。URLは環境変数 DATABASE_URL に入れて、起動時に読み込みます(環境変数を参照)。

use sqlx::postgres::PgPoolOptions;
use std::time::Duration;
let url = std::env::var("DATABASE_URL").expect("set DATABASE_URL first");
let pool = PgPoolOptions::new()
.max_connections(5)
.acquire_timeout(Duration::from_secs(3))
.connect(&url)
.await?;
// Postgres のプレースホルダーには番号が付く:$1, $2, ...
let tasks: Vec<Task> = sqlx::query_as("SELECT id, title, done FROM tasks WHERE done = $1")
.bind(false)
.fetch_all(&pool)
.await?;

SQLiteとの違いで覚えておきたいことが2つあります。Postgresのプレースホルダーは ? ではなく $1、$2 です。そして、自動で番号が振られる id はふつう BIGINT GENERATED ALWAYS AS IDENTITY(または BIGSERIAL)で、i64 に対応します。パスワードに @ や / のような文字が入っているときは、URLの中でパーセントエンコードします(@ は %40 になります)。sqlx-cliなどのツールは、プロジェクトのフォルダーにある .env ファイルからも DATABASE_URL を読み込みます。.env は .gitignore に加えて、GitHubに上がらないようにしましょう。

テーブルは時間とともに変わっていきます。こちらに新しい列、あちらに新しいテーブル、という具合です。マイグレーション(migration)は、それぞれの変更を記述した、番号付きのSQLファイルです。どのデータベース(あなたのもの、チームメイトのもの、本番環境のもの)でも同じファイルを同じ順番で実行し、データベースはどれをもう実行したのかを覚えています。まず、コマンドラインツールを一度だけインストールします。

Terminal window
# all databases
cargo install sqlx-cli
# or only what you need, for a faster build
cargo install sqlx-cli --no-default-features --features sqlite,rustls

上の1つめはすべてのデータベースに対応した版、2つめは必要なものだけを入れてビルドを速くする版です。それから、プロジェクトのフォルダーで次を実行します。

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

(番号はあなたの環境では違うものになります。日付と時刻だからです。)このファイルに変更の内容を書きます。

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

そして適用します。

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

もう一度実行しても何も起きません。そのマイグレーションはもう適用済みだからです。sqlx migrate info で、適用済みのものと未適用のものの一覧が見られます。どこかで一度実行したマイグレーションは決して書き換えず、代わりに新しいマイグレーションを追加しましょう。

プログラムの起動時にマイグレーションを適用させることもできます。migrate! マクロは、./migrations のファイルをバイナリに埋め込みます。

let pool = SqlitePool::connect("sqlite::memory:").await?;
sqlx::migrate!().run(&pool).await?; // このデータベースがまだ実行していないマイグレーションだけを実行する

Dieselは別の道を選んでいます。SQLの文字列を書く代わりに、Rustのメソッドでクエリを組み立て、コンパイラがそれをテーブルの定義と照らし合わせてチェックします。たとえば done は bool の列なので、tasks::done.eq(true) はコンパイルできますが、tasks::done.eq("yes") はコンパイルエラーになります。Dieselは同期ですが、ツールや多くのサーバーではそれで十分です。

[dependencies]
diesel = { version = "2.3", features = ["sqlite"] }
# Builds SQLite into your program. Only needed if you don't have SQLite installed:
libsqlite3-sys = { version = "0.38", features = ["bundled"] }

libsqlite3-sys の行はSQLiteをプログラムに組み込むためのもので、SQLiteがインストールされていないときだけ必要です。

use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;
// ふつうは `diesel migration run` が src/schema.rs に自動で生成してくれる。
diesel::table! {
tasks (id) {
id -> Integer,
title -> Text,
done -> Bool,
}
}
#[derive(Queryable, Selectable, Debug)]
#[diesel(table_name = tasks)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
struct Task {
id: i32,
title: String,
done: bool,
}
#[derive(Insertable)]
#[diesel(table_name = tasks)]
struct NewTask<'a> {
title: &'a str,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut conn = SqliteConnection::establish(":memory:")?;
// ふつうはマイグレーションがこれをする。
diesel::sql_query(
"CREATE TABLE tasks (
id INTEGER PRIMARY KEY NOT NULL,
title TEXT NOT NULL,
done BOOLEAN NOT NULL DEFAULT FALSE
)",
)
.execute(&mut conn)?;
let new_tasks = [NewTask { title: "Buy milk" }, NewTask { title: "Learn Diesel" }];
diesel::insert_into(tasks::table)
.values(&new_tasks[..])
.execute(&mut conn)?;
diesel::update(tasks::table.find(1))
.set(tasks::done.eq(true))
.execute(&mut conn)?;
let all: Vec<Task> = tasks::table
.order(tasks::id)
.select(Task::as_select())
.load(&mut conn)?;
for task in &all {
println!("{} {} done={}", task.id, task.title, task.done);
}
let open: i64 = tasks::table
.filter(tasks::done.eq(false))
.count()
.get_result(&mut conn)?;
println!("{open} task(s) still open");
Ok(())
}
1 Buy milk done=true
2 Learn Diesel done=false
1 task(s) still open

実際のプロジェクトでは、diesel_cli をインストールし(cargo install diesel_cli --no-default-features --features sqlite)、diesel setup、diesel migration generate create_tasks、diesel migration run を実行します。CLIが table! のコードを src/schema.rs に書いてくれるので、いつもデータベースと一致した状態に保てます。全体の流れはGetting Startedガイド(英語)で解説されています。

SeaORMは非同期のORMです。それぞれのテーブルをエンティティ(entity、属性を付けた構造体)として記述し、SQLではなくオブジェクトを扱います。使い心地はEntity Frameworkに近いです。バージョン2.0では、エンティティからテーブルを作ることもできます。

[dependencies]
sea-orm = { version = "2", features = ["sqlx-sqlite", "runtime-tokio", "entity-registry", "schema-sync"] }
tokio = { version = "1", features = ["full"] }
mod task {
use sea_orm::entity::prelude::*;
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "task")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub title: String,
pub done: bool,
}
impl ActiveModelBehavior for ActiveModel {}
}
use sea_orm::{entity::*, query::*, Database, DbErr};
#[tokio::main]
async fn main() -> Result<(), DbErr> {
let db = &Database::connect("sqlite::memory:").await?;
// このクレートのすべてのエンティティのテーブルを作る。"seaorm_demo" は
// Cargo.toml のパッケージ名で、- を _ に変えたもの。
db.get_schema_registry("seaorm_demo::*").sync(db).await?;
for title in ["Buy milk", "Learn SeaORM"] {
task::ActiveModel {
title: Set(title.to_owned()),
done: Set(false),
..Default::default()
}
.insert(db)
.await?;
}
let mut milk = task::Entity::find_by_id(1).one(db).await?.unwrap().into_active_model();
milk.done = Set(true);
milk.update(db).await?;
let open = task::Entity::find()
.filter(task::Column::Done.eq(false))
.all(db)
.await?;
for t in &open {
println!("open: {} {}", t.id, t.title);
}
Ok(())
}
open: 2 Learn SeaORM

Model は読み取った行です。ActiveModel は変更中の行で、Set(...) で保存するフィールドに印を付けます。..Default::default() は id をデータベースに埋めてもらうために空けておきます。大きなプロジェクトでは、sea-orm-cli が既存のデータベースからエンティティを生成し、マイグレーションも管理してくれます。SeaORMのドキュメント(英語)を見てください。

SQLインジェクション:必ずバインドパラメータを使う

Section titled “SQLインジェクション:必ずバインドパラメータを使う”

これがこのページでいちばん大事なルールです。ユーザーの入力を format! でSQLの文字列に貼り付けて、SQLを組み立ててはいけません。理由を、ふつうのRustで見てみましょう(データベースは要りません)。

// データベースが受け取るもの。2つの違うやり方で組み立てる。
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: {input}");
println!(" pasted: {}", pasted_query(input));
let (sql, params) = bound_query(input);
println!(" bound: {sql} params: {params:?}");
}
}

実行結果:

input: ana
pasted: SELECT * FROM users WHERE name = 'ana'
bound: SELECT * FROM users WHERE name = ? params: ["ana"]
input: x' OR '1'='1
pasted: SELECT * FROM users WHERE name = 'x' OR '1'='1'
bound: SELECT * FROM users WHERE name = ? params: ["x' OR '1'='1"]
▶ 実際に試してみよう

pasted(貼り付け)は format! で入力をそのまま埋め込んだもの、bound(バインド)はSQLと値を別々に渡したものです。貼り付けた場合、入力の中の引用符が文字列を途中で終わらせてしまい、OR '1'='1' がSQLの一部になってしまいます。この条件はいつも真なので、クエリはすべてのユーザーを返します。攻撃者は同じ手口で、ほかの人のデータを読んだり、テーブルを削除したりできます。これをSQLインジェクション(SQL injection)と呼び、Webでいちばんよくあるセキュリティのバグの1つです。

バインドパラメータを使えば、SQLの文は決して変わりません。入力は別に送られ、データベースはそれを値としてしか扱いません。sqlxで両方のやり方を、ユーザー ana と root が入った本物のSQLiteのテーブルに対して実行してみました。

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文字列は、インジェクションの可能性がないか確認(監査)すべきです」というエラーで、リテラルのSQLとバインドパラメータ、または QueryBuilder を使うようすすめています。では、AssertSqlSafe はどんなときに使うのでしょうか。実行時にどうしてもSQLを組み立てなければならないとき、たとえばユーザーが並べ替えの列を選べるときだけです。まず、その値を自分で決めた許可リストと照らし合わせてから、文字列を包んで「確認済みです」とsqlxに伝えます。値については、いつも .bind() を使いましょう。DieselとSeaORMは、生のSQL文字列ではなくクエリビルダーを使っている限り、値を自動でバインドしてくれます。

データベースへの接続を開くのは時間がかかります。ネットワークの往復、ログイン、いくつかの準備があるからです。プール(pool)は、いくつかの接続を一度だけ開いておき、それを貸し出します。コードは1つのクエリのあいだだけ接続を借りて、すぐに返します。上の例がどれも、1つの接続ではなく &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個のタスクが1つのプールを共有する。プールのクローンは安い。ただのハンドルだから。
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回の INSERT がすべて終わるのを待つ
}

20個のタスクは同時に動きますが、データベースと話すのはどの瞬間でも最大4つです。残りは、接続が空くのをほんの少し待ちます。そのあと(新しい visits.db で)行を数えると、次のようになります。

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

目安になるルールをいくつか挙げます。

  • プログラムの起動時にプールを1つ作り、それを共有します。axumのアプリなら、アプリの状態(state)に入れます(Web APIのプロジェクトを参照)。
  • リクエストのたびに新しいプールを作ってはいけません。それではせっかくの利点が台無しです。
  • max_connections は、データベースが許す数よりかなり少なくしておきます。Postgresのサーバーはデフォルトで100接続まで許しますが、アプリのコピーがそれぞれ自分のプールを持つことを忘れないでください。
  • acquire_timeout を設定しましょう。そうすれば、すべての接続が使用中のとき、リクエストは永遠に待つのではなく、わかりやすいエラーで失敗します。

メモリ上のリポジトリの考え方を使って、search メソッドを追加してください。タイトルにある単語を含むタスクを、大文字と小文字を区別せずに探し、IDの順に並べて返します。「milk」でテストしましょう。ヒント: 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("Buy milk");
repo.add("Walk the dog");
repo.add("Milk the cow");
for task in repo.search("milk") {
println!("{}: {}", task.id, task.title);
}
println!("「cat」の検索結果: {}件", repo.search("cat").len());
}

実行結果:

1: Buy milk
3: Milk the cow
「cat」の検索結果: 0件
▶ 実際に試してみよう

保存先が HashMap から Vec に変わりましたが、トレイトを呼び出すコードはそれを知る必要がありません。SQLなら、同じ検索は WHERE title LIKE ? と .bind(format!("%{word}%")) で書けます。% 記号はSQLの文ではなく、バインドする値のほうに入れることに注意してください。

2. sqlxで本物のリポジトリ(自分のパソコンで)

Section titled “2. sqlxで本物のリポジトリ(自分のパソコンで)”

SqlitePool を持つ構造体 SqliteTaskRepo を書いてください。async メソッドを3つ持たせます。add(title) は新しいIDを返し、mark_done(id) は行が変わったら true を返し、list_open() は未完了のタスクを返します。main でテーブルを作り、タスクを3つ追加し、タスク1とタスク99を完了にしてみましょう。ヒント: execute の結果の rows_affected() で、UPDATE が何行を変えたのかがわかります。

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

メモリ版と同じ形で、async になって Result を返すところだけが違います。本物のデータベースは失敗することがある(ディスクがいっぱい、ネットワークが切れた)からです。次のステップとして、Web APIのプロジェクトの Vec を、このリポジトリに置き換えてみましょう。

  • シンプルな同期のSQLiteならrusqlite、非同期でふつうのSQLを書くならsqlx(ふつうはまずこれ)、コンパイル時にチェックされるクエリビルダーならDiesel、Entity Frameworkのような非同期のORMならSeaORM。
  • リポジトリは、データがどこにあるのかをいくつかのメソッドの後ろに隠します。最初は 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)で行います。
  • ユーザーの入力は必ずバインドし、決して format! でSQLに貼り付けないこと。プールは起動時に1つだけ作って共有します。

理解度チェック

1. ふつうのSQLを書き、asyncを使い、コンパイル時にチェックされるクエリも選べるようにしたいです。いちばん合うクレートはどれですか?

2. クエリが0行か1行を返すかもしれないとき、sqlxのどのメソッドを使うべきですか?

3. format!("... WHERE name = '{name}'") が危険なのはなぜですか?

4. 接続プールは何をしますか?

スコア: 0 / 4

  • docs.rsのsqlx(英語):APIリファレンスです。query、query_as、query! マクロ、プール、対応するすべてのデータベースについて載っています。
  • docs.rsのsqlx-cli(英語):CLIのインストール、データベースの作成、マイグレーションの実行と取り消し、query! マクロのためのオフラインモードの使い方がわかります。
  • Getting Started with Diesel(英語):diesel setup から、行の追加・更新・削除までを一歩ずつ説明する公式ガイドです。
  • SeaORM documentation(英語):エンティティ、リレーション、マイグレーション、sea-orm-cli のコード生成について載っています。
  • OWASP SQL Injection Prevention Cheat Sheet(英語):SQLインジェクションのしくみと、パラメータ化クエリがそれをどう防ぐのかを、言語を問わず解説しています。