はじめに
Railsのcreated_at, updated_atは問題調査などには便利なんですが、このカラムはフレームワーク側に実装が隠蔽されてるカラムなので、ビジネス要件に使うのは避けたいと思ってます。
理由としては、
- updated_atは
update_all
など更新されないケースがある- 参考: http://qiita.com/Oakbow/items/e2322f76717b2e535176
- 使うならレビューで気をつける必要があるけれど、気が付かなかった場合クリティカルな問題に発生する可能性がある(実際に事例があった)
- created_at, updated_atをレコード作成・更新時間でなく特定のビジネス要件に合わせて振る舞いを変えたい
- このケースはそもそもcreated_at, updated_atを使うのが間違ってそう
- updated_atが予想外に更新されないこともあるらしい
とる選択肢
ビジネス要件に作成時間・更新時間のカラムを使うならビジネス要件に合わせて、フレームワーク側が用意したカラムでなくこちらでカラムを作ったり更新したりすべきでは?という議論をした結果、いくつか選択肢が出ました。
- created_at, updated_atを封印する
-
ActiveRecord::Base.record_timestamps = false
を設定すると更新されなくなるのであらかじめfalseにしておき、必要な場合自分たちで保存・更新などをするようにする - → 途中からjoinした人などが勘違いする原因になりそうなので却下
-
- created_at, updated_atは置いておき、別にカラムを用意する
- → 実装コストが一番安いが、その名前が取られてるのが痛い
- created_at, updated_atを別のカラム名にする
- → うっかり使ってしまうことはなさそう
悩んだ末に3つ目の別のカラムにするという選択をすることにし、その方法を調査したのでやり方を書いておきます。
timestampのカラムを上書きする
本題。
ActiveRecord::Timestamp
git grepして探した結果、 ActiveRecord::Timestamp
に実装があるのを見つけました。
def timestamp_attributes_for_update
[:updated_at, :updated_on]
end
def timestamp_attributes_for_create
[:created_at, :created_on]
end
まさかの埋め込み……
このカラム名を変更するだけなら ApplicationRecord
で同じメソッド名を定義してオーバーライドしてやればいいのですが、Railsがmigrationなどのmetadataを保持する ActiveRecord::InternalMetadata
が ActiveRecord::Base
を継承しているのでそれだけではmigrationの際にエラーが発生します。
== 20170103173617 CreateFoos: migrating =======================================
-- create_table(:foos)
-> 0.0013s
== 20170103173617 CreateFoos: migrated (0.0013s) ==============================
rails aborted!
ActiveRecord::StatementInvalid: SQLite3::ConstraintException: NOT NULL constraint failed: ar_internal_metadata.ar_created_at: INSERT INTO "ar_internal_metadata" ("key", "value") VALUES (?, ?)
/usr/local/lib/ruby/gems/2.3.0/gems/sqlite3-1.3.12/lib/sqlite3/statement.rb:108:in `step'
/usr/local/lib/ruby/gems/2.3.0/gems/sqlite3-1.3.12/lib/sqlite3/statement.rb:108:in `block in each'
/usr/local/lib/ruby/gems/2.3.0/gems/sqlite3-1.3.12/lib/sqlite3/statement.rb:107:in `loop'
/usr/local/lib/ruby/gems/2.3.0/gems/sqlite3-1.3.12/lib/sqlite3/statement.rb:107:in `each'
/usr/local/lib/ruby/gems/2.3.0/gems/activerecord-5.0.1/lib/active_record/connection_adapters/sqlite3_adapter.rb:202:in `to_a'
/usr/local/lib/ruby/gems/2.3.0/gems/activerecord-5.0.1/lib/active_record/connection_adapters/sqlite3_adapter.rb:202:in `block in exec_query'
...
なのでinitializerなどで ActiveRecord::Base
にパッチを当てましょう。
module TimestampPatch
module Timestamp
def timestamp_attributes_for_update
[:ar_updated_at]
end
def timestamp_attributes_for_create
[:ar_created_at]
end
end
end
ActiveRecord::Base.prepend TimestampPatch::Timestamp
ここではカラム名に ar_
のprefixをつけることにしました。
Migration
Migrationの際に t.timestamps
でtimestampを付加しますが、この実装は ActiveRecord::ConnectionAdapters::TableDefinition
にあります。
def timestamps(*args)
options = args.extract_options!
options[:null] = false if options[:null].nil?
column(:created_at, :datetime, options)
column(:updated_at, :datetime, options)
end
このメソッドにパッチをあてます。
module TimestampPatch
module TableDefinition
def timestamps(*args)
options = args.extract_options!
options[:null] = false if options[:null].nil?
column(:ar_created_at, :datetime, options)
column(:ar_updated_at, :datetime, options)
end
end
end
ActiveRecord::ConnectionAdapters::TableDefinition.prepend TimestampPatch::TableDefinition
もう一つ、 ActiveRecord::ConnectionAdapter::SchemaStatements
でも呼んでいる箇所があるのでそこも潰します。
def add_timestamps(table_name, options = {})
options[:null] = false if options[:null].nil?
add_column table_name, :created_at, :datetime, options
add_column table_name, :updated_at, :datetime, options
end
def remove_timestamps(table_name, options = {})
remove_column table_name, :updated_at
remove_column table_name, :created_at
end
module TimestampPatch
module SchemaStatements
def add_timestamps(table_name, options = {})
options[:null] = false if options[:null].nil?
add_column table_name, :ar_created_at, :datetime, options
add_column table_name, :ar_updated_at, :datetime, options
end
def remove_timestamps(table_name, options = {})
remove_column table_name, :ar_updated_at
remove_column table_name, :ar_created_at
end
end
end
ActiveRecord::ConnectionAdapters::SchemaStatements.prepend TimestampPatch::SchemaStatements
全コード
上記をまとめると、こういうinitializerを置いておくと良いでしょう。
module TimestampPatch
module TableDefinition
def timestamps(*args)
options = args.extract_options!
options[:null] = false if options[:null].nil?
column(:ar_created_at, :datetime, options)
column(:ar_updated_at, :datetime, options)
end
end
module SchemaStatements
def add_timestamps(table_name, options = {})
options[:null] = false if options[:null].nil?
add_column table_name, :ar_created_at, :datetime, options
add_column table_name, :ar_updated_at, :datetime, options
end
def remove_timestamps(table_name, options = {})
remove_column table_name, :ar_updated_at
remove_column table_name, :ar_created_at
end
end
module Timestamp
def timestamp_attributes_for_update
[:ar_updated_at]
end
def timestamp_attributes_for_create
[:ar_created_at]
end
end
end
ActiveRecord::ConnectionAdapters::TableDefinition.prepend TimestampPatch::TableDefinition
ActiveRecord::ConnectionAdapters::SchemaStatements.prepend TimestampPatch::SchemaStatements
ActiveRecord::Base.prepend TimestampPatch::Timestamp
注意点
rails内部でActiveRecordのcreated_at, updated_atを使っている場所をgit grepで探したのですが、問題が発生する可能性のある箇所は以下です。
ActionView::Helpers::AtomFeedHelper
RSSのAtom Feedを送るためのHelper。RSSは使ってませんでした。
https://github.com/rails/rails/blob/v5.0.1/actionview/lib/action_view/helpers/atom_feed_helper.rb#L184-L186
ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter
Mysql用アダプターの抽象クラス。railsのソースコード内では該当コードのメソッドは呼ばれていないので問題なさそうと判断しました。
(気になるならオーバーライドしておいたほうが良さげですが)
https://github.com/rails/rails/blob/v5.0.1/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb#L802-L808
ActiveRecord::Fixtures
fixtureを作るためのクラス。factory_girlを使っているので関係ありませんでした。
https://github.com/rails/rails/blob/v5.0.1/activerecord/lib/active_record/fixtures.rb#L762
その他
もちろん、gemの内部でも使っている可能性があるため注意が必要です。テストや使用gemのチェックはきちんと行いましょう。
まとめ
現在コードを書いているプロジェクトではridgepoleを使っているため、ApplicationRecordで上書きしてやるだけでよかったのですが、Qiitaに記事を書くにあたってmigrationを使っているRailsでの実装方法も調べてみました。
なお、これらの方法をとるのはフレームワーク側の挙動に手を入れる以上、 自己責任でお願いします 。
使うときはご自分でもきちんと調べて行ってください。
RailsにPR投げてみるのも面白そうですね