ESM アジャイル事業部 開発者ブログ

永和システムマネジメント アジャイル事業部の開発者ブログです。

【Rails 6.1】AS 句で作ったカラムに DB の型情報はない

本記事の環境

  • Rails 6.1.1

結論

9sako6 です。突然ですが、

AS 句で作ったカラムに DB の型情報はありません。

次の例をご覧ください。スキーマに日時型で定義されている created_at カラムは TimeWithZone オブジェクトが返るのに対し、AS 句で作った latest_created_at カラムは文字列が返されます。

> Foo.select('created_at').first.created_at
  Foo Load (0.2ms)  SELECT "foos"."created_at" FROM "foos" ORDER BY "foos"."id" ASC LIMIT ?  [["LIMIT", 1]]
=> Mon, 08 Feb 2021 16:56:10.531831000 JST +09:00 # ActiveSupport::TimeWithZone

> Foo.select('MAX(created_at) AS latest_created_at').first.latest_created_at
  Foo Load (0.3ms)  SELECT MAX(created_at) AS latest_created_at FROM "foos" ORDER BY "foos"."id" ASC LIMIT ?  [["LIMIT", 1]]
=> "2021-02-08 07:56:10.591486"                   # String
# スキーマ
  create_table "foos", force: :cascade do |t|
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
  end

さらに、AS 句で作ったカラムは、limit, precision, scale のようなカラム修飾子の情報も持ちません。

この挙動を把握していないと、次のような問題が起こってしまうかもしれません。

  • Rails アプリに設定したタイムゾーンではなく、DB のタイムゾーンで日付が表示される

なぜこの挙動になるのかに興味のある方は以降のおまけをご覧ください。

おまけ: コードリーディング

次のコードを実行したとき、なんのメソッドが呼ばれるか追っていきます。

# DB からデータを取ってくるフェーズ
foo = Foo.select('created_at, created_at AS created_at_clone').first 

# 値を取得するフェーズ
foo.created_at_clone                                                 

その過程で、AS 句で作ったカラムに型情報がつかない理由もわかります。 スキーマにないカラムはデフォルトで ActiveModel::Type::Value 型になるから、です。

※ 記事中で書いた foos テーブルが定義されているものとします。

環境

  • Rails 6.1.1
  • SQLite3

DB からデータを取ってくるフェーズ

Foo.select('created_at, created_at AS created_at_clone').first で何が起こるかを追っていきます。

select メソッドから始まり、おおよそ次の順に呼ばれていきます。

ActiveRecord::ConnectionAdapters::DatabaseStatements#select

ActiveRecord::ConnectionAdapters::SQLite3::DatabaseStatements#exec_query

ActiveRecord::ConnectionAdapters::AbstractAdapter#build_result

ActiveRecord::Result#initialize

次に、select の戻り値 Foo::ActiveRecord_Relation のインスタンスに対して first メソッドから次の順に呼ばれていきます。

ActiveRecord::FinderMethods#first

ActiveRecord::FinderMethods#find_nth

ActiveRecord::FinderMethods#find_nth_with_limit

ActiveRecord::Relation#to_ary

ActiveRecord::Relation#records

ActiveRecord::Relation#load

ActiveRecord::Relation#exec_queries

これが Foo のインスタンスを返します。上記の ActiveRecord::Relation#exec_queries の中で呼ばれる ActiveRecord::Querying#find_by_sql の中の ActiveRecord::Persistence::ClassMethods#instantiate_instance_offoo (Foo のインスタンス)のインスタンス変数 @attributes に DB からとってきたデータや型情報を格納しています。

ActiveRecord::Querying#find_by_sql

ActiveRecord::Persistence::ClassMethods#instantiate_instance_of

ここまでが DB からデータを取ってくるフェーズです。

まとめると、DB からとってきたデータやカラムの型情報などをfoo ( Foo インスタンス)のインスタンス変数 @attributes に格納しました。

@attributes は以下のようになっています。

> pp Foo.select('created_at, created_at AS created_at_clone').first.instance_variable_get(:@attributes)
  Foo Load (0.4ms)  SELECT created_at, created_at AS created_at_clone FROM "foos" ORDER BY "foos"."id" ASC LIMIT ?  [["LIMIT", 1]]
#<ActiveModel::LazyAttributeSet:0x00007feb86be6788
 @additional_types={},
 @attributes={},
 @casted_values={},
 @default_attributes=
  {"id"=>
    #<ActiveModel::Attribute::FromDatabase:0x00007feb82d6a9c8
     @name="id",
     @original_attribute=nil,
     @type=
      #<ActiveRecord::ConnectionAdapters::SQLite3Adapter::SQLite3Integer:0x00007feb8662a470
       @limit=nil,
       @precision=nil,
       @range=-9223372036854775808...9223372036854775808,
       @scale=nil>,
     @value_before_type_cast=nil>},
 @materialized=false,
 @types=
  {"id"=>
    #<ActiveRecord::ConnectionAdapters::SQLite3Adapter::SQLite3Integer:0x00007feb8662a470
     @limit=nil,
     @precision=nil,
     @range=-9223372036854775808...9223372036854775808,
     @scale=nil>,
   "created_at"=>
    #<ActiveRecord::Type::DateTime:0x00007feb82d6b378
     @limit=nil,
     @precision=6,
     @scale=nil>,
   "updated_at"=>
    #<ActiveRecord::Type::DateTime:0x00007feb82d6b378
     @limit=nil,
     @precision=6,
     @scale=nil>},
 @values=
  {"created_at"=>"2021-02-08 07:56:10.531831",
   "created_at_clone"=>"2021-02-08 07:56:10.531831"}>

値を取得するフェーズ

foo.created_at_clone で何が起こるかを追っていきます。 まず、ActiveModel::AttributeMethods#method_missing が呼ばれます。

ActiveModel::AttributeMethods#method_missing

    def method_missing(method, *args, &block)
      if respond_to_without_attributes?(method, true)
        super
      else
        match = matched_attribute_method(method.to_s)
        match ? attribute_missing(match, *args, &block) : super
      end
    end

次に、以下のメソッドが呼ばれます。

ActiveModel::AttributeMethods#attribute_missing

    def attribute_missing(match, *args, &block)
      __send__(match.target, match.attr_name, *args, &block)
    end

このとき、match.target'attribute'となっており、attributeメソッドが呼ばれます。 attributeメソッドの定義は ActiveRecord::AttributeMethods::Read#_read_attribute にあります。

ActiveRecord::AttributeMethods::Read#_read_attribute

      def _read_attribute(attr_name, &block) # :nodoc
        @attributes.fetch_value(attr_name, &block)
      end

      alias :attribute :_read_attribute
      private :attribute

次に呼ばれるのが fetch_value です。この fetch_value の戻り値が foo.created_at_clone の戻り値になります。

ActiveModel::LazyAttributeSet#fetch_value

  class LazyAttributeSet < AttributeSet # :nodoc:
    ...

    def fetch_value(name, &block)
      if attr = @attributes[name]
        return attr.value(&block)
      end

      ...

name'created_at_clone' です。attr = @attributes[name] に、created_at_clone カラムのデータをインスタンス化したものが格納されています。 そして、attr.value(&block) が、foo.created_at_clone として返ります。

ん...?ちょっと待ってください! 前節、「DB からデータを取ってくるフェーズ」で最後に記載した @attributes をもう一度思い出してみます。 前節で登場した @attributesfoo のインスタンス変数であり、 LazyAttributeSet のインスタンスです。

よく見ると、@attributes 自身もインスタンス変数 @attributes を持っています。そしてその中身は空の Hash です。

#<ActiveModel::LazyAttributeSet:0x00007feb864a16a8
 @additional_types={},
 @attributes={},       # HERE!!!
 @casted_values={},
 @default_attributes=
  {"id"=>
    #<ActiveModel::Attribute::FromDatabase:0x00007feb82d6a9c8
     @name="id",
     @original_attribute=nil,
     @type=
      #<ActiveRecord::ConnectionAdapters::SQLite3Adapter::SQLite3Integer:0x00007feb8662a470
       @limit=nil,
       @precision=nil,
       @range=-9223372036854775808...9223372036854775808,
       @scale=nil>,
     @value_before_type_cast=nil>},

 ...

話を戻します。

  class LazyAttributeSet < AttributeSet # :nodoc:
    ...

    def fetch_value(name, &block)
      if attr = @attributes[name]
        return attr.value(&block)
      end

      ...

ここで登場した @attributes は、前節で登場した @attributes のインスタンス変数です。つまり、foo.instance_variable_get(:@attributes).instance_variable_get(:@attributes) です。 そして、中身は空でした。

fetch_value@attributes[name] すると値は取れるので、どこかで値を格納しているはずです。

値の格納は、一番最初に呼ばれた method_missingmatched_attribute_method(method.to_s) で行われます。次の流れです。

ActiveModel::AttributeMethods#matched_attribute_method

ActiveRecord::AttributeMethods::PrimaryKey#attribute_method?

ActiveRecord::AttributeMethods#attribute_method?

ActiveModel::LazyAttributeSet#key?

ActiveModel::AttributeSet#[]

ActiveModel::LazyAttributeSet#default_attribute

      def default_attribute(
        name,
        value_present = true,
        value = values.fetch(name) { value_present = false }
      )
        type = additional_types.fetch(name, types[name])

        if value_present
          @attributes[name] = Attribute.from_database(name, value, type, @casted_values[name])

        ...

@attributes[name] = Attribute.from_database(name, value, type, @casted_values[name]) で値が格納されます。

typecreated_at_clone カラムの型です。types['created_at_clone'] が格納されます。

types は前節の @attributes のもつインスタンス変数 @types です。つまり、foo.instance_v ariable_get(:@attributes).instance_variable_get(:@types) です。

前節で @attributes が設定された際に @types も初期化されました。DB のもつカラムである id, created_at, updated_at の型情報が格納されています。

@types はただの Hash ですが、デフォルト値が ActiveModel::Type::Value のインスタンスになっています。スキーマにない 'created_at_clone' カラムの型は ActiveModel::Type::Value になるというわけです。

ちなみに、id の型は ActiveRecord::ConnectionAdapters::SQLite3Adapter::SQLite3Integercreated_at の型は ActiveRecord::Type::DateTime です。

以降、次のように呼ばれます。

ActiveModel::Attribute.from_database

ActiveModel::Attribute#value

ActiveModel::Attribute::FromDatabase#type_cast

ActiveModel::Attribute::FromDatabase#type_cast の戻り値が foo.created_at_clone です。

        def type_cast(value)
          type.deserialize(value)
        end

typeActiveModel::Type::Value が入るのでしたね。この deserialize メソッドは型ごとに定義されており、データベースから得た値を Ruby 型に変換します。

ActiveModel::Type::Valuedeserialize は、引数 value をそのまま返すメソッドです。 というわけで、foo.created_at_clone は文字列として返ります。

ActiveModel::Type::Value#deserialize

      def deserialize(value)
        cast(value)
      end

ActiveModel::Type::Value#cast

      def cast(value)
        cast_value(value) unless value.nil?
      end

ActiveModel::Type::Value#cast_value

        # Convenience method for types which do not need separate type casting
        # behavior for user and database inputs. Called by Value#cast for
        # values except +nil+.
        def cast_value(value) # :doc:
          value
        end

以上です。お疲れ様でした。

おまけのおまけ: コードを読んだ際の手の動き

EDITOR=code bundle open activerecord で、ランタイムで実際に動いている gem のコードを開きます。

明らかに呼ばれそうなメソッドに byebug を仕込み、up, down コマンドでスタックフレームを移動しながら呼ばれるメソッドを追います。これを繰り返します。

他にヒントが欲しければ Method#source_location で調べます。

最後に gem pristine activerecord して自分の編集箇所を残さないように掃除します。


永和システムマネジメント アジャイル事業部は Ruby とアジャイルの精鋭集団です。

一緒に働くことに興味をもってくださった方、ぜひ一度お話してみませんか!

agile.esm.co.jp