Help us understand the problem. What is going on with this article?

ActiveRecordのtimestampsのカラム名を変更する

More than 3 years have passed since last update.

はじめに

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 に実装があるのを見つけました。

https://github.com/rails/rails/blob/v5.0.1/activerecord/lib/active_record/timestamp.rb#L100-L106

    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::InternalMetadataActiveRecord::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 にあります。

https://github.com/rails/rails/blob/v5.0.1/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb#L345-L352

      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 でも呼んでいる箇所があるのでそこも潰します。

https://github.com/rails/rails/blob/v5.0.1/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb#L1089-L1108

      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投げてみるのも面白そうですね :smiley:

wakaba260
Webエンジニア - Vim, Ruby, ansible, docker
aiming
オンラインゲームの企画・プロデュース・開発・運営を行う会社。Web+Game+リアルタイム通信技術に力を入れています!
http://aiming-inc.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした