Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails API: ActiveRecord::Attributes::ClassMethodsの#attributeと#define_attribute(翻訳)

概要

MITライセンスに基づいて翻訳します。

y-yagiさんの以下の記事も参考になります。

ActiveRecord::Attributes::ClassMethodsのインスタンスpublicメソッド

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

attributeメソッドは、型を1つ持つ属性(attribute)をこのモデルに定義し、必要に応じて既存の属性の型を上書きします。このメソッドを用いて、モデルに代入される値をSQLと相互変換する方法を制御できます。また、ActiveRecord::Base.whereに渡される値の振る舞いも変更されます。これにより、実装の詳細やモンキーパッチに頼らずに、自分のドメインオブジェクトを多くのActive Recordで使えるようになります。

  • name: 属性メソッドの定義に用いる名前や、この属性の永続化先となるカラムを定義します。

  • cast_type: :string:integerなどのシンボルか、この属性で使われる型オブジェクトを指定します。独自の型オブジェクトを提供する方法についてはこの後のコード例をご覧ください。

オプション

**optionsでは以下のオプションを利用できます。

  • default: 値が渡されない場合のデフォルト値です。このオプションが指定されていない場合、直前のデフォルト値があればそれがデフォルト値になり、ない場合はnilがデフォルト値になります。

  • array: (PostgreSQL限定)arrayを型とすべきであることを指定します。

  • range:(PostgreSQL限定)rangeを型とすべきであることを指定します(以下のコード例を参照)。

コード例

Active Recordが検出する型をオーバーライドできます。

# 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')

# 変更前
store_listing.price_in_cents # => BigDecimal(10.1)

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

# 変更後
store_listing.price_in_cents # => 10

デフォルトの型も指定できます。

# db/schema.rb
create_table :store_listings, force: true do |t|
  t.string :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

属性の背後にデータベースカラムがなくても構いません。

# 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
  }

独自の型を作成する

値の型に定義されるメソッドに応答する限り、独自の型を定義することもできます。以下のコード例のdeserializecastはあなたのオブジェクトで呼び出され、データベースやコントローラからのraw入力を取ります。ここで期待されるAPIについてはActiveModel::Type::Valueを参照してください。利用する型オブジェクトは、既存の型かActiveModel::Type::Valueを継承することを推奨します。

class MoneyType < ActiveRecord::Type::Integer
  def cast(value)
    if !value.kind_of?(Numeric) && value.include?('$')
      price_in_dollars = 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が呼び出されると、このモデルクラスで定義された型を用いて値をSQLに変換し、利用する型オブジェクト上でserializeを呼び出します。次の例をご覧ください。

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

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

  # 値はdeserializeかcastの結果となる
  # この場合Moneyのインスタンスとなる前提
  def serialize(value)
    value_in_bitcoins = @currency_converter.convert_to_bitcoins(value)
    value_in_bitcoins.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トラッキング

ある属性の型は、dirtyトラッキングが行われると変更される可能性があります。changed?メソッドやchanged_in_place?メソッドはActiveModel::Dirtyから呼び出されます。これらのメソッドについて詳しくはActiveModel::Type::Valueのドキュメントをご覧ください。


  • ソースコード
# File activerecord/lib/active_record/attributes.rb, line 195
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 )

define_attributeメソッドは、attributeメソッドを支える低レベルAPIであり、型オブジェクトのみを受け取って、スキーマが読み込まれるのを待たずにただちに動作します。自動スキーマ検出や#attributeは、どちらも背後でdefine_attributeを呼び出します。define_attributeメソッドが提供されていることでプラグイン作者もこのメソッドを利用できますが、アプリのコードではdefine_attributeメソッドではなくattributeメソッドを使うべきです。

  • name: 定義される属性名です。Stringが期待されます。

  • cast_type: この属性で用いる型オブジェクト

  • default: 値が渡されない場合のデフォルト値です。このオプションが指定されていない場合、直前のデフォルト値があればそれがデフォルト値になり、ない場合はnilがデフォルト値になります。procを渡すことも可能であり、その場合新しい値が必要になるたびにprocが1回呼び出されます。

  • user_provided_default: デフォルト値をcastdeserializeでキャストすべきかどうかを指定します。


  • ソースコード
# File activerecord/lib/active_record/attributes.rb, line 223
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

GitHub

関連記事

Rails 5.1〜7.2: 'form_with' APIドキュメント(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。