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】該当する行が無いとき——sqlxの戻り値と404レスポンス

1
Posted at

はじめに

この投稿は、学習の備忘録として書いています。RailsアプリをRust(axumとsqlx)へ移植している途中、GitHub Copilotのレビューで、存在しないIDを指定した削除の挙動がRailsと違うと指摘を受けました。Railsではfindにより404レスポンスになりますが、移植後はその扱いになっていませんでした。同じ挙動にするには、該当する行が存在しないことを戻り値や404レスポンスに表す処理が必要でした。この投稿では、その書き方を整理します。Rustは未学習の状態から着手しており、Cursorを使って移植しています。動作を確認したバージョンはRust 1.99.1、sqlx 0.9.0、axum 0.8.9です(DBはMySQL)。

1. Railsでは、findがRecordNotFoundをraiseすると404レスポンスになる

移植前のイメージは、次です。

record = Item.find(params[:id]) # 0件なら ActiveRecord::RecordNotFound
record.update!(deleted: true)

該当する行が無いときfindはActiveRecord::RecordNotFoundをraiseし、Railsが404レスポンスを返します。

2. 問題の箇所

同じ削除(ここでは行をDELETEせず、deleted列を1にする論理削除)を、sqlxのUPDATEだけにしていたころは、次の形でした。

async fn soft_delete(pool: &MySqlPool, id: i32) -> Result<(), sqlx::Error> {
    sqlx::query("UPDATE items SET deleted = 1 WHERE id = ?")
        .bind(id)
        .execute(pool)
        .await?;
    Ok(())
}

// リクエスト処理側: 成功しても失敗しても一覧へリダイレクト(404レスポンスを返す分岐なし)

見ていたのは、DB接続失敗などsqlxのErrだけでした。対象の行が存在しないときの扱いは次節のとおりで、Ok(())のまま戻ります。RailsのようにRecordNotFoundを捕捉して404レスポンスにする仕組みも、この構成にはありません。結果として、該当する行が存在しなかったことが戻り値にも404レスポンスにも表れていませんでした。

見落としていたのは、該当する行が存在しないことを自分で戻り値や分岐に書き、404レスポンスへつなぐ処理です。

3. マッチ0件のUPDATEは、MySQLでもsqlxでもエラーにならない

まずMySQLでは、条件に合う行が0件のUPDATEもエラーになりません(影響行数0で成功します)。sqlxのexecuteでは、存在しないidでもOkが返り、rows_affected()は0になります。存在するidなら同じくOkで、rows_affected()は1です。

したがって、Rust側で「executeがErrでない=対象の行があった」とは言えません。

4. 存在しないことを戻り値で表す書き方

RailsのようなRecordNotFound→404レスポンスの流れが無いので、該当する行が存在しないことを自分で戻り値に出し、ハンドラで404レスポンスを返します。書き方は複数あります。ここでは2つ例を挙げます。

4.1 取得してから更新する(fetch_optional)

async fn soft_delete(pool: &MySqlPool, id: i32) -> Result<bool, sqlx::Error> {
    let row = sqlx::query("SELECT id FROM items WHERE id = ?")
        .bind(id)
        .fetch_optional(pool)
        .await?;

    if row.is_none() {
        return Ok(false); // 該当なし
    }

    sqlx::query("UPDATE items SET deleted = 1 WHERE id = ?")
        .bind(id)
        .execute(pool)
        .await?;
    Ok(true)
}

fetch_optionalは、行が0件のときOk(None)を返します。上の例では、そのNoneを見てOk(false)(該当する行が存在しない)にしています。この書き方ではSELECTのあとUPDATEするので、SQLは2回です。

別の取り方としてfetch_oneもあります。行が0件のときfetch_oneはErr(sqlx::Error::RowNotFound)を返します。そのエラーをハンドラで404レスポンスに対応づける書き方もあります。

4.2 更新の影響行数で判定する(rows_affected)

今回はこの形にしました。

async fn soft_delete(pool: &MySqlPool, id: i32) -> Result<bool, sqlx::Error> {
    let result = sqlx::query("UPDATE items SET deleted = 1 WHERE id = ?")
        .bind(id)
        .execute(pool)
        .await?;

    Ok(result.rows_affected() > 0)
}
  • Ok(false) … マッチ0件(該当する行が存在しない)
  • Ok(true) … 1行以上更新した
  • Err(...) … DB側の失敗(接続エラーなど)

ハンドラでは、次のように分けます。

match soft_delete(&pool, id).await {
    Ok(true) => redirect_to_index(),
    Ok(false) => StatusCode::NOT_FOUND.into_response(),
    Err(e) => {
        // ログなど
        StatusCode::INTERNAL_SERVER_ERROR.into_response()
    }
}

「該当する行が存在しない」と「DBエラー」を同じErrにまとめず、前者はOk(false)として返しています。今回は更新1回のrows_affectedでOk(true) / Ok(false)に分け、ハンドラで404レスポンスを返しました。

まとめ

  • Railsではfindの0件はActiveRecord::RecordNotFoundをraiseし、Railsが404レスポンスを返します。axumとsqlxの構成には、同じ流れはありません。
  • 条件0件のUPDATEはMySQLではエラーになりません。sqlxのexecuteでもOkになり、rows_affected()は0になります。
  • 該当する行が存在しないことは自分で戻り値に出し、ハンドラで404レスポンスを返します。fetch_optional(やfetch_one)でも、rows_affectedでも書けます。
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?