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


## はじめに

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が予想外に更新されないこともあるらしい
  - 参考： http://tsurugeek.hatenablog.jp/entry/2015/09/28/123844

## とる選択肢

ビジネス要件に作成時間・更新時間のカラムを使うならビジネス要件に合わせて、フレームワーク側が用意したカラムでなくこちらでカラムを作ったり更新したりすべきでは？という議論をした結果、いくつか選択肢が出ました。

- 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

```ruby
    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` にパッチを当てましょう。

```ruby
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

```ruby
      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
```

このメソッドにパッチをあてます。

```ruby
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

```ruby
      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
```

```ruby
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を置いておくと良いでしょう。

```ruby
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](https://github.com/winebarrel/ridgepole)を使っているため、ApplicationRecordで上書きしてやるだけでよかったのですが、Qiitaに記事を書くにあたってmigrationを使っているRailsでの実装方法も調べてみました。

なお、これらの方法をとるのはフレームワーク側の挙動に手を入れる以上、 **自己責任でお願いします** 。
使うときはご自分でもきちんと調べて行ってください。

RailsにPR投げてみるのも面白そうですね :smiley: