Rustのデータベース:sqlx・Diesel・SeaORMでSQLiteとPostgres
遅かれ早かれ、プログラムはものを覚えておく必要が出てきます。ユーザー、注文、ハイスコア、ToDoなどです。プログラムが止まると、VecやHashMapの中身は消えてしまいます。データベースはデータをディスクに安全に保存し、複数のプログラムで共有できるようにし、「未完了のタスクを新しい順に全部見せて」のような問いにとても速く答えてくれます。
Rustには、とても良いデータベース用のクレートがあります。このページでは、まずクレートを1つ選び、それから実際のコードを書きます。最初はふつうのRustで書く小さな「リポジトリ」(このページからPlaygroundで動かせます)、次にsqlxでSQLite、Postgres、マイグレーション、そしてDieselとSeaORMを駆け足で紹介します。データベースのクレートはPlaygroundでは使えないので、それらの例は自分のパソコンで cargo run して試してください。どの例も実際にビルドして実行したもので、載せている実行結果は本物です。
何が作れるのか
Section titled “何が作れるのか”- Web APIのデータ層。 プロジェクト:Web APIを作るのToDoサーバーのデータを、メモリではなくPostgresに保存できます。
- 大きくて忙しいWebサイト。 すべてのRustクレートを公開しているサイトcrates.ioは、Dieselを通してPostgresにデータを保存しています。オープンソースのコミュニティプラットフォームLemmyも同じです。
- デスクトップやコマンドラインのツール。 ローカルのSQLiteファイルを使って、メモアプリ、家計簿、キャッシュなどが作れます。
- バックグラウンドワーカーやデータ処理のジョブ。 行を読み、処理して、結果を書き戻します。
難しいところもあります。Rustには、.NETのEntity FrameworkやRailsのActiveRecordのような「公式」の定番セットが1つではないので、いくつかある良いクレートの中から選ぶことになります。データベースのクレートの最初のビルドには1〜2分かかります。そして、コンパイル時にSQLをチェックする機能(とても便利です)を使うには、少し準備が必要です。
どのクレートを使う?
Section titled “どのクレートを使う?”| クレート | 同期か非同期か | クエリをコンパイル時にチェック? | 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 dogErr("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を追加しましょう。
cargo new todo-dbcd todo-dbcargo add sqlx --features runtime-tokio,sqlitecargo add tokio --features full[dependencies]sqlx = { version = "0.9", features = ["runtime-tokio", "sqlite"] }tokio = { version = "1", features = ["full"] }sqlite フィーチャーはSQLiteをプログラムの中に組み込むので、ほかに何もインストールする必要はありません。sqlxは非同期なので、tokioが必要です。では、src/main.rs を次の内容に置き換えてください。
use sqlx::sqlite::SqlitePool;
#[derive(Debug, sqlx::FromRow)]struct Task { id: i64, title: String, done: bool,}
#[tokio::main]async fn main() -> Result<(), sqlx::Error> { // メモリ上のデータベース。学習にぴったりで、プログラムが終わると消える。 let pool = SqlitePool::connect("sqlite::memory:").await?;
sqlx::query( "CREATE TABLE tasks ( id INTEGER PRIMARY KEY, title TEXT NOT NULL, done BOOLEAN NOT NULL DEFAULT FALSE )", ) .execute(&pool) .await?;
for title in ["Buy milk", "Learn sqlx", "Walk the dog"] { let result = sqlx::query("INSERT INTO tasks (title) VALUES (?)") .bind(title) .execute(&pool) .await?; println!("Inserted task {}", result.last_insert_rowid()); }
sqlx::query("UPDATE tasks SET done = TRUE WHERE id = ?") .bind(1_i64) .execute(&pool) .await?;
let tasks: Vec<Task> = sqlx::query_as("SELECT id, title, done FROM tasks ORDER BY id") .fetch_all(&pool) .await?;
for task in &tasks { let mark = if task.done { "x" } else { " " }; println!("[{mark}] {}: {}", task.id, task.title); }
let open: Option<Task> = sqlx::query_as("SELECT id, title, done FROM tasks WHERE id = ?") .bind(42_i64) .fetch_optional(&pool) .await?; println!("Task 42: {open:?}");
Ok(())}cargo run で実行します。
Inserted task 1Inserted task 2Inserted task 3[x] 1: Buy milk[ ] 2: Learn sqlx[ ] 3: Walk the dogTask 42: None一歩ずつ見ていきましょう。
sqlx::query("...")がSQLを持ちます。それぞれの?はプレースホルダー(placeholder)で、.bind(value)が順番に値を埋めます。.execute(&pool)は、行を返さない文(CREATE、INSERT、UPDATE、DELETE)を実行します。結果からrows_affected()がわかり、SQLiteならlast_insert_rowid()もわかります。#[derive(sqlx::FromRow)]を付けると、sqlxが各行をTaskに変換できるようになります。列の名前はフィールドの名前と一致していなければなりません。query_asとfetch_allでVec<Task>が得られます。行がちょうど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に接続する
Section titled “Postgresに接続する”Postgres(PostgreSQL)は、Webアプリでいちばん人気のある選択肢です。サーバーとして動くので、動いているサーバーが1つ必要です。postgresql.org(英語)からインストールするか、クラウドのデータベースを使うか、Dockerで起動します。
docker run --name todo-pg -e POSTGRES_PASSWORD=secret -p 5432:5432 -d postgresコードはほとんど変わりません。postgres フィーチャーを有効にします(クラウドのデータベースはたいてい暗号化が必須なので、tls-rustls のようなTLSのフィーチャーも追加します)。
cargo add sqlx --features runtime-tokio,postgres,tls-rustlsそして接続文字列(connection string)で接続します。データベースがどこにあり、どうログインするのかを表すURLです。
postgres://USER:PASSWORD@HOST:PORT/DATABASEpostgres://postgres:secret@localhost:5432/postgresパスワードは決してコードに書かないでください。URLは環境変数 DATABASE_URL に入れて、起動時に読み込みます(環境変数を参照)。
use sqlx::postgres::PgPoolOptions;use std::time::Duration;
let url = std::env::var("DATABASE_URL").expect("set DATABASE_URL first");
let pool = PgPoolOptions::new() .max_connections(5) .acquire_timeout(Duration::from_secs(3)) .connect(&url) .await?;
// Postgres のプレースホルダーには番号が付く:$1, $2, ...let tasks: Vec<Task> = sqlx::query_as("SELECT id, title, done FROM tasks WHERE done = $1") .bind(false) .fetch_all(&pool) .await?;SQLiteとの違いで覚えておきたいことが2つあります。Postgresのプレースホルダーは ? ではなく $1、$2 です。そして、自動で番号が振られる id はふつう BIGINT GENERATED ALWAYS AS IDENTITY(または BIGSERIAL)で、i64 に対応します。パスワードに @ や / のような文字が入っているときは、URLの中でパーセントエンコードします(@ は %40 になります)。sqlx-cliなどのツールは、プロジェクトのフォルダーにある .env ファイルからも DATABASE_URL を読み込みます。.env は .gitignore に加えて、GitHubに上がらないようにしましょう。
sqlx-cliでマイグレーション
Section titled “sqlx-cliでマイグレーション”テーブルは時間とともに変わっていきます。こちらに新しい列、あちらに新しいテーブル、という具合です。マイグレーション(migration)は、それぞれの変更を記述した、番号付きのSQLファイルです。どのデータベース(あなたのもの、チームメイトのもの、本番環境のもの)でも同じファイルを同じ順番で実行し、データベースはどれをもう実行したのかを覚えています。まず、コマンドラインツールを一度だけインストールします。
# all databasescargo install sqlx-cli
# or only what you need, for a faster buildcargo install sqlx-cli --no-default-features --features sqlite,rustls上の1つめはすべてのデータベースに対応した版、2つめは必要なものだけを入れてビルドを速くする版です。それから、プロジェクトのフォルダーで次を実行します。
echo "DATABASE_URL=sqlite:todos.db" > .envsqlx database createsqlx migrate add create_tasksCreating migrations/20261018035858_create_tasks.sql(番号はあなたの環境では違うものになります。日付と時刻だからです。)このファイルに変更の内容を書きます。
CREATE TABLE tasks ( id INTEGER PRIMARY KEY, title TEXT NOT NULL, done BOOLEAN NOT NULL DEFAULT FALSE);そして適用します。
sqlx migrate runApplied 20261018035858/migrate create tasks (1.449995ms)もう一度実行しても何も起きません。そのマイグレーションはもう適用済みだからです。sqlx migrate info で、適用済みのものと未適用のものの一覧が見られます。どこかで一度実行したマイグレーションは決して書き換えず、代わりに新しいマイグレーションを追加しましょう。
プログラムの起動時にマイグレーションを適用させることもできます。migrate! マクロは、./migrations のファイルをバイナリに埋め込みます。
let pool = SqlitePool::connect("sqlite::memory:").await?;sqlx::migrate!().run(&pool).await?; // このデータベースがまだ実行していないマイグレーションだけを実行するDieselをざっくり紹介
Section titled “Dieselをざっくり紹介”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=true2 Learn Diesel done=false1 task(s) still open実際のプロジェクトでは、diesel_cli をインストールし(cargo install diesel_cli --no-default-features --features sqlite)、diesel setup、diesel migration generate create_tasks、diesel migration run を実行します。CLIが table! のコードを src/schema.rs に書いてくれるので、いつもデータベースと一致した状態に保てます。全体の流れはGetting Startedガイド(英語)で解説されています。
SeaORMをざっくり紹介
Section titled “SeaORMをざっくり紹介”SeaORMは非同期のORMです。それぞれのテーブルをエンティティ(entity、属性を付けた構造体)として記述し、SQLではなくオブジェクトを扱います。使い心地はEntity Frameworkに近いです。バージョン2.0では、エンティティからテーブルを作ることもできます。
[dependencies]sea-orm = { version = "2", features = ["sqlx-sqlite", "runtime-tokio", "entity-registry", "schema-sync"] }tokio = { version = "1", features = ["full"] }mod task { use sea_orm::entity::prelude::*;
#[sea_orm::model] #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "task")] pub struct Model { #[sea_orm(primary_key)] pub id: i32, pub title: String, pub done: bool, }
impl ActiveModelBehavior for ActiveModel {}}
use sea_orm::{entity::*, query::*, Database, DbErr};
#[tokio::main]async fn main() -> Result<(), DbErr> { let db = &Database::connect("sqlite::memory:").await?;
// このクレートのすべてのエンティティのテーブルを作る。"seaorm_demo" は // Cargo.toml のパッケージ名で、- を _ に変えたもの。 db.get_schema_registry("seaorm_demo::*").sync(db).await?;
for title in ["Buy milk", "Learn SeaORM"] { task::ActiveModel { title: Set(title.to_owned()), done: Set(false), ..Default::default() } .insert(db) .await?; }
let mut milk = task::Entity::find_by_id(1).one(db).await?.unwrap().into_active_model(); milk.done = Set(true); milk.update(db).await?;
let open = task::Entity::find() .filter(task::Column::Done.eq(false)) .all(db) .await?; for t in &open { println!("open: {} {}", t.id, t.title); } Ok(())}open: 2 Learn SeaORMModel は読み取った行です。ActiveModel は変更中の行で、Set(...) で保存するフィールドに印を付けます。..Default::default() は id をデータベースに埋めてもらうために空けておきます。大きなプロジェクトでは、sea-orm-cli が既存のデータベースからエンティティを生成し、マイグレーションも管理してくれます。SeaORMのドキュメント(英語)を見てください。
SQLインジェクション:必ずバインドパラメータを使う
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を設定しましょう。そうすれば、すべての接続が使用中のとき、リクエストは永遠に待つのではなく、わかりやすいエラーで失敗します。
1. リポジトリを検索する
Section titled “1. リポジトリを検索する”メモリ上のリポジトリの考え方を使って、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 milk3: 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: truemark 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つだけ作って共有します。
理解度チェック
Section titled “理解度チェック”理解度チェック
1. ふつうのSQLを書き、asyncを使い、コンパイル時にチェックされるクエリも選べるようにしたいです。いちばん合うクレートはどれですか?
2. クエリが0行か1行を返すかもしれないとき、sqlxのどのメソッドを使うべきですか?
3. format!("... WHERE name = '{name}'") が危険なのはなぜですか?
4. 接続プールは何をしますか?
スコア: 0 / 4
さらに詳しく
Section titled “さらに詳しく”- 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インジェクションのしくみと、パラメータ化クエリがそれをどう防ぐのかを、言語を問わず解説しています。