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,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Formed
|
4
|
+
module Associations
|
5
|
+
module Builder
|
6
|
+
class SingularAssociation < ::Formed::Associations::Builder::Association # :nodoc:
|
7
|
+
def self.valid_options(options)
|
8
|
+
super + %i[required touch]
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.define_accessors(model, reflection)
|
12
|
+
super
|
13
|
+
mixin = model.generated_association_methods
|
14
|
+
name = reflection.name
|
15
|
+
|
16
|
+
define_constructors(mixin, name) unless reflection.polymorphic?
|
17
|
+
|
18
|
+
mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
|
19
|
+
def reload_#{name}
|
20
|
+
association(:#{name}).force_reload_reader
|
21
|
+
end
|
22
|
+
CODE
|
23
|
+
end
|
24
|
+
|
25
|
+
# Defines the (build|create)_association methods for belongs_to or has_one association
|
26
|
+
def self.define_constructors(mixin, name)
|
27
|
+
mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
|
28
|
+
def build_#{name}(*args, &block)
|
29
|
+
association(:#{name}).build(*args, &block)
|
30
|
+
end
|
31
|
+
|
32
|
+
def create_#{name}(*args, &block)
|
33
|
+
association(:#{name}).create(*args, &block)
|
34
|
+
end
|
35
|
+
|
36
|
+
def create_#{name}!(*args, &block)
|
37
|
+
association(:#{name}).create!(*args, &block)
|
38
|
+
end
|
39
|
+
CODE
|
40
|
+
end
|
41
|
+
|
42
|
+
private_class_method :valid_options, :define_accessors, :define_constructors
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "formed/associations/builder/association"
|
4
|
+
require "formed/associations/builder/singular_association"
|
5
|
+
require "formed/associations/builder/collection_association"
|
6
|
+
require "formed/associations/builder/has_many"
|
7
|
+
require "formed/associations/builder/has_one"
|
8
|
+
|
9
|
+
module Formed
|
10
|
+
module Associations
|
11
|
+
module Builder; end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,296 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Formed
|
4
|
+
module Associations
|
5
|
+
class CollectionAssociation < Association # :nodoc:
|
6
|
+
# Implements the reader method, e.g. foo.items for Foo.has_many :items
|
7
|
+
def reader
|
8
|
+
ensure_klass_exists!
|
9
|
+
|
10
|
+
reload if stale_target?
|
11
|
+
|
12
|
+
@proxy ||= CollectionProxy.create(klass, self)
|
13
|
+
@proxy.reset_scope
|
14
|
+
end
|
15
|
+
|
16
|
+
def writer(records)
|
17
|
+
replace(records)
|
18
|
+
end
|
19
|
+
|
20
|
+
def reset
|
21
|
+
super
|
22
|
+
@target = []
|
23
|
+
@replaced_or_added_targets = Set.new
|
24
|
+
@association_ids = nil
|
25
|
+
end
|
26
|
+
|
27
|
+
def build(attributes = nil, &block)
|
28
|
+
if attributes.is_a?(Array)
|
29
|
+
attributes.collect { |attr| build(attr, &block) }
|
30
|
+
else
|
31
|
+
add_to_target(build_record(attributes, &block), replace: true)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Add +records+ to this association. Since +<<+ flattens its argument list
|
36
|
+
# and inserts each record, +push+ and +concat+ behave identically.
|
37
|
+
def concat(*records)
|
38
|
+
records = records.flatten
|
39
|
+
load_target if owner.new_record?
|
40
|
+
concat_records(records)
|
41
|
+
end
|
42
|
+
|
43
|
+
# Returns the size of the collection by executing a SELECT COUNT(*)
|
44
|
+
# query if the collection hasn't been loaded, and calling
|
45
|
+
# <tt>collection.size</tt> if it has.
|
46
|
+
#
|
47
|
+
# If the collection has been already loaded +size+ and +length+ are
|
48
|
+
# equivalent. If not and you are going to need the records anyway
|
49
|
+
# +length+ will take one less query. Otherwise +size+ is more efficient.
|
50
|
+
#
|
51
|
+
# This method is abstract in the sense that it relies on
|
52
|
+
# +count_records+, which is a method descendants have to provide.
|
53
|
+
def size
|
54
|
+
if !find_target? || loaded?
|
55
|
+
target.size
|
56
|
+
elsif @association_ids
|
57
|
+
@association_ids.size
|
58
|
+
elsif !association_scope.group_values.empty?
|
59
|
+
load_target.size
|
60
|
+
else
|
61
|
+
unsaved_records = target.select(&:new_record?)
|
62
|
+
unsaved_records.size + count_records
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# Returns true if the collection is empty.
|
67
|
+
#
|
68
|
+
# If the collection has been loaded
|
69
|
+
# it is equivalent to <tt>collection.size.zero?</tt>. If the
|
70
|
+
# collection has not been loaded, it is equivalent to
|
71
|
+
# <tt>!collection.exists?</tt>. If the collection has not already been
|
72
|
+
# loaded and you are going to fetch the records anyway it is better to
|
73
|
+
# check <tt>collection.length.zero?</tt>.
|
74
|
+
def empty?
|
75
|
+
if loaded? || @association_ids || reflection.has_cached_counter?
|
76
|
+
size.zero?
|
77
|
+
else
|
78
|
+
target.empty? && !scope.exists?
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
# Replace this collection with +other_array+. This will perform a diff
|
83
|
+
# and delete/add only records that have changed.
|
84
|
+
def replace(other_array)
|
85
|
+
other_array = other_array.map do |other|
|
86
|
+
if other.class < Formed::Base
|
87
|
+
other
|
88
|
+
else
|
89
|
+
build_record(other)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
original_target = load_target.dup
|
93
|
+
|
94
|
+
if owner.new_record?
|
95
|
+
replace_records(other_array, original_target)
|
96
|
+
else
|
97
|
+
replace_common_records_in_memory(other_array, original_target)
|
98
|
+
if other_array != original_target
|
99
|
+
transaction { replace_records(other_array, original_target) }
|
100
|
+
else
|
101
|
+
other_array
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
def include?(record)
|
107
|
+
if record.is_a?(reflection.klass)
|
108
|
+
if record.new_record?
|
109
|
+
include_in_memory?(record)
|
110
|
+
else
|
111
|
+
loaded? ? target.include?(record) : scope.exists?(record.id)
|
112
|
+
end
|
113
|
+
else
|
114
|
+
false
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def load_target
|
119
|
+
@target = merge_target_lists(find_target, target) if find_target?
|
120
|
+
|
121
|
+
loaded!
|
122
|
+
target
|
123
|
+
end
|
124
|
+
|
125
|
+
def add_to_target(record, skip_callbacks: false, replace: true, &block)
|
126
|
+
replace_on_target(record, skip_callbacks, replace: replace, &block)
|
127
|
+
end
|
128
|
+
|
129
|
+
def target=(record)
|
130
|
+
return super unless reflection.klass.has_many_inversing
|
131
|
+
|
132
|
+
case record
|
133
|
+
when nil
|
134
|
+
# It's not possible to remove the record from the inverse association.
|
135
|
+
when Array
|
136
|
+
super
|
137
|
+
else
|
138
|
+
replace_on_target(record, true, replace: true, inversing: true)
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
def scope
|
143
|
+
end
|
144
|
+
|
145
|
+
def null_scope?
|
146
|
+
owner.new_record?
|
147
|
+
end
|
148
|
+
|
149
|
+
def find_from_target?
|
150
|
+
loaded? ||
|
151
|
+
owner.strict_loading? ||
|
152
|
+
reflection.strict_loading? ||
|
153
|
+
owner.new_record? ||
|
154
|
+
target.any? { |record| record.new_record? || record.changed? }
|
155
|
+
end
|
156
|
+
|
157
|
+
private
|
158
|
+
|
159
|
+
def merge_target_lists(persisted, memory)
|
160
|
+
return persisted if memory.empty?
|
161
|
+
|
162
|
+
persisted.map! do |record|
|
163
|
+
if (mem_record = memory.delete(record))
|
164
|
+
|
165
|
+
((record.attribute_names & mem_record.attribute_names) - mem_record.changed_attribute_names_to_save).each do |name|
|
166
|
+
mem_record[name] = record[name]
|
167
|
+
end
|
168
|
+
|
169
|
+
mem_record
|
170
|
+
else
|
171
|
+
record
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
persisted + memory.reject(&:persisted?)
|
176
|
+
end
|
177
|
+
|
178
|
+
def _create_record(attributes, raise = false, &block)
|
179
|
+
if attributes.is_a?(Array)
|
180
|
+
attributes.collect { |attr| _create_record(attr, raise, &block) }
|
181
|
+
else
|
182
|
+
build_record(attributes, &block)
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
def replace_records(new_target, original_target)
|
187
|
+
unless concat(difference(new_target, target))
|
188
|
+
@target = original_target
|
189
|
+
raise RecordNotSaved, "Failed to replace #{reflection.name} because one or more of the " \
|
190
|
+
"new records could not be saved."
|
191
|
+
end
|
192
|
+
|
193
|
+
target
|
194
|
+
end
|
195
|
+
|
196
|
+
def replace_common_records_in_memory(new_target, original_target)
|
197
|
+
common_records = intersection(new_target, original_target)
|
198
|
+
common_records.each do |record|
|
199
|
+
skip_callbacks = true
|
200
|
+
replace_on_target(record, skip_callbacks, replace: true)
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
def concat_records(records, raise = false)
|
205
|
+
result = true
|
206
|
+
|
207
|
+
records.each do |record|
|
208
|
+
add_to_target(record) do
|
209
|
+
unless owner.new_record?
|
210
|
+
result &&= insert_record(record, true, raise) do
|
211
|
+
@_was_loaded = loaded?
|
212
|
+
end
|
213
|
+
end
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
records
|
218
|
+
end
|
219
|
+
|
220
|
+
def replace_on_target(record, skip_callbacks, replace:, inversing: false)
|
221
|
+
index = @target.index(record) if replace && (!record.new_record? || @replaced_or_added_targets.include?(record))
|
222
|
+
|
223
|
+
unless skip_callbacks
|
224
|
+
catch(:abort) do
|
225
|
+
callback(:before_add, record)
|
226
|
+
end || return
|
227
|
+
end
|
228
|
+
|
229
|
+
set_inverse_instance(record)
|
230
|
+
|
231
|
+
@_was_loaded = true
|
232
|
+
|
233
|
+
yield(record) if block_given?
|
234
|
+
|
235
|
+
index = @target.index(record) if !index && @replaced_or_added_targets.include?(record)
|
236
|
+
|
237
|
+
@replaced_or_added_targets << record if inversing || index || record.new_record?
|
238
|
+
|
239
|
+
if index
|
240
|
+
target[index] = record
|
241
|
+
elsif @_was_loaded || !loaded?
|
242
|
+
@association_ids = nil
|
243
|
+
target << record
|
244
|
+
end
|
245
|
+
|
246
|
+
callback(:after_add, record) unless skip_callbacks
|
247
|
+
|
248
|
+
record
|
249
|
+
ensure
|
250
|
+
@_was_loaded = nil
|
251
|
+
end
|
252
|
+
|
253
|
+
def callback(method, record)
|
254
|
+
callbacks_for(method).each do |callback|
|
255
|
+
callback.call(method, owner, record)
|
256
|
+
end
|
257
|
+
end
|
258
|
+
|
259
|
+
def callbacks_for(callback_name)
|
260
|
+
full_callback_name = "#{callback_name}_for_#{reflection.name}"
|
261
|
+
if owner.class.respond_to?(full_callback_name)
|
262
|
+
owner.class.send(full_callback_name)
|
263
|
+
else
|
264
|
+
[]
|
265
|
+
end
|
266
|
+
end
|
267
|
+
|
268
|
+
def include_in_memory?(record)
|
269
|
+
if reflection.is_a?(Formed::Reflection::ThroughReflection)
|
270
|
+
assoc = owner.association(reflection.through_reflection.name)
|
271
|
+
assoc.reader.any? do |source|
|
272
|
+
target_reflection = source.send(reflection.source_reflection.name)
|
273
|
+
target_reflection.respond_to?(:include?) ? target_reflection.include?(record) : target_reflection == record
|
274
|
+
end || target.include?(record)
|
275
|
+
else
|
276
|
+
target.include?(record)
|
277
|
+
end
|
278
|
+
end
|
279
|
+
|
280
|
+
# If the :inverse_of option has been
|
281
|
+
# specified, then #find scans the entire collection.
|
282
|
+
def find_by_scan(*args)
|
283
|
+
expects_array = args.first.is_a?(Array)
|
284
|
+
ids = args.flatten.compact.map(&:to_s).uniq
|
285
|
+
|
286
|
+
if ids.size == 1
|
287
|
+
id = ids.first
|
288
|
+
record = load_target.detect { |r| id == r.id.to_s }
|
289
|
+
expects_array ? [record] : record
|
290
|
+
else
|
291
|
+
load_target.select { |r| ids.include?(r.id.to_s) }
|
292
|
+
end
|
293
|
+
end
|
294
|
+
end
|
295
|
+
end
|
296
|
+
end
|