hotwire-love / touhyosan

Hotwire.love で使う投票ツール
MIT License
13 stars 19 forks source link

DB保存時DBカラム制約違反で500エラーページが表示されないという問題 #73

Closed ykominami closed 12 months ago

ykominami commented 1 year ago

Hotwire.love meetup Vol.21(2023-09-14)のフリートークにて、「Modelに対するValidation定義とは異なるDBのカラム制約を与えた状態で、DBに保存するときに制約違反が発生しても、500エラーページとして表示されない」というテーマで盛り上がりました。 ただし、最終的な結論がでずに時間切れとなりました。 今まではsave!メソッドを呼び出し、制約違反の例外が発生したらエラーページが表示されていたが、最近画面が何も変化しないことに遭遇した、Turboが握りつぶしているのではないかという疑問でした。 いくつかのやり方でコード変更し、挙動が変わることは確認しましたが、なぜエラーページが表示されないかには辿り着けませんでした。 勉強会の後、Railsのエラーページ表示の仕組みを色々調べて、やっと腑に落ちる状態になりました。フリートーク中に出された色々な仮説に基づくコード変更の根拠が理解出来るようにはなったのですが、今まで出来ていたのに最近できなくなったというという事に対しては、説明できないとも感じました。

そこで思いついたのは、「実はDBカラムに対する制約違反は発生していない」という仮説です。 そもそも制約違反が発生していないのならば、エラーページは表示されません。 「制約違反が発生している」という前提で話していましたが、db/schema.rbをみて確認したり、railsのコンソールでsaveメソッドを呼び出して例外が発生するのかを確認してはいませんでした。またValidationが通った後にDBに保存するのですから、Validationが通らなかった場合は、保存しないため制約違反も発生しません。

実際に、Ruby 3.2.2, Rails 7.0.8、PostgrSQLにて

rails g scaffold blog title:string content:

とし、db/schema.rbが以下の通りになるようにmigrateして、試してみました。

ActiveRecord::Schema[7.0].define(version: 2023_09_17_002252) do
  # These are extensions that must be enabled in order to support this database
  enable_extension "plpgsql"

  create_table "blogs", force: :cascade do |t|
    t.string "title", null: false
    t.text "content", null: false
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
  end

end

簡単にするため、Modelに対してはValidationを定義しませんでした。 Not Null制約を与えているので、blog_paramsではなく(フォームから送信されたパラメータでは値がnilではなく空文字列になってしまうため)明示的に値がnilであるハッシュをsaveメソッドに与えて呼び出しました。

  def create
    hash = { title: nil, content: nil }
    # @blog = Blog.new(blog_params)
    @blog = Blog.new(hash)
    # raise
    # @blog = Blog.find(100)
    # @blog.save!   # (A)
    respond_to do |format|
      if @blog.save  # (B)
      # if @blog.valid? # (A)の場合はこちらを有効にしました
          format.html { redirect_to blog_url(@blog), notice: "Blog was successfully created." }
        format.json { render :show, status: :created, location: @blog }
      else
        format.html { render :new, status: :unprocessable_entity }
        format.json { render json: @blog.errors, status: :unprocessable_entity }
      end
    end
  end

結果は、(A)の場合はsave!メソッド呼び出し時、(B)の場合saveメソッド呼び出し時に、以下の例外が発生しエラーページが表示されました。

ActiveRecord::NotNullViolation in BlogsController#create

saveメソッドはDB保存に成功したらtrue、失敗したらfalseを返します。 Validation結果はerrorsに格納されています。

save!メソッドは、DB保存に失敗したら、RecordNotSavedエラーをraiseします。 saveとsave!の差は成功、失敗を値として返すか、失敗時に例外をraisesするかであり、呼び出し先でraiseされた例外はrescueしないようです。

JunichiIto commented 12 months ago

Hotwire.love meetup Vol.21(2023-09-14)のフリートークにて、「Modelに対するValidation定義とは異なるDBのカラム制約を与えた状態で、DBに保存するときに制約違反が発生しても、500エラーページとして表示されない」というテーマで盛り上がりました。

すいません、こちらですが、前回のフリートークで話していたのは、「save!でActiveRecord::RecordInvalidエラーが発生しても、ブラウザにエラーページが発生しない」という話題でした。 この場合、ActiveRecord::RecordInvalidエラーはRails 6までは422エラーとして扱われていました。500エラーではないです。 また、DBのカラム制約の有無は関係なく、DBに保存する前(=DB制約が発動する前)のRailsのバリデーションエラーが論点でした。

ちょっと論点がずれているようなので、すいませんがいったんこちらのissueはクローズさせてください 🙏

ykominami commented 11 months ago

誤解したissueを立てて混乱を招いてしまい、申し訳ありません。 クローズしていただきありがとうございます。

当日の議論のときにも、理解が不十分なままだったと思います。

勉強会後にいろいろ調べました。 実は、最初「Railsのバリデーションエラー」が発生した場合を検討していました。 「save!でActiveRecord::RecordInvalidエラーが発生しても、ブラウザにエラーページが発生しない」 という現象も、手元の環境でも再現しました。

ただ、調べれば調べるほど、「ActiveRecordの実装上こういう現象が起こる」、「このようなActiveRecordの実装は合理的ではないか」(たまたまそうなっているというのではなく、こういう挙動になるようにしている)という思いが強くなりました。

Validationエラーは、サーバ側のロジックに起因するものではなく、ユーザ入力の誤りに起因するものであり、それをInternal Server Errorで返して、エラーページを表示させるのはおかしいのではないかということです。

そのため、「save!でActiveRecord::RecordInvalidエラーが発生しても、ブラウザにエラーページが発生しないが問題だった」という認識自体が自分の思い違いではないかと考えるようになりました。

上記以外で問題になりそうなケースとして、「Modelに対するValidation定義とは異なるDBのカラム制約を与えた状態で、DBに保存するときに制約違反が発生しても、500エラーページとして表示されない」を考え付き、このケースでいろいろ調べてみたという経緯になりました。