はじめに
この投稿は、学習の備忘録として書いています。RailsアプリをRust(axumとsqlx)へ移植しているとき、Railsのモデルにまとまっていた行・検証・保存などが、sqlxを選んだ構成ではどこに分かれたかを整理します。行と型をモデル寄りにつなぐORMのクレートもありますが、今回はSQLを書くsqlxを使いました。Rustは未学習の状態から着手しており、Cursorを使って移植しています。動作を確認したバージョンはRust 1.99.1、sqlx 0.9.0、axum 0.8.9です(DBはMySQL)。
1. Railsのモデルにまとまっていたもの
Railsアプリでは、次のような実装を主にモデルに書いていました。
- テーブルの1行に対応する属性
-
validatesなどの入力チェック -
has_manyなどの関連付け -
accepts_nested_attributes_forなどのネストしたパラメータ -
saveやコールバック
sqlxを選んだ構成では、これらは1つのモデルクラスにはまとまっていません。
2. 行のかたちはstruct、取得はFromRowで載せる
Railsのモデル全体が、そのまま1つのstructになるわけではありません。テーブル定義(DDL)そのものでもありません。今回分けたのは、まず1行の属性のかたちです。それをRustではstructにしました。検証・保存・関連付けは、このあとの節のとおり別の関数側です。
#[derive(Debug)]
struct Item {
id: i64,
name: String,
}
DBから行を取るとき、fetch_optionalなどは「行がある/無い」を返します。行の中身をItem { ... }という値に組み立てるのがFromRowとquery_asです。
use sqlx::FromRow;
#[derive(Debug, FromRow)]
struct Item {
id: i64,
name: String,
}
async fn find_item(pool: &sqlx::MySqlPool, id: i64) -> Result<Option<Item>, sqlx::Error> {
sqlx::query_as::<_, Item>("SELECT id, name FROM items WHERE id = ?")
.bind(id)
.fetch_optional(pool)
.await
}
FromRowを使わない場合は、先に行を取ってから、列を手でフィールドへ入れます。
async fn find_item(pool: &sqlx::MySqlPool, id: i64) -> Result<Option<Item>, sqlx::Error> {
let row = sqlx::query("SELECT id, name FROM items WHERE id = ?")
.bind(id)
.fetch_optional(pool)
.await?;
match row {
Some(row) => Ok(Some(Item {
id: row.try_get("id")?,
name: row.try_get("name")?,
})),
None => Ok(None),
}
}
3. バリデーションはモデルクラスではなく関数側
入力チェックは、行用のstructのメソッドというより、入力を見る関数に書きました。
struct ItemInput {
name: String,
}
fn validate(input: &ItemInput) -> Vec<String> {
let mut errors = Vec::new();
if input.name.trim().is_empty() {
errors.push("名称を入力してください".into());
}
errors
}
空ならエラー文字列が付く、という形です。保存の直前にこの関数を呼び、問題が無ければINSERT/UPDATE側の関数へ進みます。
4. ネストしたパラメータはキーを指定して取る
Railsのaccepts_nested_attributes_forに相当する仕組みは、この構成にはありません。ネストしたフォームのキーを指定して値を取り、入力用の型に載せました。
// Railsのネストしたフォームでよく見るキーの例。
// Item が has_many :details のとき、details の note は次の形になりやすい。
// item[details_attributes][0][note]
fn note_from_form(form: &std::collections::HashMap<String, String>) -> String {
form
.get("item[details_attributes][0][note]")
.cloned() // getの結果は参照なので、所有するStringにする
.unwrap_or_default()
}
ネスト機能一式を作り直したのではなく、フォームのキーを自分で指定して値を取り、ItemInputなどに載せた、という分け方です。
5. 保存と関連付け
モデルにあったsaveもhas_manyも、この構成にはありません。代わりにクエリ用の関数とSQLを書きます。
保存は、INSERTを書く関数を呼びます。次は新規INSERTの例で、入力はname、採番されたidを返します。
async fn insert_item(pool: &sqlx::MySqlPool, name: &str) -> Result<u64, sqlx::Error> {
let result = sqlx::query("INSERT INTO items (name) VALUES (?)")
.bind(name)
.execute(pool)
.await?;
Ok(result.last_insert_id())
}
関連付けは、has_manyを宣言したときに使えるメソッドはありません。Itemがhas_many :details相当のとき、親のIDでDetail一覧を取るなら、例えば次のように別クエリを書きます。
use sqlx::FromRow;
#[derive(Debug, FromRow)]
struct Detail {
id: i64,
note: String,
}
async fn details_of_item(
pool: &sqlx::MySqlPool,
item_id: i64,
) -> Result<Vec<Detail>, sqlx::Error> {
sqlx::query_as::<_, Detail>("SELECT id, note FROM details WHERE item_id = ?")
.bind(item_id)
.fetch_all(pool)
.await
}
before_saveのようなコールバックもありません。処理は関数を呼ぶ順番で書きました。
まとめ
- sqlxを選んだ構成では、行のかたちは
struct(取得時はFromRowとquery_asで値に組み立てる)、検証は入力を見る関数、ネストはフォームのキーを指定して取る、保存と関連付けはクエリ用の関数とSQL、に分かれました。