はじめに
この投稿は、学習の備忘録として書いています。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でも書けます。