LoginSignup
10
8

More than 3 years have passed since last update.

Active Recordの型変換はいつ実行されるのか?

Last updated at Posted at 2019-10-12

上記の動作を知らずに実装していたらハマったのでメモします。
(ジャストなドキュメントを見つけられなかったためActive Recordのソースやその他記事等を確認しましたが、間違っている部分がありましたらご指摘ください)

前提

  • Rails 5.2.3

Task というクラスがあり、 tests という属性を持っているとします。
tests はRails上では ActiveSupport::TimeWithZone として扱われます。
アプリケーションのデフォルトのタイムゾーンは Tokyo です。

rails_console
[2] pry(main)> Task.columns_hash["tests"].type
=> :datetime
[5] pry(main)> Time.zone.name
=> "Tokyo"

疑問

以下のアクション( TasksController#index )が実行された場合に # ?? の部分はどのような値(タイムゾーン)となるか?

tasks_controller.rb
class TasksController < ApplicationController
  def index
    task = Task.new(tests: "2019/01/01")
    Time.use_zone("UTC"){ task.tests }
    puts task.tests # ??
  end
end

正解

正解は Tue, 01 Jan 2019 00:00:00 UTC +00:00
Tokyo のタイムゾーンになると思っていたのでハマった。

Active Recordの型変換

事前知識

Active Recordでは属性として入力された値を型変換することがあります。例えば属性にInteger型の Task.id があった場合で、idにInteger型以外の値が入力された場合でも、以下のように型変換して属性値として扱います。

rails_console
[1] pry(main)> task = Task.new
=> #<Task:0x00007f3a201cad70 id: nil, created_at: nil, updated_at: nil, tests: nil>
[2] pry(main)> task.id = "1"
=> "1"
[3] pry(main)> task.id
=> 1
[4] pry(main)> task.id = "2019/01/01"
=> "2019/01/01"
[5] pry(main)> task.id
=> 2019

型変換が実行されるタイミングは?

さて、上記の型変換が実行されるタイミングですが、これまで私は何となく、前述のケースでいえば以下(1)にようにモデルがインスタンス化されたときだと思っていました。しかし、今回調べたところ、正しくは、初めてその属性のアクセサメソッドが実行されたとき、のようです。
すなわち、以下(2)のタイミングのときにtask.tests は TimeWithZone 型に変換されるようです。

(1)    task = Task.new(tests: "2019/01/01")
(2)    Time.use_zone("UTC"){ task.tests }

では、 (1) ではどこまで処理が実行されるのかというと、アクセスメソッドを定義するまで、実行されるようです(他にも色々しているのでしょうが)。
以下、(1)(2)それそれが実行される流れを簡単にコードで追ってみようと思います。

ソースコードを読んでみる

Model.new すると起きること

ActiveRecord を継承したクラスが new される場合に実行されるコンストラクタは ActiveRecord::Core モジュール(includeされるクラスは ActiveRecord::Base クラス)のコンストラクタのようです。コードを抜粋して確認してみます。

core.rb
    # New objects can be instantiated as either empty (pass no construction parameter) or pre-set with
    # attributes but not yet saved (pass a hash with key names matching the associated table column names).
    # In both instances, valid attribute keys are determined by the column names of the associated table --
    # hence you can't have attributes that aren't part of the table columns.
    #
    # ==== Example:
    #   # Instantiates a single new object
    #   User.new(first_name: 'Jamie')
    def initialize(attributes = nil)
      self.class.define_attribute_methods
      @attributes = self.class._default_attributes.deep_dup

      init_internals
      initialize_internals_callback

      assign_attributes(attributes) if attributes

      yield self if block_given?
      _run_initialize_callbacks
    end

上記の

self.class.define_attribute_methods

部分で属性のアクセサメソッドを定義していると想像できます。
さらにこの奥に進んでみると、ActiveRecord::AttributeMethods モジュールの中で define_attribute_methods が定義されていました。

attribute_methods.rb
      # Generates all the attribute related methods for columns in the database
      # accessors, mutators and query methods.
      def define_attribute_methods # :nodoc:
        return false if @attribute_methods_generated
        # Use a mutex; we don't want two threads simultaneously trying to define
        # attribute methods.
        generated_attribute_methods.synchronize do
          return false if @attribute_methods_generated
          superclass.define_attribute_methods unless self == base_class
          super(attribute_names)
          @attribute_methods_generated = true
        end
      end

この中の

 superclass.define_attribute_methods 

によって今度はActiveModelモジュールの方の同名のメソッドが呼ばれます。

attribute_methods.rb
      def define_attribute_methods(*attr_names)
        attr_names.flatten.each { |attr_name| define_attribute_method(attr_name) }
      end

この中での define_attribute_method メソッドがアクセサメソッドを定義していそうです。ここを追ってみると同じファイル内で以下のメソッドが定義されています。

attribute_methods.rb
      def define_attribute_method(attr_name)
        attribute_method_matchers.each do |matcher|
          method_name = matcher.method_name(attr_name)

          unless instance_method_already_implemented?(method_name)
            generate_method = "define_method_#{matcher.method_missing_target}"

            if respond_to?(generate_method, true)
              send(generate_method, attr_name.to_s)
            else
              define_proxy_call true, generated_attribute_methods, method_name, matcher.method_missing_target, attr_name.to_s
            end
          end
        end
        attribute_method_matchers_cache.clear
      end

この中の attribute_method_matchersActiveModel::AttributeMethods::ClassMethods::AttributeMethodMatcher の配列となっており、これらは <Atrribute名>_in_database 等すべての属性に共通して使えるようにするメソッドのプレフィックス等が格納されていました。
今回は、それらの深追いはせず、属性名自体のアクセサメソッドの定義の動作を追います。
それについては、上記コードの

send(generate_method, attr_name.to_s)

が呼ばれるようです。このとき引数は

generate_method # => define_method_attribute
attr_name.to_s # => tests

の通りでした。
define_method_attribute メソッドが実行されるので、追ってみると以下で定義されていました。

read.rb
          def define_method_attribute(name)
            safe_name = name.unpack("h*".freeze).first
            temp_method = "__temp__#{safe_name}"

            ActiveRecord::AttributeMethods::AttrNames.set_name_cache safe_name, name
            sync_with_transaction_state = "sync_with_transaction_state" if name == primary_key

            generated_attribute_methods.module_eval <<-STR, __FILE__, __LINE__ + 1
              def #{temp_method}
                #{sync_with_transaction_state}
                name = ::ActiveRecord::AttributeMethods::AttrNames::ATTR_#{safe_name}
                _read_attribute(name) { |n| missing_attribute(n, caller) }
              end
            STR

            generated_attribute_methods.module_eval do
              alias_method name, temp_method
              undef_method temp_method
            end
          end

上記により、 module_evalによりメソッドが動的に定義されている様子がわかります。

型変換が実行されるタイミング

さて、上記によりモデルが new されてアクセサメソッドが定義されるまでの流れは分かりました。話を戻して型変換が実際に実行される処理を確認してみたいと思います。すなわち、今回の例では(2)を実行すると起きること、です。

(1)    task = Task.new(tests: "2019/01/01")
(2)    Time.use_zone("UTC"){ task.tests }

(2)の task.tests (=Model.newしたときに動的に定義されたアクセサメソッド)を実行すると、上記で確認したように以下メソッドの内容が実行されることになります。

read.rb
              def #{temp_method}
                #{sync_with_transaction_state}
                name = ::ActiveRecord::AttributeMethods::AttrNames::ATTR_#{safe_name}
                _read_attribute(name) { |n| missing_attribute(n, caller) }
              end

_read_attribute(name) が属性値を読み込んで値を返答している箇所と考えられるのでこの部分を追ってみると、同ファイルに定義されています。

read.rb
        def _read_attribute(attr_name) # :nodoc:
          @attributes.fetch_value(attr_name.to_s) { |n| yield n if block_given? }
        end

@attributesActiveModel::AttributeSet クラスです。
fetch_value メソッドを追ってみると、以下で定義されていました。

attribute_set.rb
      def fetch_value(name)
        self[name].value { |n| yield n if block_given? }
      end

self[name].value は今回はWebブラウザからリクエストを送信しており ActiveModel::Attribute::FromUser でした。
ここで実行される value メソッドを追うと以下の定義されていました。

attribute.rb
    def value
      # `defined?` is cheaper than `||=` when we get back falsy values
      @value = type_cast(value_before_type_cast) unless defined?(@value)
      @value
    end

さて、上記まで来るとかなり具体的なな処理が見えてきたように思います。
すなわち、

  • type_cast メソッドで元々の入力値( value_before_type_cast 、今回では "2019/01/01")をキャストしている
  • 一度キャストして返答した値は再びキャストしない( unless defined?(@value)

と考えてよいでしょう。せっかくなので type_cast ももう少し追ってみようと思います。同ファイル内に以下の定義があります。

attribute.rb
      class FromUser < Attribute # :nodoc:
        def type_cast(value) 
          type.cast(value)
        end

type.classActiveRecord::AttributeMethods::TimeZoneConversion::TimeZoneConverter です。そのクラスの cast メソッドを確認してみると以下の定義が見つかります。

time_zone_conversion.rb
        def cast(value)
          return if value.nil?

          if value.is_a?(Hash)
            set_time_zone_without_conversion(super)
          elsif value.respond_to?(:in_time_zone)
            begin
              super(user_input_in_time_zone(value)) || super
            rescue ArgumentError
              nil
            end
          else
            map_avoiding_infinite_recursion(super) { |v| cast(v) }
          end
        end

今回 value"2019/01/01" であり、Railsでは文字列が日時への変換対象のフォーマットを満たしていれば in_time_zoneメソッドで変換できるので、

            begin
              super(user_input_in_time_zone(value)) || super
            rescue ArgumentError
              nil
            end

の処理が実行されます。user_input_in_time_zone メソッドを追うと、

time_value.rb
        def user_input_in_time_zone(value)
          value.in_time_zone
        end

が、実行されていました。なるほど型変換の実体は in_time_zoneメソッドが実行されていわけですね。すなわちその時の Time.zone のタイムゾーンにしたがって値が変換されると理解できます。

まとめ

さて、最初に戻って、以下のアクション( TasksController#index )が実行された場合に # ?? の部分が Tue, 01 Jan 2019 00:00:00 UTC +00:00 となる理由は、

  • (1) のタイミングでは各属性のアクセサメソッド等が定義されたたけで、実際にアクセサメソッド経由で取得される値の生成(型変換)はされていない
  • (2) のタイミングではじめてアクセサメソッドが実行され、元々入力された値が(必要な場合に)型変換されて返答される
    • 型変換は String#in_time_zone で実行されており、このときのタイムゾーンはUTCのためUTCのタイムゾーンで値が生成される
  • 一度型変換された値は二度目以降にアクセサメソッドが実行された後は再度型変換されないため、(3)のタイミングではタイムゾーンがTokyoであっても(2)で生成されたUTCの値が読み出される
tasks_controller.rb
class TasksController < ApplicationController
  def index
    task = Task.new(tests: "2019/01/01") # (1)
    Time.use_zone("UTC"){ task.tests } # (2)
    puts task.tests # ?? (3)
  end
end

感想

長かった…。

参考

『メタプログラミングRuby』にも少しActive Recordの記載があった(ただし少しVersionが古いので今と違う部分があるかも)。

メタプログラミングRuby 第2版
Paolo Perrotta
オライリージャパン
売り上げランキング: 85,250
10
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
8