Railsのcreated_atとupdated_at周りのソースコードを読んでみた

こんにちは!kossyです!




さて、今回はRailsのcreated_atとupdated_at周りのソースコードを読んでみたので、
ブログに残してみたいと思います。




環境
Ruby 2.6.3
Rails 6.0.3
MacOS catalina



まずはドキュメントを探す

自分のググり方が悪いのか、解説している記事がなかなか見つからなかったので、
ソースコードを読んでみることにしました。

rails created_at 自動 と検索して、下記のファイルでcreated_atやupdated_atに関する処理を行っているとみました。

まず、Google翻訳大先生にコメントアウト部分を訳してもらいます。一部よしなに変更して訳しました。

ActiveRecord automatically timestamps create and update operations
if the table has fields named 「created_at/created_on」or「updated_at/updated_on」.
  
ActiveRecordはレコードの作成または更新時にそのテーブルに
「created_at/created_on」か「updated_at/updated_on」という名前のフィールドがあれば、自動的にタイムスタンプを付与します。
  
Timestamping can be turned off by setting:
  
タイムスタンプ機能をオフにしたい場合は、config/application.rbで以下の記述をします。
  
config.active_record.record_timestamps = false
  
Timestamps are in UTC by default but you can use the local timezone by setting:
  
タイムスタンプはデフォルトでUTCですが、config/application.rbでローカルタイムゾーンを使用できます。
  
config.active_record.default_timezone = :local
  
== Time Zone aware attributes
  
タイムゾーン対応の属性
  
ActiveRecord keeps all the「datetime」and「time」columns timezone aware.
By default, these values are stored in the database as UTC
and converted back to the current「Time.zone」when pulled from the database.
  
Active Recordは、すべての「datetime」列と「time 」列のタイムゾーンを認識し続けます。
デフォルトでは、これらの値はUTCとしてデータベースに保存されます。
データベースからプルすると、現在の「Time.zone」に変換されます。
  
This feature can be turned off completely by setting:
  
この機能は、以下をconfig/application.rbで設定することで完全にオフにできます。
  
config.active_record.time_zone_aware_attributes = false
  
You can also specify that only「datetime」columns should be time-zone
aware (while「time」should not) by setting:
  
次のように設定することで、日時列のみがタイムゾーンを認識するように指定することもできます(時間は認識されません)。
  
ActiveRecord::Base.time_zone_aware_types = [:datetime]
  
You can also add database specific timezone aware types. For example, for PostgreSQL:
  
データベース固有のタイムゾーン対応タイプを追加することもできます。
たとえば、PostgreSQLの場合:
  
ActiveRecord::Base.time_zone_aware_types += [:tsrange, :tstzrange]
  
Finally, you can indicate specific attributes of a model for which time zone conversion should not applied, for instance by setting:
  
最後に、たとえば次のように設定することで、タイムゾーン変換を適用してはならないモデルの特定の属性を指定できます。
  
class Topic < ActiveRecord::Base
self.skip_time_zone_conversion_for_attributes = [:written_on]
end
  
出典: https://github.com/rails/rails/blob/master/activerecord/lib/active_record/timestamp.rb

時刻周りは意外と柔軟に設定できるインターフェイスになっている印象を受けました。
各オプションを活用するユースケースはパッと思いつきませんが、、、(詳しい方、教えてください)


ソースコードを追う(その1)

コメントアウト部分の意図を汲み取ったところで、次は本丸のコードリーディングです。

目についたのは77行目のcurrent_time_from_proper_timezoneメソッドです。

def current_time_from_proper_timezone
  default_timezone == :utc ? Time.now.utc : Time.now
end

このメソッドは、ActiveRecord::Timestamp::ClassMethodsに定義されていて、
ActiveRecord::Baseにincludeされているため、ActiveRecord::Baseを継承したモデルから呼び出せます。

以下のファイルの299行目でincludeしています。

Moduleに定義したinstance_methodsは外部からincludeすれば使えるようになりますゆえ。

ではコンソールで試してみましょう。
前提として、ActiveRecord::Baseを継承したUserモデルが定義してあるとします。

User.current_time_from_proper_timezone
=> 2020-11-07 09:28:28 +0900

はい、返り値としてTimeクラスのインスタンスが返って来ました。

config/application.rbで、

    config.active_record.default_timezone = :local

を指定しているので、Time.nowの返り値になっています。

このcurrent_time_from_proper_timezoneの値をcreated_atおよびupdated_atに保存していると思われます。



もっと深く追ってみましょう。


ソースコードを追う(その2)

current_time_from_proper_timezoneはどこから呼び出されているでしょうか。

いくつか呼び出し箇所がありましたが、

ここから呼び出されています。

      def touch_attributes_with_time(*names, time: nil)
        attribute_names = timestamp_attributes_for_update_in_model
        attribute_names |= names.map(&:to_s)
        attribute_names.index_with(time || current_time_from_proper_timezone)
      end

引数のtimeがnilならcurrent_time_from_proper_timezoneを呼び出していますね。

timestamp_attributes_for_update_in_modelメソッドはどんな処理でしょうか。
このメソッドもコンソールから呼び出せるので、試してみましょう。

$ User.timestamp_attributes_for_update_in_model
=> ["updated_at"]

ふむふむ。後続の処理から察するに{ updated_at: Time.now } みたいな値が返りそうですが、、、

$ names = []
=> []

$ User.touch_attributes_with_time(*names, time: nil)
=> {"updated_at"=>2020-11-07 09:52:56 +0900}

当たってましたね。

他の呼び出し箇所も見てみましょう。

  private
    def _create_record
      if record_timestamps
        current_time = current_time_from_proper_timezone

        all_timestamp_attributes_in_model.each do |column|
          _write_attribute(column, current_time) unless _read_attribute(column)
        end
      end

      super
    end

実際にレコード作成時に呼び出されてTime.nowの値が入るのがこの処理です。

all_timestamp_attributes_in_modelメソッドの返り値は、

User.all_timestamp_attributes_in_model
=> ["created_at", "updated_at"]

となりますので、上記2つのカラムに対して、Time.nowの返り値が入ります。

update時のメソッドも定義されていて、

    def _update_record
      if @_touch_record && should_record_timestamps?
        current_time = current_time_from_proper_timezone

        timestamp_attributes_for_update_in_model.each do |column|
          next if will_save_change_to_attribute?(column)
          _write_attribute(column, current_time)
        end
      end

      super
    end

timestamp_attributes_for_update_in_modelはupdated_at(updated_onカラムがあればそちらも)を返します。

will_save_change_to_attribute?はRails5.1系から〇〇_changed?の代わりに推奨されるようになったメソッドで、上記の処理の場合だと引数のcolumnに該当する値が変更されているかどうかを真偽値で返します。


なお、Timeクラスの挙動については、以下の記事が詳しかったです。




勉強になりました。(コード追うの楽しい)