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