[RUBY] [Rails] ActiveRecord :: Attributes :: ClassMethods

Introduction

For ambiguities, read the Rails documentation properly.

Follow Rails ActiveRecord :: Attributes :: ClassMethods documentation.

When I'm creating a form object recently, I'll get a better understanding of ʻattribute`, which is ambiguous within me.

It was a recognition that ʻattr_accessor` can be typed.

Overview

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

Defines an attribute that has a model type. Overrides existing attribute types as needed. This allows you to ** control how the value is converted to and from SQL when assigned to the model **. It also changes the behavior of the value passed to ʻActiveRecord :: Base.where`. This allows you to use domain objects with most of Active Record without resorting to implementation details or monkey patches.

name: The name of the method that defines the attribute method, and the columns that are retained.

cast_type: A symbol such as: string or : integer, or the type object used for this attribute. See the example below for more information on providing custom type objects.

option

The following options are available.

default: The default value to use if no value is given. If this option is not specified, the previous default value (?) Will be used if it exists, otherwise the default value will be nil.

ʻArray (PostgreSQL only): Indicates that the type is ʻarray.

range (PostgreSQL only): Indicates that the type is range.

Using the cast_type symbol passes additional options to the type object's constructor.

Example

You can override the types found by ActiveRecord.

# 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

In some cases the default value is passed.

# 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 do not have to be based on database columns.

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

If you pass an option to the type constructor

# 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

Try to make a custom type

The user can also define a custom type as long as it is a method defined in the value type. The deserialize and cast methods are called by their own type object, using raw input from the database or controller. See ʻActiveModel :: Type :: Value for the expected API. It is recommended that the type object inherits an existing type or ʻActiveRecord :: Type :: Value.

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

Read the ʻActiveModel :: Type :: Value documentation for more information on custom types. Read the ʻActiveRecord :: Type.register documentation for the unique types referenced by symbols. You can also pass a type object directly instead of a symbol.

Query

When ʻActiveRecord :: Base.whereis called, it uses the type defined by the model class and converts it to a SQL value by calling theserialize` method on its own type object.

Example

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

The type of ʻattributecan also change the behavior of dirty tracking. Thechanged?andchanged_in_pace? methods are called from the ʻActiveModel :: Dirty class. See the methods of the ʻActiveModel :: Type :: Value` class for details.

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

This is an API located under the low level ʻattribute. Accepts only type objects and does the work immediately instead of waiting for the schema to load. Both automatic schema discovery and ClassMethods # attributecall this internally. This method is provided for use by plugin authors, but you probably need to useClassMethods # attribute` in your application code.

name: The name of the attribute to be defined. Expected to be String.

cast_type: The type object used for this attribute.

default: The default value to use if no value is given. If this option is not specified, the previous default value (?) Will be used if it exists, otherwise the default value will be nil. You can also pass a procedure, which will be called whenever a new value is needed.

ʻUser_provided_default: The default value is cast using the cast or deserialize` methods.

# 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

Summary

Recommended Posts

[Rails] ActiveRecord :: Attributes :: ClassMethods
[Rails] ActiveRecord
Resolve ActiveRecord :: NoDatabaseError on rails6
Rails 5 Code Reading Part 1 ~ ActiveRecord new Method ~
[Rails] How to use ActiveRecord :: Bitemporal (BiTemporalDataModel)