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.
- checksums.yaml +7 -0
- data/README.md +146 -0
- data/Rakefile +12 -0
- data/lib/active_form.rb +12 -0
- data/lib/formed/acts_like_model.rb +27 -0
- data/lib/formed/association_relation.rb +22 -0
- data/lib/formed/associations/association.rb +193 -0
- data/lib/formed/associations/builder/association.rb +116 -0
- data/lib/formed/associations/builder/collection_association.rb +71 -0
- data/lib/formed/associations/builder/has_many.rb +24 -0
- data/lib/formed/associations/builder/has_one.rb +44 -0
- data/lib/formed/associations/builder/singular_association.rb +46 -0
- data/lib/formed/associations/builder.rb +13 -0
- data/lib/formed/associations/collection_association.rb +296 -0
- data/lib/formed/associations/collection_proxy.rb +519 -0
- data/lib/formed/associations/foreign_association.rb +37 -0
- data/lib/formed/associations/has_many_association.rb +63 -0
- data/lib/formed/associations/has_one_association.rb +27 -0
- data/lib/formed/associations/singular_association.rb +66 -0
- data/lib/formed/associations.rb +62 -0
- data/lib/formed/attributes.rb +42 -0
- data/lib/formed/base.rb +183 -0
- data/lib/formed/core.rb +73 -0
- data/lib/formed/from_model.rb +41 -0
- data/lib/formed/from_params.rb +33 -0
- data/lib/formed/inheritance.rb +179 -0
- data/lib/formed/nested_attributes.rb +287 -0
- data/lib/formed/reflection.rb +781 -0
- data/lib/formed/relation/delegation.rb +147 -0
- data/lib/formed/relation.rb +113 -0
- data/lib/formed/version.rb +3 -0
- data/lib/generators/active_form/form_generator.rb +72 -0
- data/lib/generators/active_form/templates/form.rb.tt +8 -0
- data/lib/generators/active_form/templates/form_spec.rb.tt +5 -0
- data/lib/generators/active_form/templates/module.rb.tt +4 -0
- 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
|