1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
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
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
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
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
|
module ActiveRecord
# See ActiveRecord::Attributes::ClassMethods for documentation
module Attributes
extend ActiveSupport::Concern
# :nodoc:
Type = ActiveRecord::Type
included do
class_attribute :attributes_to_define_after_schema_loads, instance_accessor: false # :internal:
self.attributes_to_define_after_schema_loads = {}
end
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.
#
# ==== Options
# 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+.
#
# +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).
#
# ==== Examples
#
# The type detected by Active Record can be overridden.
#
# # 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
# attribute :price_in_cents, :integer
# end
#
# # after
# store_listing.price_in_cents # => 10
#
# 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
# +type_cast_from_database+ or +type_cast_from_user+ 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_from_user(value)
# if value.include?('$')
# price_in_dollars = value.gsub(/\$/, '').to_f
# super(price_in_dollars * 100)
# else
# super
# end
# end
# end
#
# class StoreListing < ActiveRecord::Base
# attribute :price_in_cents, MoneyType.new
# end
#
# store_listing = StoreListing.new(price_in_cents: '$10.00')
# store_listing.price_in_cents # => 1000
#
# For more details on creating custom types, see the documentation for
# ActiveRecord::Type::Value.
#
# ==== Querying
#
# When ActiveRecord::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 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
reload_schema_from_cache
self.attributes_to_define_after_schema_loads =
attributes_to_define_after_schema_loads.merge(
name => [cast_type, options]
)
end
# 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
# +type_cast_from_user+ or +type_cast_from_database+.
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 load_schema! # :nodoc:
super
attributes_to_define_after_schema_loads.each do |name, (type, options)|
if type.is_a?(Symbol)
type = connection.type_for_attribute_options(type, **options.except(:default))
end
define_attribute(name, type, **options.slice(:default))
end
end
private
NO_DEFAULT_PROVIDED = Object.new # :nodoc:
private_constant :NO_DEFAULT_PROVIDED
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
end
|