attributes.rb 8.1 KB
Newer Older
1
module ActiveRecord
S
Sean Griffin 已提交
2
  module Attributes
3 4
    extend ActiveSupport::Concern

5
    Type = ActiveRecord::Type
6

7
    included do
8 9
      class_attribute :attributes_to_define_after_schema_loads, instance_accessor: false # :internal:
      self.attributes_to_define_after_schema_loads = {}
10 11
    end

S
Sean Griffin 已提交
12 13 14 15 16 17 18 19 20 21 22
    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::Relation::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.
23 24 25 26 27
      #
      # +cast_type+ A type object that contains information about how to type cast the value.
      # See the examples section for more information.
      #
      # ==== Options
S
Sean Griffin 已提交
28 29 30 31 32
      # The following options are accepted:
      #
      # +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+.
33
      #
S
Sean Griffin 已提交
34 35 36
      # +array+ (PG only) specifies that the type should be an array (see the examples below)
      #
      # +range+ (PG only) specifies that the type should be a range (see the examples below)
37
      #
38 39
      # ==== Examples
      #
A
Anton Cherepanov 已提交
40
      # The type detected by Active Record can be overridden.
41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
      #
      #   # 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.new(10.1)
      #
      #   class StoreListing < ActiveRecord::Base
57
      #     attribute :price_in_cents, :integer
58 59 60 61 62
      #   end
      #
      #   # after
      #   store_listing.price_in_cents # => 10
      #
S
Sean Griffin 已提交
63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91
      # 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 +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.
92 93 94 95 96 97 98 99 100 101 102 103 104
      #
      #   class MoneyType < ActiveRecord::Type::Integer
      #     def type_cast(value)
      #       if value.include?('$')
      #         price_in_dollars = value.gsub(/\$/, '').to_f
      #         price_in_dollars * 100
      #       else
      #         value.to_i
      #       end
      #     end
      #   end
      #
      #   class StoreListing < ActiveRecord::Base
S
Sean Griffin 已提交
105
      #     attribute :price_in_cents, MoneyType.new
106 107 108 109
      #   end
      #
      #   store_listing = StoreListing.new(price_in_cents: '$10.00')
      #   store_listing.price_in_cents # => 1000
S
Sean Griffin 已提交
110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154
      #
      # For more details on creating custom types, see the documentation for
      # +ActiveRecord::Type::Value+
      #
      # ==== Querying
      #
      # When +ActiveRecord::Relation::QueryMethods#where+ is called, it will
      # use the type defined by the model class to convert the value to SQL,
      # calling +type_cast_for_database+ 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 +type_cast_from_database+ or
      #     # +type_cast_from_user+. Assumed to be in instance of +Money+ in
      #     # this case.
      #     def type_cast_for_database(value)
      #       value_in_bitcoins = currency_converter.convert_to_bitcoins(value)
      #       value_in_bitcoins.amount
      #     end
      #   end
      #
      #   class Product < ActiveRecord::Base
      #     currency_converter = ConversionRatesFromTheInternet.new
      #     attribute :price_in_bitcoins, MoneyType.new(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 +ActiveRecord::AttributeMethods::Dirty+. See the
      # documentation for those methods in +ActiveRecord::Type::Value+ for more
      # details.
155
      def attribute(name, cast_type, **options)
156
        name = name.to_s
157
        reload_schema_from_cache
158

159 160 161 162
        self.attributes_to_define_after_schema_loads =
          attributes_to_define_after_schema_loads.merge(
            name => [cast_type, options]
          )
163 164
      end

S
Sean Griffin 已提交
165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181
      # 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
      # +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 +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
      # +type_cast_from_user+ or +type_cast_from_database+
182 183 184 185 186 187 188 189
      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)
190 191
      end

S
Sean Griffin 已提交
192
      def load_schema! # :nodoc:
193
        super
194
        attributes_to_define_after_schema_loads.each do |name, (type, options)|
195 196 197 198 199
          if type.is_a?(Symbol)
            type = connection.type_for_attribute_options(type, **options.except(:default))
          end

          define_attribute(name, type, **options.slice(:default))
200
        end
201 202 203 204
      end

      private

205 206
      NO_DEFAULT_PROVIDED = Object.new # :nodoc:
      private_constant :NO_DEFAULT_PROVIDED
207

208 209 210 211 212 213 214
      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)
215
        end
216
        _default_attributes[name] = default_attribute
217
      end
218 219 220
    end
  end
end