activeentity 0.0.1.beta1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (74) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +42 -0
  3. data/README.md +145 -0
  4. data/Rakefile +29 -0
  5. data/lib/active_entity.rb +73 -0
  6. data/lib/active_entity/aggregations.rb +276 -0
  7. data/lib/active_entity/associations.rb +146 -0
  8. data/lib/active_entity/associations/embedded/association.rb +134 -0
  9. data/lib/active_entity/associations/embedded/builder/association.rb +100 -0
  10. data/lib/active_entity/associations/embedded/builder/collection_association.rb +69 -0
  11. data/lib/active_entity/associations/embedded/builder/embedded_in.rb +38 -0
  12. data/lib/active_entity/associations/embedded/builder/embeds_many.rb +13 -0
  13. data/lib/active_entity/associations/embedded/builder/embeds_one.rb +16 -0
  14. data/lib/active_entity/associations/embedded/builder/singular_association.rb +28 -0
  15. data/lib/active_entity/associations/embedded/collection_association.rb +188 -0
  16. data/lib/active_entity/associations/embedded/collection_proxy.rb +310 -0
  17. data/lib/active_entity/associations/embedded/embedded_in_association.rb +31 -0
  18. data/lib/active_entity/associations/embedded/embeds_many_association.rb +15 -0
  19. data/lib/active_entity/associations/embedded/embeds_one_association.rb +19 -0
  20. data/lib/active_entity/associations/embedded/singular_association.rb +35 -0
  21. data/lib/active_entity/attribute_assignment.rb +85 -0
  22. data/lib/active_entity/attribute_decorators.rb +90 -0
  23. data/lib/active_entity/attribute_methods.rb +330 -0
  24. data/lib/active_entity/attribute_methods/before_type_cast.rb +78 -0
  25. data/lib/active_entity/attribute_methods/primary_key.rb +98 -0
  26. data/lib/active_entity/attribute_methods/query.rb +35 -0
  27. data/lib/active_entity/attribute_methods/read.rb +47 -0
  28. data/lib/active_entity/attribute_methods/serialization.rb +90 -0
  29. data/lib/active_entity/attribute_methods/time_zone_conversion.rb +91 -0
  30. data/lib/active_entity/attribute_methods/write.rb +63 -0
  31. data/lib/active_entity/attributes.rb +165 -0
  32. data/lib/active_entity/base.rb +303 -0
  33. data/lib/active_entity/coders/json.rb +15 -0
  34. data/lib/active_entity/coders/yaml_column.rb +50 -0
  35. data/lib/active_entity/core.rb +281 -0
  36. data/lib/active_entity/define_callbacks.rb +17 -0
  37. data/lib/active_entity/enum.rb +234 -0
  38. data/lib/active_entity/errors.rb +80 -0
  39. data/lib/active_entity/gem_version.rb +17 -0
  40. data/lib/active_entity/inheritance.rb +278 -0
  41. data/lib/active_entity/integration.rb +78 -0
  42. data/lib/active_entity/locale/en.yml +45 -0
  43. data/lib/active_entity/model_schema.rb +115 -0
  44. data/lib/active_entity/nested_attributes.rb +592 -0
  45. data/lib/active_entity/readonly_attributes.rb +47 -0
  46. data/lib/active_entity/reflection.rb +441 -0
  47. data/lib/active_entity/serialization.rb +25 -0
  48. data/lib/active_entity/store.rb +242 -0
  49. data/lib/active_entity/translation.rb +24 -0
  50. data/lib/active_entity/type.rb +73 -0
  51. data/lib/active_entity/type/date.rb +9 -0
  52. data/lib/active_entity/type/date_time.rb +9 -0
  53. data/lib/active_entity/type/decimal_without_scale.rb +15 -0
  54. data/lib/active_entity/type/hash_lookup_type_map.rb +25 -0
  55. data/lib/active_entity/type/internal/timezone.rb +17 -0
  56. data/lib/active_entity/type/json.rb +30 -0
  57. data/lib/active_entity/type/modifiers/array.rb +72 -0
  58. data/lib/active_entity/type/registry.rb +92 -0
  59. data/lib/active_entity/type/serialized.rb +71 -0
  60. data/lib/active_entity/type/text.rb +11 -0
  61. data/lib/active_entity/type/time.rb +21 -0
  62. data/lib/active_entity/type/type_map.rb +62 -0
  63. data/lib/active_entity/type/unsigned_integer.rb +17 -0
  64. data/lib/active_entity/validate_embedded_association.rb +305 -0
  65. data/lib/active_entity/validations.rb +50 -0
  66. data/lib/active_entity/validations/absence.rb +25 -0
  67. data/lib/active_entity/validations/associated.rb +60 -0
  68. data/lib/active_entity/validations/length.rb +26 -0
  69. data/lib/active_entity/validations/presence.rb +68 -0
  70. data/lib/active_entity/validations/subset.rb +76 -0
  71. data/lib/active_entity/validations/uniqueness_in_embedding.rb +99 -0
  72. data/lib/active_entity/version.rb +10 -0
  73. data/lib/tasks/active_entity_tasks.rake +6 -0
  74. metadata +155 -0
@@ -0,0 +1,45 @@
1
+ en:
2
+ # Attributes names common to most models
3
+ #attributes:
4
+ #created_at: "Created at"
5
+ #updated_at: "Updated at"
6
+
7
+ # Default error messages
8
+ errors:
9
+ messages:
10
+ non_subset: "is not subset of the given list"
11
+ duplicated: "duplicated"
12
+
13
+ # Active Entity models configuration
14
+ active_entity:
15
+ errors:
16
+ messages:
17
+ record_invalid: "Validation failed: %{errors}"
18
+ # Append your own errors here or at the model/attributes scope.
19
+
20
+ # You can define own errors for models or model attributes.
21
+ # The values :model, :attribute and :value are always available for interpolation.
22
+ #
23
+ # For example,
24
+ # models:
25
+ # user:
26
+ # blank: "This is a custom blank message for %{model}: %{attribute}"
27
+ # attributes:
28
+ # login:
29
+ # blank: "This is a custom blank message for User login"
30
+ # Will define custom blank validation message for User model and
31
+ # custom blank validation message for login attribute of User model.
32
+ #models:
33
+
34
+ # Translate model names. Used in Model.human_name().
35
+ #models:
36
+ # For example,
37
+ # user: "Dude"
38
+ # will translate User model name to "Dude"
39
+
40
+ # Translate model attribute names. Used in Model.human_attribute_name(attribute).
41
+ #attributes:
42
+ # For example,
43
+ # user:
44
+ # login: "Handle"
45
+ # will translate User attribute "login" as "Handle"
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module ActiveEntity
6
+ module ModelSchema
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ delegate :type_for_attribute, to: :class
11
+
12
+ self.inheritance_attribute = "type"
13
+
14
+ initialize_load_schema_monitor
15
+ end
16
+
17
+ module ClassMethods
18
+ def inheritance_attribute
19
+ (@inheritance_attribute ||= nil) || superclass.inheritance_attribute
20
+ end
21
+
22
+ # Sets the value of inheritance_attribute
23
+ def inheritance_attribute=(value)
24
+ @inheritance_attribute = value.to_s
25
+ @explicit_inheritance_attribute = true
26
+ end
27
+
28
+ def attributes_builder # :nodoc:
29
+ unless defined?(@attributes_builder) && @attributes_builder
30
+ defaults = _default_attributes
31
+ @attributes_builder = ActiveModel::AttributeSet::Builder.new(attribute_types, defaults)
32
+ end
33
+ @attributes_builder
34
+ end
35
+
36
+ def attribute_types # :nodoc:
37
+ load_schema
38
+ @attribute_types ||= Hash.new(Type.default_value)
39
+ end
40
+
41
+ def yaml_encoder # :nodoc:
42
+ @yaml_encoder ||= ActiveModel::AttributeSet::YAMLEncoder.new(attribute_types)
43
+ end
44
+
45
+ # Returns the type of the attribute with the given name, after applying
46
+ # all modifiers. This method is the only valid source of information for
47
+ # anything related to the types of a model's attributes. This method will
48
+ # access the database and load the model's schema if it is required.
49
+ #
50
+ # The return value of this method will implement the interface described
51
+ # by ActiveModel::Type::Value (though the object itself may not subclass
52
+ # it).
53
+ #
54
+ # +attr_name+ The name of the attribute to retrieve the type for. Must be
55
+ # a string or a symbol.
56
+ def type_for_attribute(attr_name, &block)
57
+ attr_name = attr_name.to_s
58
+ if block
59
+ attribute_types.fetch(attr_name, &block)
60
+ else
61
+ attribute_types[attr_name]
62
+ end
63
+ end
64
+
65
+ def _default_attributes # :nodoc:
66
+ load_schema
67
+ @default_attributes ||= ActiveModel::AttributeSet.new({})
68
+ end
69
+
70
+ protected
71
+
72
+ def initialize_load_schema_monitor
73
+ @load_schema_monitor = Monitor.new
74
+ end
75
+
76
+ private
77
+
78
+ def inherited(child_class)
79
+ super
80
+ child_class.initialize_load_schema_monitor
81
+ end
82
+
83
+ def schema_loaded?
84
+ defined?(@schema_loaded) && @schema_loaded
85
+ end
86
+
87
+ def load_schema
88
+ return if schema_loaded?
89
+ @load_schema_monitor.synchronize do
90
+ return if defined?(@load_schema_invoked) && @load_schema_invoked
91
+
92
+ load_schema!
93
+ @schema_loaded = true
94
+ end
95
+ end
96
+
97
+ def load_schema!
98
+ @load_schema_invoked = true
99
+ end
100
+
101
+ def reload_schema_from_cache
102
+ @attribute_types = nil
103
+ @default_attributes = nil
104
+ @attributes_builder = nil
105
+ @schema_loaded = false
106
+ @load_schema_invoked = false
107
+ @attribute_names = nil
108
+ @yaml_encoder = nil
109
+ direct_descendants.each do |descendant|
110
+ descendant.send(:reload_schema_from_cache)
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,592 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/hash/except"
4
+ require "active_support/core_ext/module/redefine_method"
5
+ require "active_support/core_ext/object/try"
6
+ require "active_support/core_ext/hash/indifferent_access"
7
+
8
+ module ActiveEntity
9
+ module NestedAttributes #:nodoc:
10
+ class TooManyRecords < ActiveEntityError
11
+ end
12
+
13
+ extend ActiveSupport::Concern
14
+
15
+ included do
16
+ class_attribute :nested_attributes_options, instance_writer: false, default: {}
17
+ end
18
+
19
+ # = Active Entity Nested Attributes
20
+ #
21
+ # Nested attributes allow you to save attributes on associated records
22
+ # through the parent. By default nested attribute updating is turned off
23
+ # and you can enable it using the accepts_nested_attributes_for class
24
+ # method. When you enable nested attributes an attribute writer is
25
+ # defined on the model.
26
+ #
27
+ # The attribute writer is named after the association, which means that
28
+ # in the following example, two new methods are added to your model:
29
+ #
30
+ # <tt>author_attributes=(attributes)</tt> and
31
+ # <tt>pages_attributes=(attributes)</tt>.
32
+ #
33
+ # class Book < ActiveEntity::Base
34
+ # has_one :author
35
+ # has_many :pages
36
+ #
37
+ # accepts_nested_attributes_for :author, :pages
38
+ # end
39
+ #
40
+ # Note that the <tt>:autosave</tt> option is automatically enabled on every
41
+ # association that accepts_nested_attributes_for is used for.
42
+ #
43
+ # === One-to-one
44
+ #
45
+ # Consider a Member model that has one Avatar:
46
+ #
47
+ # class Member < ActiveEntity::Base
48
+ # has_one :avatar
49
+ # accepts_nested_attributes_for :avatar
50
+ # end
51
+ #
52
+ # Enabling nested attributes on a one-to-one association allows you to
53
+ # create the member and avatar in one go:
54
+ #
55
+ # params = { member: { name: 'Jack', avatar_attributes: { icon: 'smiling' } } }
56
+ # member = Member.create(params[:member])
57
+ # member.avatar.id # => 2
58
+ # member.avatar.icon # => 'smiling'
59
+ #
60
+ # It also allows you to update the avatar through the member:
61
+ #
62
+ # params = { member: { avatar_attributes: { id: '2', icon: 'sad' } } }
63
+ # member.update params[:member]
64
+ # member.avatar.icon # => 'sad'
65
+ #
66
+ # If you want to update the current avatar without providing the id, you must add <tt>:update_only</tt> option.
67
+ #
68
+ # class Member < ActiveEntity::Base
69
+ # has_one :avatar
70
+ # accepts_nested_attributes_for :avatar, update_only: true
71
+ # end
72
+ #
73
+ # params = { member: { avatar_attributes: { icon: 'sad' } } }
74
+ # member.update params[:member]
75
+ # member.avatar.id # => 2
76
+ # member.avatar.icon # => 'sad'
77
+ #
78
+ # By default you will only be able to set and update attributes on the
79
+ # associated model. If you want to destroy the associated model through the
80
+ # attributes hash, you have to enable it first using the
81
+ # <tt>:allow_destroy</tt> option.
82
+ #
83
+ # class Member < ActiveEntity::Base
84
+ # has_one :avatar
85
+ # accepts_nested_attributes_for :avatar, allow_destroy: true
86
+ # end
87
+ #
88
+ # Now, when you add the <tt>_destroy</tt> key to the attributes hash, with a
89
+ # value that evaluates to +true+, you will destroy the associated model:
90
+ #
91
+ # member.avatar_attributes = { id: '2', _destroy: '1' }
92
+ # member.avatar.marked_for_destruction? # => true
93
+ # member.save
94
+ # member.reload.avatar # => nil
95
+ #
96
+ # Note that the model will _not_ be destroyed until the parent is saved.
97
+ #
98
+ # Also note that the model will not be destroyed unless you also specify
99
+ # its id in the updated hash.
100
+ #
101
+ # === One-to-many
102
+ #
103
+ # Consider a member that has a number of posts:
104
+ #
105
+ # class Member < ActiveEntity::Base
106
+ # has_many :posts
107
+ # accepts_nested_attributes_for :posts
108
+ # end
109
+ #
110
+ # You can now set or update attributes on the associated posts through
111
+ # an attribute hash for a member: include the key +:posts_attributes+
112
+ # with an array of hashes of post attributes as a value.
113
+ #
114
+ # For each hash that does _not_ have an <tt>id</tt> key a new record will
115
+ # be instantiated, unless the hash also contains a <tt>_destroy</tt> key
116
+ # that evaluates to +true+.
117
+ #
118
+ # params = { member: {
119
+ # name: 'joe', posts_attributes: [
120
+ # { title: 'Kari, the awesome Ruby documentation browser!' },
121
+ # { title: 'The egalitarian assumption of the modern citizen' },
122
+ # { title: '', _destroy: '1' } # this will be ignored
123
+ # ]
124
+ # }}
125
+ #
126
+ # member = Member.create(params[:member])
127
+ # member.posts.length # => 2
128
+ # member.posts.first.title # => 'Kari, the awesome Ruby documentation browser!'
129
+ # member.posts.second.title # => 'The egalitarian assumption of the modern citizen'
130
+ #
131
+ # You may also set a +:reject_if+ proc to silently ignore any new record
132
+ # hashes if they fail to pass your criteria. For example, the previous
133
+ # example could be rewritten as:
134
+ #
135
+ # class Member < ActiveEntity::Base
136
+ # has_many :posts
137
+ # accepts_nested_attributes_for :posts, reject_if: proc { |attributes| attributes['title'].blank? }
138
+ # end
139
+ #
140
+ # params = { member: {
141
+ # name: 'joe', posts_attributes: [
142
+ # { title: 'Kari, the awesome Ruby documentation browser!' },
143
+ # { title: 'The egalitarian assumption of the modern citizen' },
144
+ # { title: '' } # this will be ignored because of the :reject_if proc
145
+ # ]
146
+ # }}
147
+ #
148
+ # member = Member.create(params[:member])
149
+ # member.posts.length # => 2
150
+ # member.posts.first.title # => 'Kari, the awesome Ruby documentation browser!'
151
+ # member.posts.second.title # => 'The egalitarian assumption of the modern citizen'
152
+ #
153
+ # Alternatively, +:reject_if+ also accepts a symbol for using methods:
154
+ #
155
+ # class Member < ActiveEntity::Base
156
+ # has_many :posts
157
+ # accepts_nested_attributes_for :posts, reject_if: :new_record?
158
+ # end
159
+ #
160
+ # class Member < ActiveEntity::Base
161
+ # has_many :posts
162
+ # accepts_nested_attributes_for :posts, reject_if: :reject_posts
163
+ #
164
+ # def reject_posts(attributes)
165
+ # attributes['title'].blank?
166
+ # end
167
+ # end
168
+ #
169
+ # If the hash contains an <tt>id</tt> key that matches an already
170
+ # associated record, the matching record will be modified:
171
+ #
172
+ # member.attributes = {
173
+ # name: 'Joe',
174
+ # posts_attributes: [
175
+ # { id: 1, title: '[UPDATED] An, as of yet, undisclosed awesome Ruby documentation browser!' },
176
+ # { id: 2, title: '[UPDATED] other post' }
177
+ # ]
178
+ # }
179
+ #
180
+ # member.posts.first.title # => '[UPDATED] An, as of yet, undisclosed awesome Ruby documentation browser!'
181
+ # member.posts.second.title # => '[UPDATED] other post'
182
+ #
183
+ # However, the above applies if the parent model is being updated as well.
184
+ # For example, If you wanted to create a +member+ named _joe_ and wanted to
185
+ # update the +posts+ at the same time, that would give an
186
+ # ActiveEntity::RecordNotFound error.
187
+ #
188
+ # By default the associated records are protected from being destroyed. If
189
+ # you want to destroy any of the associated records through the attributes
190
+ # hash, you have to enable it first using the <tt>:allow_destroy</tt>
191
+ # option. This will allow you to also use the <tt>_destroy</tt> key to
192
+ # destroy existing records:
193
+ #
194
+ # class Member < ActiveEntity::Base
195
+ # has_many :posts
196
+ # accepts_nested_attributes_for :posts, allow_destroy: true
197
+ # end
198
+ #
199
+ # params = { member: {
200
+ # posts_attributes: [{ id: '2', _destroy: '1' }]
201
+ # }}
202
+ #
203
+ # member.attributes = params[:member]
204
+ # member.posts.detect { |p| p.id == 2 }.marked_for_destruction? # => true
205
+ # member.posts.length # => 2
206
+ # member.save
207
+ # member.reload.posts.length # => 1
208
+ #
209
+ # Nested attributes for an associated collection can also be passed in
210
+ # the form of a hash of hashes instead of an array of hashes:
211
+ #
212
+ # Member.create(
213
+ # name: 'joe',
214
+ # posts_attributes: {
215
+ # first: { title: 'Foo' },
216
+ # second: { title: 'Bar' }
217
+ # }
218
+ # )
219
+ #
220
+ # has the same effect as
221
+ #
222
+ # Member.create(
223
+ # name: 'joe',
224
+ # posts_attributes: [
225
+ # { title: 'Foo' },
226
+ # { title: 'Bar' }
227
+ # ]
228
+ # )
229
+ #
230
+ # The keys of the hash which is the value for +:posts_attributes+ are
231
+ # ignored in this case.
232
+ # However, it is not allowed to use <tt>'id'</tt> or <tt>:id</tt> for one of
233
+ # such keys, otherwise the hash will be wrapped in an array and
234
+ # interpreted as an attribute hash for a single post.
235
+ #
236
+ # Passing attributes for an associated collection in the form of a hash
237
+ # of hashes can be used with hashes generated from HTTP/HTML parameters,
238
+ # where there may be no natural way to submit an array of hashes.
239
+ #
240
+ # === Saving
241
+ #
242
+ # All changes to models, including the destruction of those marked for
243
+ # destruction, are saved and destroyed automatically and atomically when
244
+ # the parent model is saved. This happens inside the transaction initiated
245
+ # by the parent's save method. See ActiveEntity::AutosaveAssociation.
246
+ #
247
+ # === Validating the presence of a parent model
248
+ #
249
+ # If you want to validate that a child record is associated with a parent
250
+ # record, you can use the +validates_presence_of+ method and the +:inverse_of+
251
+ # key as this example illustrates:
252
+ #
253
+ # class Member < ActiveEntity::Base
254
+ # has_many :posts, inverse_of: :member
255
+ # accepts_nested_attributes_for :posts
256
+ # end
257
+ #
258
+ # class Post < ActiveEntity::Base
259
+ # belongs_to :member, inverse_of: :posts
260
+ # validates_presence_of :member
261
+ # end
262
+ #
263
+ # Note that if you do not specify the +:inverse_of+ option, then
264
+ # Active Entity will try to automatically guess the inverse association
265
+ # based on heuristics.
266
+ #
267
+ # For one-to-one nested associations, if you build the new (in-memory)
268
+ # child object yourself before assignment, then this module will not
269
+ # overwrite it, e.g.:
270
+ #
271
+ # class Member < ActiveEntity::Base
272
+ # has_one :avatar
273
+ # accepts_nested_attributes_for :avatar
274
+ #
275
+ # def avatar
276
+ # super || build_avatar(width: 200)
277
+ # end
278
+ # end
279
+ #
280
+ # member = Member.new
281
+ # member.avatar_attributes = {icon: 'sad'}
282
+ # member.avatar.width # => 200
283
+ module ClassMethods
284
+ REJECT_ALL_BLANK_PROC = proc { |attributes| attributes.all? { |key, value| key == "_destroy" || value.blank? } }
285
+
286
+ # Defines an attributes writer for the specified association(s).
287
+ #
288
+ # Supported options:
289
+ # [:allow_destroy]
290
+ # If true, destroys any members from the attributes hash with a
291
+ # <tt>_destroy</tt> key and a value that evaluates to +true+
292
+ # (eg. 1, '1', true, or 'true'). This option is off by default.
293
+ # [:reject_if]
294
+ # Allows you to specify a Proc or a Symbol pointing to a method
295
+ # that checks whether a record should be built for a certain attribute
296
+ # hash. The hash is passed to the supplied Proc or the method
297
+ # and it should return either +true+ or +false+. When no +:reject_if+
298
+ # is specified, a record will be built for all attribute hashes that
299
+ # do not have a <tt>_destroy</tt> value that evaluates to true.
300
+ # Passing <tt>:all_blank</tt> instead of a Proc will create a proc
301
+ # that will reject a record where all the attributes are blank excluding
302
+ # any value for +_destroy+.
303
+ # [:limit]
304
+ # Allows you to specify the maximum number of associated records that
305
+ # can be processed with the nested attributes. Limit also can be specified
306
+ # as a Proc or a Symbol pointing to a method that should return a number.
307
+ # If the size of the nested attributes array exceeds the specified limit,
308
+ # NestedAttributes::TooManyRecords exception is raised. If omitted, any
309
+ # number of associations can be processed.
310
+ # Note that the +:limit+ option is only applicable to one-to-many
311
+ # associations.
312
+ # [:update_only]
313
+ # For a one-to-one association, this option allows you to specify how
314
+ # nested attributes are going to be used when an associated record already
315
+ # exists. In general, an existing record may either be updated with the
316
+ # new set of attribute values or be replaced by a wholly new record
317
+ # containing those values. By default the +:update_only+ option is +false+
318
+ # and the nested attributes are used to update the existing record only
319
+ # if they include the record's <tt>:id</tt> value. Otherwise a new
320
+ # record will be instantiated and used to replace the existing one.
321
+ # However if the +:update_only+ option is +true+, the nested attributes
322
+ # are used to update the record's attributes always, regardless of
323
+ # whether the <tt>:id</tt> is present. The option is ignored for collection
324
+ # associations.
325
+ #
326
+ # Examples:
327
+ # # creates avatar_attributes=
328
+ # accepts_nested_attributes_for :avatar, reject_if: proc { |attributes| attributes['name'].blank? }
329
+ # # creates avatar_attributes=
330
+ # accepts_nested_attributes_for :avatar, reject_if: :all_blank
331
+ # # creates avatar_attributes= and posts_attributes=
332
+ # accepts_nested_attributes_for :avatar, :posts, allow_destroy: true
333
+ def accepts_nested_attributes_for(*attr_names)
334
+ options = { allow_destroy: false, update_only: false }
335
+ options.update(attr_names.extract_options!)
336
+ options.assert_valid_keys(:allow_destroy, :reject_if, :limit, :update_only)
337
+ options[:reject_if] = REJECT_ALL_BLANK_PROC if options[:reject_if] == :all_blank
338
+
339
+ attr_names.each do |association_name|
340
+ if reflection = _reflect_on_association(association_name)
341
+ nested_attributes_options = self.nested_attributes_options.dup
342
+ nested_attributes_options[association_name.to_sym] = options
343
+ self.nested_attributes_options = nested_attributes_options
344
+
345
+ type = (reflection.collection? ? :collection : :one_to_one)
346
+ generate_association_writer(association_name, type)
347
+ else
348
+ raise ArgumentError, "No association found for name `#{association_name}'. Has it been defined yet?"
349
+ end
350
+ end
351
+ end
352
+
353
+ private
354
+
355
+ # Generates a writer method for this association. Serves as a point for
356
+ # accessing the objects in the association. For example, this method
357
+ # could generate the following:
358
+ #
359
+ # def pirate_attributes=(attributes)
360
+ # assign_nested_attributes_for_one_to_one_association(:pirate, attributes)
361
+ # end
362
+ #
363
+ # This redirects the attempts to write objects in an association through
364
+ # the helper methods defined below. Makes it seem like the nested
365
+ # associations are just regular associations.
366
+ def generate_association_writer(association_name, type)
367
+ generated_association_methods.module_eval <<-eoruby, __FILE__, __LINE__ + 1
368
+ silence_redefinition_of_method :#{association_name}_attributes=
369
+ def #{association_name}_attributes=(attributes)
370
+ assign_nested_attributes_for_#{type}_association(:#{association_name}, attributes)
371
+ end
372
+ eoruby
373
+ end
374
+ end
375
+
376
+ # Returns ActiveEntity::AutosaveAssociation::marked_for_destruction? It's
377
+ # used in conjunction with fields_for to build a form element for the
378
+ # destruction of this association.
379
+ #
380
+ # See ActionView::Helpers::FormHelper::fields_for for more info.
381
+ def _destroy
382
+ marked_for_destruction?
383
+ end
384
+
385
+ private
386
+
387
+ # Attribute hash keys that should not be assigned as normal attributes.
388
+ # These hash keys are nested attributes implementation details.
389
+ UNASSIGNABLE_KEYS = %w( id _destroy )
390
+
391
+ # Assigns the given attributes to the association.
392
+ #
393
+ # If an associated record does not yet exist, one will be instantiated. If
394
+ # an associated record already exists, the method's behavior depends on
395
+ # the value of the update_only option. If update_only is +false+ and the
396
+ # given attributes include an <tt>:id</tt> that matches the existing record's
397
+ # id, then the existing record will be modified. If no <tt>:id</tt> is provided
398
+ # it will be replaced with a new record. If update_only is +true+ the existing
399
+ # record will be modified regardless of whether an <tt>:id</tt> is provided.
400
+ #
401
+ # If the given attributes include a matching <tt>:id</tt> attribute, or
402
+ # update_only is true, and a <tt>:_destroy</tt> key set to a truthy value,
403
+ # then the existing record will be marked for destruction.
404
+ def assign_nested_attributes_for_one_to_one_association(association_name, attributes)
405
+ options = nested_attributes_options[association_name]
406
+ if attributes.respond_to?(:permitted?)
407
+ attributes = attributes.to_h
408
+ end
409
+ attributes = attributes.with_indifferent_access
410
+ existing_record = send(association_name)
411
+
412
+ if (options[:update_only] || !attributes["id"].blank?) && existing_record &&
413
+ (options[:update_only] || existing_record.id.to_s == attributes["id"].to_s)
414
+ assign_to_or_mark_for_destruction(existing_record, attributes, options[:allow_destroy]) unless call_reject_if(association_name, attributes)
415
+
416
+ elsif attributes["id"].present?
417
+ raise_nested_attributes_record_not_found!(association_name, attributes["id"])
418
+
419
+ elsif !reject_new_record?(association_name, attributes)
420
+ assignable_attributes = attributes.except(*UNASSIGNABLE_KEYS)
421
+
422
+ if existing_record && existing_record.new_record?
423
+ existing_record.assign_attributes(assignable_attributes)
424
+ association(association_name).initialize_attributes(existing_record)
425
+ else
426
+ method = :"build_#{association_name}"
427
+ if respond_to?(method)
428
+ send(method, assignable_attributes)
429
+ else
430
+ raise ArgumentError, "Cannot build association `#{association_name}'. Are you trying to build a polymorphic one-to-one association?"
431
+ end
432
+ end
433
+ end
434
+ end
435
+
436
+ # Assigns the given attributes to the collection association.
437
+ #
438
+ # Hashes with an <tt>:id</tt> value matching an existing associated record
439
+ # will update that record. Hashes without an <tt>:id</tt> value will build
440
+ # a new record for the association. Hashes with a matching <tt>:id</tt>
441
+ # value and a <tt>:_destroy</tt> key set to a truthy value will mark the
442
+ # matched record for destruction.
443
+ #
444
+ # For example:
445
+ #
446
+ # assign_nested_attributes_for_collection_association(:people, {
447
+ # '1' => { id: '1', name: 'Peter' },
448
+ # '2' => { name: 'John' },
449
+ # '3' => { id: '2', _destroy: true }
450
+ # })
451
+ #
452
+ # Will update the name of the Person with ID 1, build a new associated
453
+ # person with the name 'John', and mark the associated Person with ID 2
454
+ # for destruction.
455
+ #
456
+ # Also accepts an Array of attribute hashes:
457
+ #
458
+ # assign_nested_attributes_for_collection_association(:people, [
459
+ # { id: '1', name: 'Peter' },
460
+ # { name: 'John' },
461
+ # { id: '2', _destroy: true }
462
+ # ])
463
+ def assign_nested_attributes_for_collection_association(association_name, attributes_collection)
464
+ options = nested_attributes_options[association_name]
465
+ if attributes_collection.respond_to?(:permitted?)
466
+ attributes_collection = attributes_collection.to_h
467
+ end
468
+
469
+ unless attributes_collection.is_a?(Hash) || attributes_collection.is_a?(Array)
470
+ raise ArgumentError, "Hash or Array expected for attribute `#{association_name}`, got #{attributes_collection.class.name} (#{attributes_collection.inspect})"
471
+ end
472
+
473
+ check_record_limit!(options[:limit], attributes_collection)
474
+
475
+ if attributes_collection.is_a? Hash
476
+ keys = attributes_collection.keys
477
+ attributes_collection = if keys.include?("id") || keys.include?(:id)
478
+ [attributes_collection]
479
+ else
480
+ attributes_collection.values
481
+ end
482
+ end
483
+
484
+ association = association(association_name)
485
+
486
+ existing_records = association.target
487
+
488
+ attributes_collection.each do |attributes|
489
+ if attributes.respond_to?(:permitted?)
490
+ attributes = attributes.to_h
491
+ end
492
+ attributes = attributes.with_indifferent_access
493
+
494
+ if attributes["id"].blank?
495
+ unless reject_new_record?(association_name, attributes)
496
+ association.reader.build(attributes.except(*UNASSIGNABLE_KEYS))
497
+ end
498
+ elsif existing_record = existing_records.detect { |record| record.id.to_s == attributes["id"].to_s }
499
+ unless call_reject_if(association_name, attributes)
500
+ # Make sure we are operating on the actual object which is in the association's
501
+ # proxy_target array (either by finding it, or adding it if not found)
502
+ # Take into account that the proxy_target may have changed due to callbacks
503
+ target_record = association.target.detect { |record| record.id.to_s == attributes["id"].to_s }
504
+ if target_record
505
+ existing_record = target_record
506
+ else
507
+ association.add_to_target(existing_record, :skip_callbacks)
508
+ end
509
+
510
+ assign_to_or_mark_for_destruction(existing_record, attributes, options[:allow_destroy])
511
+ end
512
+ else
513
+ raise_nested_attributes_record_not_found!(association_name, attributes["id"])
514
+ end
515
+ end
516
+ end
517
+
518
+ # Takes in a limit and checks if the attributes_collection has too many
519
+ # records. It accepts limit in the form of symbol, proc, or
520
+ # number-like object (anything that can be compared with an integer).
521
+ #
522
+ # Raises TooManyRecords error if the attributes_collection is
523
+ # larger than the limit.
524
+ def check_record_limit!(limit, attributes_collection)
525
+ if limit
526
+ limit = \
527
+ case limit
528
+ when Symbol
529
+ send(limit)
530
+ when Proc
531
+ limit.call
532
+ else
533
+ limit
534
+ end
535
+
536
+ if limit && attributes_collection.size > limit
537
+ raise TooManyRecords, "Maximum #{limit} records are allowed. Got #{attributes_collection.size} records instead."
538
+ end
539
+ end
540
+ end
541
+
542
+ # Updates a record with the +attributes+ or marks it for destruction if
543
+ # +allow_destroy+ is +true+ and has_destroy_flag? returns +true+.
544
+ def assign_to_or_mark_for_destruction(record, attributes, allow_destroy)
545
+ record.assign_attributes(attributes.except(*UNASSIGNABLE_KEYS))
546
+ record.mark_for_destruction if has_destroy_flag?(attributes) && allow_destroy
547
+ end
548
+
549
+ # Determines if a hash contains a truthy _destroy key.
550
+ def has_destroy_flag?(hash)
551
+ Type::Boolean.new.cast(hash["_destroy"])
552
+ end
553
+
554
+ # Determines if a new record should be rejected by checking
555
+ # has_destroy_flag? or if a <tt>:reject_if</tt> proc exists for this
556
+ # association and evaluates to +true+.
557
+ def reject_new_record?(association_name, attributes)
558
+ will_be_destroyed?(association_name, attributes) || call_reject_if(association_name, attributes)
559
+ end
560
+
561
+ # Determines if a record with the particular +attributes+ should be
562
+ # rejected by calling the reject_if Symbol or Proc (if defined).
563
+ # The reject_if option is defined by +accepts_nested_attributes_for+.
564
+ #
565
+ # Returns false if there is a +destroy_flag+ on the attributes.
566
+ def call_reject_if(association_name, attributes)
567
+ return false if will_be_destroyed?(association_name, attributes)
568
+
569
+ case callback = nested_attributes_options[association_name][:reject_if]
570
+ when Symbol
571
+ method(callback).arity == 0 ? send(callback) : send(callback, attributes)
572
+ when Proc
573
+ callback.call(attributes)
574
+ end
575
+ end
576
+
577
+ # Only take into account the destroy flag if <tt>:allow_destroy</tt> is true
578
+ def will_be_destroyed?(association_name, attributes)
579
+ allow_destroy?(association_name) && has_destroy_flag?(attributes)
580
+ end
581
+
582
+ def allow_destroy?(association_name)
583
+ nested_attributes_options[association_name][:allow_destroy]
584
+ end
585
+
586
+ def raise_nested_attributes_record_not_found!(association_name, record_id)
587
+ model = self.class._reflect_on_association(association_name).klass.name
588
+ raise RecordNotFound.new("Couldn't find #{model} with ID=#{record_id} for #{self.class.name} with ID=#{id}",
589
+ model, "id", record_id)
590
+ end
591
+ end
592
+ end