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,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