【Rails】ActiveRecord::Attributes::ClassMethods

# はじめに

曖昧なところはRailsのドキュメントをちゃんと読む。

[Rails ActiveRecord::Attributes::ClassMethodsのドキュメント](https://api.rubyonrails.org/classes/ActiveRecord/Attributes/ClassMethods.html)を追っていく。

最近フォームオブジェクトを作っているときに、自分の中で挙動が曖昧になっている `attribute` について理解を深める。

`attr_accessor` の型指定できるやつ、という認識だった。

# 概要

## attribute(name, cast_type = Type::Value.new, **options)

モデルの型を持つ属性を定義します。必要に応じて、既存の属性の型をオーバーライドします。
これにより、 **モデルに割り当てられたときに、SQLとの間で値を変換する方法を制御** できます。また、 `ActiveRecord :: Base.where` に渡される値の振る舞いを変更します。これにより、実装の詳細やモンキーパッチに頼ることなく、Active Recordの大部分でドメインオブジェクトを使用できます。

`name` : attributeメソッドを定義するメソッドの名前、および保持されるカラム。

`cast_type` : `:string` や `:integer` などのシンボル、またはこの属性に使用する型オブジェクト。カスタムタイプオブジェクトの提供の詳細については、以下の例を参照。

### オプション

以下のオプションを使うことができます。

`default` : 値が与えられない場合に使用するデフォルト値。このオプションを指定しない場合、前のデフォルト値（？）があればそれが使われ、なければデフォルト値は `nil` になる。

`array` （PostgreSQLのみ） : 型が `array` であることを示す。

`range` （PostgreSQLのみ） : 型が `range` であることを示す。

`cast_type` のシンボルを使用すると、追加のオプションが型オブジェクトのコンストラクタに渡される。

### 例

ActiveRecordによって検出された型はオーバーライドできます。

```rb
# db/schema.rb
create_table :store_listings, force: true do |t|
  t.decimal :price_in_cents
end

# app/models/store_listing.rb
class StoreListing < ActiveRecord::Base
end

store_listing = StoreListing.new(price_in_cents: '10.1')

# before
store_listing.price_in_cents #=> BigDecimal(10.1)

class StoreListing < ActiveRecord::Base
  attribute :price_in_cents, :integer
end

# after
store_listing.price_in_cents # => 10
```

デフォルト値が渡されるケースもあります。

```rb
# db/schema.rb
create_table :store_listings, force: true do |t|
  t.decimal :my_string, default: 'original default'
end

StoreListing.new.my_string # => "original default"

# app/models/store_listing.rb
class StoreListing < ActiveRecord::Base
  attribute :my_string, :string, default: "new default"
end

StoreListing.new.my_string # => "new default"

class Product < ActiveRecord::Base
  attribute :my_default_proc, :datetime, default: -> { Time.now }
end

Product.new.my_default_proc # => 2015-05-30 11:04:48 -0600
sleep 1
Product.new.my_default_proc # => 2015-05-30 11:04:49 -0600
```

Attributes はデータベースのカラムに基づく必要はありません。

```rb
# app/models/my_model.rb
class MyModel < ActiveRecord::Base
  attribute :my_string, :string
  attribute :my_int_array, :integer, array: true
  attribute :my_float_range, :float, range: true
end

model = MyModel.new(
  my_string: "string",
  my_int_array: ["1", "2", "3"]
  my_float_range: "[1,3.5]",
)
model.attributes
# =>
  {
    my_string: "string",
    my_int_array: [1, 2, 3],
    my_float_range: 1.0..3.5
  }
```

型コンストラクタにオプションを渡した場合

```rb
# app/models/my_model.rb
class MyModel < ActiveRecord::Base
  attribute :small_int, :integer, limit: 2
end

MyModel.create(small_int: 65537)
# => Error: 65537 is out of range for the limit of two bytes
```

### カスタムタイプを作ってみる

ユーザーは、値の型において定義されたメソッドである限り、カスタムタイプを定義することもできます。 `deserialize` や `cast` メソッドは、データベースまたはコントローラーからの生の入力を使用して、独自の型オブジェクトによって呼び出されます。 期待されるAPIは `ActiveModel::Type::Value` を見てください。型オブジェクトは既存の型か `ActiveRecord::Type::Value` を継承することが推奨されます。

```rb
class MoneyType < ActiveRecord::Type::Integer
  def cast(value)
    if !value.kind_of?(Numeric) && value.include?('$')
      price_in_dollars 0 value.gsub(/\$/, '').to_f
      super(price_in_dollars * 100)
    else
      super
    end
  end
end

# config/initializers/types.rb
ActiveRecord::Type.register(:money, MoneyType)

# app/models/store_listing.rb
class StoreListing < ActiveRecord::Base
  attribute :price_in_cents, :money
end

store_listing = StoreListing.new(price_in_cents: '$10.00')
store_listing.price_in_cents # => 1000
```

カスタムタイプの詳細は `ActiveModel::Type::Value` のドキュメントを読んでください。シンボルで参照される独自の型については、 `ActiveRecord::Type.register` のドキュメントを読んでください。
シンボルの代わりに、型オブジェクトを直接渡すこともできます。

### クエリ

`ActiveRecord::Base.where` が呼ばれると、モデルクラスによって定義された型を使用して、 `serialize` メソッドを独自の型オブジェクトに対して呼ぶことで、SQLの値に変換します。

#### 例

```rb
class Money < Struct.new(:amount, :currency)
end

class MoneyType < Type::Value
  def initialize(currency_converter:)
    @currency_converter = currency_converter
  end

  # value will be the result of +deserialiez+ or
  # +cast+. Assumed to be an instance of +Money+ in
  # this case.
  def serialize(value)
    value_in_bitcoins = @currency_converter.convert_to_bitcoins(value)
    value_in_bitcoints.amount
  end
end

# config/initializers/types.rb
ActiveRecord::Type.register(:money, MoneyType)

# app/models/product.rb
class Product < ActiveRecord::Base
  currency_converter = ConversionRatesFromTheInternet.new
  attribute :price_in_bitcoins, :money, currency_converter: currency_converter
end

Product.where(price_in_bitcoins: Money.new(5, "USD"))
# => SELECT * FROM products WHERE price_in_bitcoins = 0.02230

Product.where(price_in_bitcoins: Money.new(5, "GBP"))
# => SELECT * FROM products WHERE price_in_bitcoins = 0.03412
```

### Dirty Tracking

`attribute` の型は、dirty trackingの振る舞いを変えることもできます。 `changed?` や `changed_in_pace?` メソッドは `ActiveModel::Dirty` クラスから呼ばれます。詳細は `ActiveModel::Type::Value` クラスのメソッドを見てください。

```rb
# File activerecord/lib/active_record/attributes.rb, line 208
def attribute(name, cast_type = Type::Value.new, **options)
  name = name.to_s
  reload_schema_from_cache

  self.attributes_to_define_after_schema_loads = 
    attributes_to_define_after_schema_loads.merge(
      name => [cast_type, options]
    )
end
```

### define_attribute( name, cast_type, default: NO_DEFAULT_PROVIDED, user_provided_default: true )

これは低レベルな `attribute` の下に位置するAPIです。型オブジェクトのみを受け入れ、スキーマが読み込まれるのを待つのでなく、すぐに処理を実行します。自動スキーマ検出と `ClassMethods#attribute` が両方とも、これを内部で呼び出します。このメソッドはプラグインの作成者が使用できるように提供されていますが、アプリケーションコードではおそらく `ClassMethods#attribute` を使用する必要があります。

`name` : 定義される属性の名前。 `String` であることが期待されます。

`cast_type` : この属性に使用する型オブジェクト。

`default` : 値が与えられない場合に使用するデフォルト値。このオプションを指定しない場合、前のデフォルト値（？）があればそれが使われ、なければデフォルト値は `nil` になる。プロシージャも渡すことができ、新しい値が必要になるたびに呼び出される。

`user_provided_default` : デフォルト値は `cast` か `deserialize` メソッドを使ってキャストされる。

```rb
# File activerecord/lib/actvie_record/attributes.rb, line 236
def define_attribute(
  name,
  cast_type,
  default: NO_DEFAULT_PROVIDED,
  user_provided_default: true
)
  attribute_types[name] = cast_type
  define_default_attribute(name, default, cast_type, from_user: user_provided_default)
end
```

## まとめ