diff options
Diffstat (limited to 'activerecord/lib/active_record/attributes.rb')
-rw-r--r-- | activerecord/lib/active_record/attributes.rb | 256 |
1 files changed, 177 insertions, 79 deletions
diff --git a/activerecord/lib/active_record/attributes.rb b/activerecord/lib/active_record/attributes.rb index b263a89d79..c8979a60d7 100644 --- a/activerecord/lib/active_record/attributes.rb +++ b/activerecord/lib/active_record/attributes.rb @@ -1,33 +1,42 @@ module ActiveRecord - module Attributes # :nodoc: + # See ActiveRecord::Attributes::ClassMethods for documentation + module Attributes extend ActiveSupport::Concern + # :nodoc: Type = ActiveRecord::Type included do - class_attribute :user_provided_columns, instance_accessor: false # :internal: - class_attribute :user_provided_defaults, instance_accessor: false # :internal: - self.user_provided_columns = {} - self.user_provided_defaults = {} - - delegate :persistable_attribute_names, to: :class + class_attribute :attributes_to_define_after_schema_loads, instance_accessor: false # :internal: + self.attributes_to_define_after_schema_loads = {} end - module ClassMethods # :nodoc: - # Defines or overrides a attribute on this model. This allows customization of - # Active Record's type casting behavior, as well as adding support for user defined - # types. + module ClassMethods + # Defines an attribute with a type on this model. It will override the + # type of existing attributes if needed. This allows control over how + # values are converted to and from SQL when assigned to a model. It also + # changes the behavior of values passed to + # ActiveRecord::QueryMethods#where. This will let you use + # your domain objects across much of Active Record, without having to + # rely on implementation details or monkey patching. + # + # +name+ The name of the methods to define attribute methods for, and the + # column which this will persist to. + # + # +cast_type+ A symbol such as +:string+ or +:integer+, or a type object + # to be used for this attribute. See the examples below for more + # information about providing custom type objects. # - # +name+ The name of the methods to define attribute methods for, and the column which - # this will persist to. + # ==== Options + # The following options are accepted: # - # +cast_type+ A type object that contains information about how to type cast the value. - # See the examples section for more information. + # +default+ The default value to use when no value is provided. If this option + # is not passed, the previous default value (if any) will be used. + # Otherwise, the default will be +nil+. # - # ==== Options - # The options hash accepts the following options: + # +array+ (PG only) specifies that the type should be an array (see the examples below). # - # +default+ is the default value that the column should use on a new record. + # +range+ (PG only) specifies that the type should be a range (see the examples below). # # ==== Examples # @@ -48,99 +57,188 @@ module ActiveRecord # store_listing.price_in_cents # => BigDecimal.new(10.1) # # class StoreListing < ActiveRecord::Base - # attribute :price_in_cents, Type::Integer.new + # attribute :price_in_cents, :integer # end # # # after # store_listing.price_in_cents # => 10 # - # Users may also define their own custom types, as long as they respond to the methods - # defined on the value type. The +type_cast+ method on your type object will be called - # with values both from the database, and from your controllers. See - # +ActiveRecord::Attributes::Type::Value+ for the expected API. It is recommended that your - # type objects inherit from an existing type, or the base value type. + # A default can also be provided. + # + # create_table :store_listings, force: true do |t| + # t.string :my_string, default: "original default" + # end + # + # StoreListing.new.my_string # => "original default" + # + # class StoreListing < ActiveRecord::Base + # attribute :my_string, :string, default: "new default" + # end + # + # StoreListing.new.my_string # => "new default" + # + # Attributes do not need to be backed by a database column. + # + # 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 + # } + # + # ==== Creating Custom Types + # + # Users may also define their own custom types, as long as they respond + # to the methods defined on the value type. The method + # +deserialize+ or +cast+ will be called on + # your type object, with raw input from the database or from your + # controllers. See ActiveRecord::Type::Value for the expected API. It is + # recommended that your type objects inherit from an existing type, or + # from ActiveRecord::Type::Value # # class MoneyType < ActiveRecord::Type::Integer - # def type_cast(value) + # def cast(value) # if value.include?('$') # price_in_dollars = value.gsub(/\$/, '').to_f - # price_in_dollars * 100 + # super(price_in_dollars * 100) # else - # value.to_i + # 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, MoneyType.new + # attribute :price_in_cents, :money # end # # store_listing = StoreListing.new(price_in_cents: '$10.00') # store_listing.price_in_cents # => 1000 - def attribute(name, cast_type, options = {}) + # + # For more details on creating custom types, see the documentation for + # ActiveRecord::Type::Value. For more details on registering your types + # to be referenced by a symbol, see ActiveRecord::Type.register. You can + # also pass a type object directly, in place of a symbol. + # + # ==== Querying + # + # When ActiveRecord::QueryMethods#where is called, it will + # use the type defined by the model class to convert the value to SQL, + # calling +serialize+ on your type object. For 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 +deserialize+ or + # # +cast+. Assumed to be in instance of +Money+ in + # # this case. + # def serialize(value) + # value_in_bitcoins = @currency_converter.convert_to_bitcoins(value) + # value_in_bitcoins.amount + # end + # end + # + # ActiveRecord::Type.register(:money, MoneyType) + # + # class Product < ActiveRecord::Base + # currency_converter = ConversionRatesFromTheInternet.new + # attribute :price_in_bitcoins, :money, 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 an attribute is given the opportunity to change how dirty + # tracking is performed. The methods +changed?+ and +changed_in_place?+ + # will be called from ActiveModel::Dirty. See the documentation for those + # methods in ActiveRecord::Type::Value for more details. + def attribute(name, cast_type, **options) name = name.to_s - clear_caches_calculated_from_columns - # Assign a new hash to ensure that subclasses do not share a hash - self.user_provided_columns = user_provided_columns.merge(name => cast_type) - - if options.key?(:default) - self.user_provided_defaults = user_provided_defaults.merge(name => options[:default]) - end - end + reload_schema_from_cache - # Returns an array of column objects for the table associated with this class. - def columns - @columns ||= add_user_provided_columns(connection.schema_cache.columns(table_name)) + self.attributes_to_define_after_schema_loads = + attributes_to_define_after_schema_loads.merge( + name => [cast_type, options] + ) end - # Returns a hash of column objects for the table associated with this class. - def columns_hash - @columns_hash ||= Hash[columns.map { |c| [c.name, c] }] + # This is the low level API which sits beneath +attribute+. It only + # accepts type objects, and will do its work immediately instead of + # waiting for the schema to load. Automatic schema detection and + # ClassMethods#attribute both call this under the hood. While this method + # is provided so it can be used by plugin authors, application code + # should probably use ClassMethods#attribute. + # + # +name+ The name of the attribute being defined. Expected to be a +String+. + # + # +cast_type+ The type object to use for this attribute. + # + # +default+ The default value to use when no value is provided. If this option + # is not passed, the previous default value (if any) will be used. + # Otherwise, the default will be +nil+. + # + # +user_provided_default+ Whether the default value should be cast using + # +cast+ or +deserialize+. + 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 - def persistable_attribute_names # :nodoc: - @persistable_attribute_names ||= connection.schema_cache.columns_hash(table_name).keys - end - - def reset_column_information # :nodoc: + def load_schema! # :nodoc: super - clear_caches_calculated_from_columns - end - - private - - def add_user_provided_columns(schema_columns) - existing_columns = schema_columns.map do |column| - new_type = user_provided_columns[column.name] - if new_type - column.with_type(new_type) - else - column + attributes_to_define_after_schema_loads.each do |name, (type, options)| + if type.is_a?(Symbol) + type = ActiveRecord::Type.lookup(type, **options.except(:default)) end - end - existing_column_names = existing_columns.map(&:name) - new_columns = user_provided_columns.except(*existing_column_names).map do |(name, type)| - connection.new_column(name, nil, type) + define_attribute(name, type, **options.slice(:default)) end - - existing_columns + new_columns end - def clear_caches_calculated_from_columns - @arel_table = nil - @attributes_builder = nil - @column_names = nil - @column_types = nil - @columns = nil - @columns_hash = nil - @content_columns = nil - @default_attributes = nil - @persistable_attribute_names = nil - end + private + + NO_DEFAULT_PROVIDED = Object.new # :nodoc: + private_constant :NO_DEFAULT_PROVIDED - def raw_default_values - super.merge(user_provided_defaults) + def define_default_attribute(name, value, type, from_user:) + if value == NO_DEFAULT_PROVIDED + default_attribute = _default_attributes[name].with_type(type) + elsif from_user + default_attribute = Attribute.from_user(name, value, type) + else + default_attribute = Attribute.from_database(name, value, type) + end + _default_attributes[name] = default_attribute end end end |