formed 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +146 -0
  3. data/Rakefile +12 -0
  4. data/lib/active_form.rb +12 -0
  5. data/lib/formed/acts_like_model.rb +27 -0
  6. data/lib/formed/association_relation.rb +22 -0
  7. data/lib/formed/associations/association.rb +193 -0
  8. data/lib/formed/associations/builder/association.rb +116 -0
  9. data/lib/formed/associations/builder/collection_association.rb +71 -0
  10. data/lib/formed/associations/builder/has_many.rb +24 -0
  11. data/lib/formed/associations/builder/has_one.rb +44 -0
  12. data/lib/formed/associations/builder/singular_association.rb +46 -0
  13. data/lib/formed/associations/builder.rb +13 -0
  14. data/lib/formed/associations/collection_association.rb +296 -0
  15. data/lib/formed/associations/collection_proxy.rb +519 -0
  16. data/lib/formed/associations/foreign_association.rb +37 -0
  17. data/lib/formed/associations/has_many_association.rb +63 -0
  18. data/lib/formed/associations/has_one_association.rb +27 -0
  19. data/lib/formed/associations/singular_association.rb +66 -0
  20. data/lib/formed/associations.rb +62 -0
  21. data/lib/formed/attributes.rb +42 -0
  22. data/lib/formed/base.rb +183 -0
  23. data/lib/formed/core.rb +73 -0
  24. data/lib/formed/from_model.rb +41 -0
  25. data/lib/formed/from_params.rb +33 -0
  26. data/lib/formed/inheritance.rb +179 -0
  27. data/lib/formed/nested_attributes.rb +287 -0
  28. data/lib/formed/reflection.rb +781 -0
  29. data/lib/formed/relation/delegation.rb +147 -0
  30. data/lib/formed/relation.rb +113 -0
  31. data/lib/formed/version.rb +3 -0
  32. data/lib/generators/active_form/form_generator.rb +72 -0
  33. data/lib/generators/active_form/templates/form.rb.tt +8 -0
  34. data/lib/generators/active_form/templates/form_spec.rb.tt +5 -0
  35. data/lib/generators/active_form/templates/module.rb.tt +4 -0
  36. metadata +203 -0
@@ -0,0 +1,287 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formed
4
+ module NestedAttributes # :nodoc:
5
+ class TooManyRecords < StandardError
6
+ end
7
+
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
11
+ class_attribute :nested_attributes_options, instance_writer: false, default: {}
12
+ end
13
+
14
+ def associated_records_to_validate(association, new_record)
15
+ if new_record
16
+ association&.target
17
+ else
18
+ association.target
19
+ end
20
+ end
21
+
22
+ # Validate the association if <tt>:validate</tt> or <tt>:autosave</tt> is
23
+ # turned on for the association.
24
+ def validate_single_association(reflection)
25
+ association = association_instance_get(reflection.name)
26
+ record = association&.reader
27
+ association_valid?(reflection, record) if record
28
+ end
29
+
30
+ # Validate the associated records if <tt>:validate</tt> or
31
+ # <tt>:autosave</tt> is turned on for the association specified by
32
+ # +reflection+.
33
+ def validate_collection_association(reflection)
34
+ return unless (association = association_instance_get(reflection.name))
35
+ return unless (records = associated_records_to_validate(association, new_record?))
36
+
37
+ records.each_with_index { |record, index| association_valid?(reflection, record, index) }
38
+ end
39
+
40
+ # Returns whether or not the association is valid and applies any errors to
41
+ # the parent, <tt>self</tt>, if it wasn't. Skips any <tt>:autosave</tt>
42
+ # enabled records if they're marked_for_destruction? or destroyed.
43
+ def association_valid?(reflection, record, index = nil)
44
+ context = nil
45
+
46
+ unless (valid = record.valid?(context))
47
+ indexed_attribute = !index.nil? && reflection.options[:index_errors]
48
+
49
+ record.errors.group_by_attribute.each do |attribute, errors|
50
+ attribute = normalize_reflection_attribute(indexed_attribute, reflection, index, attribute)
51
+
52
+ errors.each do |error|
53
+ self.errors.import(
54
+ error,
55
+ attribute: attribute
56
+ )
57
+ end
58
+ end
59
+ end
60
+ valid
61
+ end
62
+
63
+ def normalize_reflection_attribute(indexed_attribute, reflection, index, attribute)
64
+ if indexed_attribute
65
+ "#{reflection.name}[#{index}].#{attribute}"
66
+ else
67
+ "#{reflection.name}.#{attribute}"
68
+ end
69
+ end
70
+
71
+ def _ensure_no_duplicate_errors
72
+ errors.uniq!
73
+ end
74
+
75
+ module ClassMethods
76
+ REJECT_ALL_BLANK_PROC = proc { |attributes| attributes.all? { |key, value| key == "_destroy" || value.blank? } }
77
+
78
+ def accepts_nested_attributes_for(*attr_names)
79
+ options = { allow_destroy: false, update_only: false }
80
+ options.update(attr_names.extract_options!)
81
+ options.assert_valid_keys(:allow_destroy, :reject_if, :limit, :update_only)
82
+ options[:reject_if] = REJECT_ALL_BLANK_PROC if options[:reject_if] == :all_blank
83
+
84
+ attr_names.each do |association_name|
85
+ unless (reflection = _reflect_on_association(association_name))
86
+ raise ArgumentError, "No association found for name `#{association_name}'. Has it been defined yet?"
87
+ end
88
+
89
+ nested_attributes_options = self.nested_attributes_options.dup
90
+ nested_attributes_options[association_name.to_sym] = options
91
+ self.nested_attributes_options = nested_attributes_options
92
+ define_validation_callbacks(reflection)
93
+
94
+ type = (reflection.collection? ? :collection : :one_to_one)
95
+ generate_association_writer(association_name, type)
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ def define_validation_callbacks(reflection)
102
+ validation_method = :"validate_associated_records_for_#{reflection.name}"
103
+ return unless reflection.validate? && !method_defined?(validation_method)
104
+
105
+ method = if reflection.collection?
106
+ :validate_collection_association
107
+ else
108
+ :validate_single_association
109
+ end
110
+
111
+ define_non_cyclic_method(validation_method) { send(method, reflection) }
112
+ validate validation_method
113
+ after_validation :_ensure_no_duplicate_errors
114
+ end
115
+
116
+ def define_non_cyclic_method(name, &block)
117
+ return if method_defined?(name, false)
118
+
119
+ define_method(name) do |*_args|
120
+ result = true
121
+ @_already_called ||= {}
122
+ # Loop prevention for validation of associations
123
+ unless @_already_called[name]
124
+ begin
125
+ @_already_called[name] = true
126
+ result = instance_eval(&block)
127
+ ensure
128
+ @_already_called[name] = false
129
+ end
130
+ end
131
+
132
+ result
133
+ end
134
+ end
135
+
136
+ def generate_association_writer(association_name, type)
137
+ generated_association_methods.module_eval <<-EORUBY, __FILE__, __LINE__ + 1
138
+ silence_redefinition_of_method :#{association_name}_attributes=
139
+ def #{association_name}_attributes=(attributes)
140
+ assign_nested_attributes_for_#{type}_association(:#{association_name}, attributes)
141
+ end
142
+ EORUBY
143
+ end
144
+ end
145
+
146
+ def _destroy
147
+ marked_for_destruction?
148
+ end
149
+
150
+ private
151
+
152
+ UNASSIGNABLE_KEYS = %w[id _destroy].freeze
153
+
154
+ def assign_nested_attributes_for_one_to_one_association(association_name, attributes)
155
+ options = nested_attributes_options[association_name]
156
+ attributes = attributes.to_h if attributes.respond_to?(:permitted?)
157
+ attributes = attributes.with_indifferent_access
158
+ existing_record = send(association_name)
159
+
160
+ if (options[:update_only] || !attributes["id"].blank?) && existing_record && (options[:update_only] || existing_record.id.to_s == attributes["id"].to_s)
161
+ assign_to_or_mark_for_destruction(existing_record, attributes, options[:allow_destroy]) unless call_reject_if(
162
+ association_name, attributes
163
+ )
164
+
165
+ elsif attributes["id"].present?
166
+ raise_nested_attributes_record_not_found!(association_name, attributes["id"])
167
+
168
+ elsif !reject_new_record?(association_name, attributes)
169
+ assignable_attributes = attributes.except(*UNASSIGNABLE_KEYS)
170
+
171
+ if existing_record&.new_record?
172
+ existing_record.assign_attributes(assignable_attributes)
173
+ association(association_name).initialize_attributes(existing_record)
174
+ else
175
+ method = :"build_#{association_name}"
176
+ if respond_to?(method)
177
+ send(method, assignable_attributes)
178
+ else
179
+ raise ArgumentError,
180
+ "Cannot build association `#{association_name}'. Are you trying to build a polymorphic one-to-one association?"
181
+ end
182
+ end
183
+ end
184
+ end
185
+
186
+ def assign_nested_attributes_for_collection_association(association_name, attributes_collection)
187
+ options = nested_attributes_options[association_name]
188
+ attributes_collection = attributes_collection.to_h if attributes_collection.respond_to?(:permitted?)
189
+
190
+ unless attributes_collection.is_a?(Hash) || attributes_collection.is_a?(Array)
191
+ raise ArgumentError,
192
+ "Hash or Array expected for attribute `#{association_name}`, got #{attributes_collection.class.name} (#{attributes_collection.inspect})"
193
+ end
194
+
195
+ if attributes_collection.is_a? Hash
196
+ keys = attributes_collection.keys
197
+ attributes_collection = if keys.include?("id") || keys.include?(:id)
198
+ [attributes_collection]
199
+ else
200
+ attributes_collection.values
201
+ end
202
+ end
203
+
204
+ association = association(association_name)
205
+
206
+ if association.loaded?
207
+ association.target
208
+ else
209
+ attributes_collection
210
+ end
211
+
212
+ attributes_collection.each do |attributes|
213
+ attributes = attributes.to_h if attributes.respond_to?(:permitted?)
214
+ attributes = attributes.with_indifferent_access
215
+
216
+ if attributes["id"].blank?
217
+ unless reject_new_record?(association_name, attributes)
218
+ association.reader.build(attributes.except(*UNASSIGNABLE_KEYS))
219
+ end
220
+ else
221
+ unless call_reject_if(association_name, attributes)
222
+
223
+ target_record = association.target.detect { |record| record.id.to_s == attributes["id"].to_s }
224
+ if target_record
225
+ existing_record = association.reader.build(attributes.except(*UNASSIGNABLE_KEYS))
226
+ else
227
+ existing_record = association.reader.build(attributes)
228
+ association.add_to_target(existing_record, skip_callbacks: true)
229
+ end
230
+
231
+ assign_to_or_mark_for_destruction(existing_record, attributes, options[:allow_destroy])
232
+ end
233
+ end
234
+ end
235
+ end
236
+
237
+ # Updates a record with the +attributes+ or marks it for destruction if
238
+ # +allow_destroy+ is +true+ and has_destroy_flag? returns +true+.
239
+ def assign_to_or_mark_for_destruction(record, attributes, allow_destroy)
240
+ record.assign_attributes(attributes)
241
+ record.mark_for_destruction if has_destroy_flag?(attributes) && allow_destroy
242
+ end
243
+
244
+ # Determines if a hash contains a truthy _destroy key.
245
+ def has_destroy_flag?(hash)
246
+ ::ActiveModel::Type::Boolean.new.cast(hash["destroy"])
247
+ end
248
+
249
+ # Determines if a new record should be rejected by checking
250
+ # has_destroy_flag? or if a <tt>:reject_if</tt> proc exists for this
251
+ # association and evaluates to +true+.
252
+ def reject_new_record?(association_name, attributes)
253
+ will_be_destroyed?(association_name, attributes) || call_reject_if(association_name, attributes)
254
+ end
255
+
256
+ # Determines if a record with the particular +attributes+ should be
257
+ # rejected by calling the reject_if Symbol or Proc (if defined).
258
+ # The reject_if option is defined by +accepts_nested_attributes_for+.
259
+ #
260
+ # Returns false if there is a +destroy_flag+ on the attributes.
261
+ def call_reject_if(association_name, attributes)
262
+ return false if will_be_destroyed?(association_name, attributes)
263
+
264
+ case callback = nested_attributes_options[association_name][:reject_if]
265
+ when Symbol
266
+ method(callback).arity.zero? ? send(callback) : send(callback, attributes)
267
+ when Proc
268
+ callback.call(attributes)
269
+ end
270
+ end
271
+
272
+ # Only take into account the destroy flag if <tt>:allow_destroy</tt> is true
273
+ def will_be_destroyed?(association_name, attributes)
274
+ allow_destroy?(association_name) && has_destroy_flag?(attributes)
275
+ end
276
+
277
+ def allow_destroy?(association_name)
278
+ nested_attributes_options[association_name][:allow_destroy]
279
+ end
280
+
281
+ def raise_nested_attributes_record_not_found!(association_name, record_id)
282
+ model = self.class._reflect_on_association(association_name).klass.name
283
+ raise RecordNotFound.new("Couldn't find #{model} with ID=#{record_id} for #{self.class.name} with ID=#{id}",
284
+ model, "id", record_id)
285
+ end
286
+ end
287
+ end