formed 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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