1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Rust】ActiveRecordにまとまっていたものを、sqlxでは分けて書いた

1
Posted at

はじめに

この投稿は、学習の備忘録として書いています。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、に分かれました。
1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?