前提
ActiveModelはDBに保存しない値を保持するModelに対して適用するのが通例なので、DBに保存するためにModelとして作成するActiveRecord(ApplicationRecord)と併用して保存するのはあまり適切ではありません。
(厳密には外部APIなどを使用して異なる必須条件のパラメータを保持する場合などには利用することがあるので、寧ろ相性は良い方です)
ただ、ActiveRecordとしての機能が使えなくなる部分が多く、慎重にならざるを得ないので実装の際には気を付けましょう。
なお、こちらのQiitaを見て安易に実装は行わないようにお願いします。
ソースを見た上でその危険性やリスクを理解し、他者に対してそれを明確に説明することができてリスク回避の方法を知っている
とまではいかなくても、理解して説明できることを前提として実装を行ってください。
障害内容と解消方法
以下のようなModelがあったとします
class TAct < ApplicationRecord
include ActiveModel::Model
attr_accessor :profile
########################################
## DBのカラムとしては以下の通り
## id:Integer Primary Key
## name:String
## age:Integer
########################################
このModelに対して登録・削除処理を行おうとすると様々な不都合が発生します。
登録処理
user = User.create(user_params) # エラーが発生
user = User.new(user_params) #
user.save # エラーが発生
user = User.new #
user.assign_attributes(user_params) # attributesによる設定でも正常に動きます
user.save # 正常に動作
原因はinclude ActiveModel::Model
によるModelの属性が消去されることです。
create
は内部的にはnew(user_params)
を使用しているため、newと同様のエラーが発生します。
更新処理
更新処理はinclude ActiveModel::Model
をする前の書き方でも問題なく動作します。
内部でassign_attributes
とsave
を呼び出しているから正常に動作するのだと思われます。
ただし、FormにModelを指定している場合、Modelの属性が消えることで自動的にcreate
アクションに飛ばされますので注意してください。
削除処理
User.find(1).delete
User.find_by(id: 1).delete
User.where(id: 1).delete
User.find(1).destroy
User.find_by(id: 1).destroy
User.where(id: 1).destroy
User.where(id: 1).destroy_all
# 上記の書き方だとエラーが発生します
User.delete(1) # 正常に動作
User.delete_all # 正常に動作
User.where(id: 1).delete_all # 正常に動作
原因は登録処理の時と同じです。
原因についての考察
include ActiveModel::Model
はModelに設定されているDBとの紐づきを解消してしまいます。
そのため、User
Modelを宣言しただけでは属性が設定されていない状態になり、関連してActiveRecordとしての登録・更新・削除処理が適切に動かないようになります。
特に
<%= form_with model: @user, url: { action: :update } %>
といったFormを実装している場合、update
アクションではなくcreate
アクションに飛んでしまいます。
理由はid
属性が設定されていないためなので、Modelを渡さない、attr_accessor
で設定するなどの工夫が必要になります。
form_withの動作についてはこちらを参照してください。
内部ソースを見ながら考察
削除処理
こちらはRailsの削除処理周りのみを抜き出したソースです。
(以下、deleteの引数有はdelete()
、deleteの引数無はdelete
として表現します。)
def delete(id_or_array)
delete_by(primary_key => id_or_array)
end
-----
def _delete_record(constraints) # :nodoc:
constraints = _substitute_values(constraints).map { |attr, bind| attr.eq(bind) }
dm = Arel::DeleteManager.new
dm.from(arel_table)
dm.wheres = constraints
connection.delete(dm, "#{self} Destroy")
end
-----
def delete
_delete_row if persisted?
@destroyed = true
freeze
end
-----
def destroy
_raise_readonly_record_error if readonly?
destroy_associations
@_trigger_destroy_callback = if persisted?
destroy_row > 0
else
true
end
@destroyed = true
freeze
end
def destroy!
destroy || _raise_record_not_destroyed
end
-----
def destroy_row
_delete_row
end
def _delete_row
self.class._delete_record(@primary_key => id_in_database)
end
実際に見てみると、delete()
は与えられた引数を基にdelete_by
を実行しています。引数にはid
を渡すため、基本的に正しく動作させることができます。
対して、delete
やdestroy
、destroy!
は前提としてpersisted?
がtrueであれば削除処理を行うように設計されています。削除処理自体は問題なく動作します。
しかし、前述したようにinclude ActiveMode::Model
によってDBとの紐づきは失われるため、persisted?
の結果はfalseが返却されます。
結果として、destroy
前のSELECT句のみ走り、ログ上には何も動作していないBEGIN ~ COMMIT
文が流れてしまう、という現象になります。
destroy_all
は内部でdestroy
を利用しているため削除処理が動作せず、delete_all
は内部でDELETE SQLを直接発行しているので正常に動作します。
(画像のModel名は都合上、加工を行っています)
ちなみに、Rails5系であればdelete()
は
def delete(id_or_array)
where(primary_key => id_or_array).delete_all
end
と記載されており、こちらのdelete()
でも正常に動作することを確認しています。
まとめ
- ActiveRecord、ApplicationRecordを継承したModelに対してActiveModelをincludeした場合、ActiveRecordに準ずるDB処理機能が正常に動作しなくなる
- 直接SQLを発行するタイプの処理は動作するため、そちらで対処する
-
delete()
はValidationやROLLBACKが効かないので、実装する場合はトランザクションやvalid?
に注意しながら実装を行う - 中身のない
BEGIN ~ COMMIT
文などを見かけた場合、ActiveModelか否か、その場合に各処理はどのように動いているのか、ActiveRecord基準の動作が想定されていないか、を確認する