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,519 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formed
4
+ module Associations
5
+ class CollectionProxy < Relation
6
+ def initialize(klass, association, **) # :nodoc:
7
+ @association = association
8
+ super klass
9
+
10
+ extensions = association.extensions
11
+ extend(*extensions) if extensions.any?
12
+ end
13
+
14
+ def target
15
+ @association.target
16
+ end
17
+
18
+ def load_target
19
+ @association.load_target
20
+ end
21
+
22
+ # Returns +true+ if the association has been loaded, otherwise +false+.
23
+ #
24
+ # person.pets.loaded? # => false
25
+ # person.pets.records
26
+ # person.pets.loaded? # => true
27
+ def loaded?
28
+ @association.loaded?
29
+ end
30
+ alias loaded loaded?
31
+
32
+ def last(limit = nil)
33
+ if limit
34
+ target.last(limit)
35
+ else
36
+ target.last
37
+ end
38
+ end
39
+
40
+ # Gives a record (or N records if a parameter is supplied) from the collection
41
+ # using the same rules as <tt>ActiveRecord::Base.take</tt>.
42
+ #
43
+ # class Person < ActiveRecord::Base
44
+ # has_many :pets
45
+ # end
46
+ #
47
+ # person.pets
48
+ # # => [
49
+ # # #<Pet id: 1, name: "Fancy-Fancy", person_id: 1>,
50
+ # # #<Pet id: 2, name: "Spook", person_id: 1>,
51
+ # # #<Pet id: 3, name: "Choo-Choo", person_id: 1>
52
+ # # ]
53
+ #
54
+ # person.pets.take # => #<Pet id: 1, name: "Fancy-Fancy", person_id: 1>
55
+ #
56
+ # person.pets.take(2)
57
+ # # => [
58
+ # # #<Pet id: 1, name: "Fancy-Fancy", person_id: 1>,
59
+ # # #<Pet id: 2, name: "Spook", person_id: 1>
60
+ # # ]
61
+ #
62
+ # another_person_without.pets # => []
63
+ # another_person_without.pets.take # => nil
64
+ # another_person_without.pets.take(2) # => []
65
+ def take(limit = nil)
66
+ target.take(limit)
67
+ end
68
+
69
+ # Returns a new object of the collection type that has been instantiated
70
+ # with +attributes+ and linked to this object, but have not yet been saved.
71
+ # You can pass an array of attributes hashes, this will return an array
72
+ # with the new objects.
73
+ #
74
+ # class Person
75
+ # has_many :pets
76
+ # end
77
+ #
78
+ # person.pets.build
79
+ # # => #<Pet id: nil, name: nil, person_id: 1>
80
+ #
81
+ # person.pets.build(name: 'Fancy-Fancy')
82
+ # # => #<Pet id: nil, name: "Fancy-Fancy", person_id: 1>
83
+ #
84
+ # person.pets.build([{name: 'Spook'}, {name: 'Choo-Choo'}, {name: 'Brain'}])
85
+ # # => [
86
+ # # #<Pet id: nil, name: "Spook", person_id: 1>,
87
+ # # #<Pet id: nil, name: "Choo-Choo", person_id: 1>,
88
+ # # #<Pet id: nil, name: "Brain", person_id: 1>
89
+ # # ]
90
+ #
91
+ # person.pets.size # => 5 # size of the collection
92
+ # person.pets.count # => 0 # count from database
93
+ def build(attributes = {}, &block)
94
+ @association.build(attributes, &block)
95
+ end
96
+ alias new build
97
+
98
+ def with_context(context)
99
+ each { |record| record.with_context(context) }
100
+ end
101
+
102
+ # Replaces this collection with +other_array+. This will perform a diff
103
+ # and delete/add only records that have changed.
104
+ #
105
+ # class Person < ActiveRecord::Base
106
+ # has_many :pets
107
+ # end
108
+ #
109
+ # person.pets
110
+ # # => [#<Pet id: 1, name: "Gorby", group: "cats", person_id: 1>]
111
+ #
112
+ # other_pets = [Pet.new(name: 'Puff', group: 'celebrities')]
113
+ #
114
+ # person.pets.replace(other_pets)
115
+ #
116
+ # person.pets
117
+ # # => [#<Pet id: 2, name: "Puff", group: "celebrities", person_id: 1>]
118
+ #
119
+ # If the supplied array has an incorrect association type, it raises
120
+ # an <tt>ActiveRecord::AssociationTypeMismatch</tt> error:
121
+ #
122
+ # person.pets.replace(["doo", "ggie", "gaga"])
123
+ # # => ActiveRecord::AssociationTypeMismatch: Pet expected, got String
124
+ def replace(other_array)
125
+ @association.replace(other_array)
126
+ end
127
+
128
+ ##
129
+ # :method: count
130
+ #
131
+ # :call-seq:
132
+ # count(column_name = nil, &block)
133
+ #
134
+ # Count all records.
135
+ #
136
+ # class Person < ActiveRecord::Base
137
+ # has_many :pets
138
+ # end
139
+ #
140
+ # # This will perform the count using SQL.
141
+ # person.pets.count # => 3
142
+ # person.pets
143
+ # # => [
144
+ # # #<Pet id: 1, name: "Fancy-Fancy", person_id: 1>,
145
+ # # #<Pet id: 2, name: "Spook", person_id: 1>,
146
+ # # #<Pet id: 3, name: "Choo-Choo", person_id: 1>
147
+ # # ]
148
+ #
149
+ # Passing a block will select all of a person's pets in SQL and then
150
+ # perform the count using Ruby.
151
+ #
152
+ # person.pets.count { |pet| pet.name.include?('-') } # => 2
153
+
154
+ # Returns the size of the collection. If the collection hasn't been loaded,
155
+ # it executes a <tt>SELECT COUNT(*)</tt> query. Else it calls <tt>collection.size</tt>.
156
+ #
157
+ # If the collection has been already loaded +size+ and +length+ are
158
+ # equivalent. If not and you are going to need the records anyway
159
+ # +length+ will take one less query. Otherwise +size+ is more efficient.
160
+ #
161
+ # class Person < ActiveRecord::Base
162
+ # has_many :pets
163
+ # end
164
+ #
165
+ # person.pets.size # => 3
166
+ # # executes something like SELECT COUNT(*) FROM "pets" WHERE "pets"."person_id" = 1
167
+ #
168
+ # person.pets # This will execute a SELECT * FROM query
169
+ # # => [
170
+ # # #<Pet id: 1, name: "Fancy-Fancy", person_id: 1>,
171
+ # # #<Pet id: 2, name: "Spook", person_id: 1>,
172
+ # # #<Pet id: 3, name: "Choo-Choo", person_id: 1>
173
+ # # ]
174
+ #
175
+ # person.pets.size # => 3
176
+ # # Because the collection is already loaded, this will behave like
177
+ # # collection.size and no SQL count query is executed.
178
+ def size
179
+ @association.size
180
+ end
181
+
182
+ ##
183
+ # :method: length
184
+ #
185
+ # :call-seq:
186
+ # length()
187
+ #
188
+ # Returns the size of the collection calling +size+ on the target.
189
+ # If the collection has been already loaded, +length+ and +size+ are
190
+ # equivalent. If not and you are going to need the records anyway this
191
+ # method will take one less query. Otherwise +size+ is more efficient.
192
+ #
193
+ # class Person < ActiveRecord::Base
194
+ # has_many :pets
195
+ # end
196
+ #
197
+ # person.pets.length # => 3
198
+ # # executes something like SELECT "pets".* FROM "pets" WHERE "pets"."person_id" = 1
199
+ #
200
+ # # Because the collection is loaded, you can
201
+ # # call the collection with no additional queries:
202
+ # person.pets
203
+ # # => [
204
+ # # #<Pet id: 1, name: "Fancy-Fancy", person_id: 1>,
205
+ # # #<Pet id: 2, name: "Spook", person_id: 1>,
206
+ # # #<Pet id: 3, name: "Choo-Choo", person_id: 1>
207
+ # # ]
208
+
209
+ # Returns +true+ if the collection is empty. If the collection has been
210
+ # loaded it is equivalent
211
+ # to <tt>collection.size.zero?</tt>. If the collection has not been loaded,
212
+ # it is equivalent to <tt>!collection.exists?</tt>. If the collection has
213
+ # not already been loaded and you are going to fetch the records anyway it
214
+ # is better to check <tt>collection.load.empty?</tt>.
215
+ #
216
+ # class Person < ActiveRecord::Base
217
+ # has_many :pets
218
+ # end
219
+ #
220
+ # person.pets.count # => 1
221
+ # person.pets.empty? # => false
222
+ #
223
+ # person.pets.delete_all
224
+ #
225
+ # person.pets.count # => 0
226
+ # person.pets.empty? # => true
227
+ def empty?
228
+ @association.empty?
229
+ end
230
+
231
+ ##
232
+ # :method: any?
233
+ #
234
+ # :call-seq:
235
+ # any?()
236
+ #
237
+ # Returns +true+ if the collection is not empty.
238
+ #
239
+ # class Person < ActiveRecord::Base
240
+ # has_many :pets
241
+ # end
242
+ #
243
+ # person.pets.count # => 0
244
+ # person.pets.any? # => false
245
+ #
246
+ # person.pets << Pet.new(name: 'Snoop')
247
+ # person.pets.count # => 1
248
+ # person.pets.any? # => true
249
+ #
250
+ # Calling it without a block when the collection is not yet
251
+ # loaded is equivalent to <tt>collection.exists?</tt>.
252
+ # If you're going to load the collection anyway, it is better
253
+ # to call <tt>collection.load.any?</tt> to avoid an extra query.
254
+ #
255
+ # You can also pass a +block+ to define criteria. The behavior
256
+ # is the same, it returns true if the collection based on the
257
+ # criteria is not empty.
258
+ #
259
+ # person.pets
260
+ # # => [#<Pet name: "Snoop", group: "dogs">]
261
+ #
262
+ # person.pets.any? do |pet|
263
+ # pet.group == 'cats'
264
+ # end
265
+ # # => false
266
+ #
267
+ # person.pets.any? do |pet|
268
+ # pet.group == 'dogs'
269
+ # end
270
+ # # => true
271
+
272
+ ##
273
+ # :method: many?
274
+ #
275
+ # :call-seq:
276
+ # many?()
277
+ #
278
+ # Returns true if the collection has more than one record.
279
+ # Equivalent to <tt>collection.size > 1</tt>.
280
+ #
281
+ # class Person < ActiveRecord::Base
282
+ # has_many :pets
283
+ # end
284
+ #
285
+ # person.pets.count # => 1
286
+ # person.pets.many? # => false
287
+ #
288
+ # person.pets << Pet.new(name: 'Snoopy')
289
+ # person.pets.count # => 2
290
+ # person.pets.many? # => true
291
+ #
292
+ # You can also pass a +block+ to define criteria. The
293
+ # behavior is the same, it returns true if the collection
294
+ # based on the criteria has more than one record.
295
+ #
296
+ # person.pets
297
+ # # => [
298
+ # # #<Pet name: "Gorby", group: "cats">,
299
+ # # #<Pet name: "Puff", group: "cats">,
300
+ # # #<Pet name: "Snoop", group: "dogs">
301
+ # # ]
302
+ #
303
+ # person.pets.many? do |pet|
304
+ # pet.group == 'dogs'
305
+ # end
306
+ # # => false
307
+ #
308
+ # person.pets.many? do |pet|
309
+ # pet.group == 'cats'
310
+ # end
311
+ # # => true
312
+
313
+ # Returns +true+ if the given +record+ is present in the collection.
314
+ #
315
+ # class Person < ActiveRecord::Base
316
+ # has_many :pets
317
+ # end
318
+ #
319
+ # person.pets # => [#<Pet id: 20, name: "Snoop">]
320
+ #
321
+ # person.pets.include?(Pet.find(20)) # => true
322
+ # person.pets.include?(Pet.find(21)) # => false
323
+ def include?(record)
324
+ !!@association.include?(record)
325
+ end
326
+
327
+ def proxy_association # :nodoc:
328
+ @association
329
+ end
330
+
331
+ # Returns a <tt>Relation</tt> object for the records in this association
332
+ def scope
333
+ @scope ||= @association.scope
334
+ end
335
+
336
+ # Equivalent to <tt>Array#==</tt>. Returns +true+ if the two arrays
337
+ # contain the same number of elements and if each element is equal
338
+ # to the corresponding element in the +other+ array, otherwise returns
339
+ # +false+.
340
+ #
341
+ # class Person < ActiveRecord::Base
342
+ # has_many :pets
343
+ # end
344
+ #
345
+ # person.pets
346
+ # # => [
347
+ # # #<Pet id: 1, name: "Fancy-Fancy", person_id: 1>,
348
+ # # #<Pet id: 2, name: "Spook", person_id: 1>
349
+ # # ]
350
+ #
351
+ # other = person.pets.to_ary
352
+ #
353
+ # person.pets == other
354
+ # # => true
355
+ #
356
+ # other = [Pet.new(id: 1), Pet.new(id: 2)]
357
+ #
358
+ # person.pets == other
359
+ # # => false
360
+ def ==(other)
361
+ load_target == other
362
+ end
363
+
364
+ ##
365
+ # :method: to_ary
366
+ #
367
+ # :call-seq:
368
+ # to_ary()
369
+ #
370
+ # Returns a new array of objects from the collection. If the collection
371
+ # hasn't been loaded, it fetches the records from the database.
372
+ #
373
+ # class Person < ActiveRecord::Base
374
+ # has_many :pets
375
+ # end
376
+ #
377
+ # person.pets
378
+ # # => [
379
+ # # #<Pet id: 4, name: "Benny", person_id: 1>,
380
+ # # #<Pet id: 5, name: "Brain", person_id: 1>,
381
+ # # #<Pet id: 6, name: "Boss", person_id: 1>
382
+ # # ]
383
+ #
384
+ # other_pets = person.pets.to_ary
385
+ # # => [
386
+ # # #<Pet id: 4, name: "Benny", person_id: 1>,
387
+ # # #<Pet id: 5, name: "Brain", person_id: 1>,
388
+ # # #<Pet id: 6, name: "Boss", person_id: 1>
389
+ # # ]
390
+ #
391
+ # other_pets.replace([Pet.new(name: 'BooGoo')])
392
+ #
393
+ # other_pets
394
+ # # => [#<Pet id: nil, name: "BooGoo", person_id: 1>]
395
+ #
396
+ # person.pets
397
+ # # This is not affected by replace
398
+ # # => [
399
+ # # #<Pet id: 4, name: "Benny", person_id: 1>,
400
+ # # #<Pet id: 5, name: "Brain", person_id: 1>,
401
+ # # #<Pet id: 6, name: "Boss", person_id: 1>
402
+ # # ]
403
+
404
+ def records # :nodoc:
405
+ load_target
406
+ end
407
+
408
+ # Adds one or more +records+ to the collection by setting their foreign keys
409
+ # to the association's primary key. Since <tt><<</tt> flattens its argument list and
410
+ # inserts each record, +push+ and +concat+ behave identically. Returns +self+
411
+ # so several appends may be chained together.
412
+ #
413
+ # class Person < ActiveRecord::Base
414
+ # has_many :pets
415
+ # end
416
+ #
417
+ # person.pets.size # => 0
418
+ # person.pets << Pet.new(name: 'Fancy-Fancy')
419
+ # person.pets << [Pet.new(name: 'Spook'), Pet.new(name: 'Choo-Choo')]
420
+ # person.pets.size # => 3
421
+ #
422
+ # person.id # => 1
423
+ # person.pets
424
+ # # => [
425
+ # # #<Pet id: 1, name: "Fancy-Fancy", person_id: 1>,
426
+ # # #<Pet id: 2, name: "Spook", person_id: 1>,
427
+ # # #<Pet id: 3, name: "Choo-Choo", person_id: 1>
428
+ # # ]
429
+ def <<(*records)
430
+ proxy_association.concat(records) && self
431
+ end
432
+ alias push <<
433
+ alias append <<
434
+ alias concat <<
435
+
436
+ def prepend(*_args) # :nodoc:
437
+ raise NoMethodError, "prepend on association is not defined. Please use <<, push or append"
438
+ end
439
+
440
+ # Equivalent to +delete_all+. The difference is that returns +self+, instead
441
+ # of an array with the deleted objects, so methods can be chained. See
442
+ # +delete_all+ for more information.
443
+ # Note that because +delete_all+ removes records by directly
444
+ # running an SQL query into the database, the +updated_at+ column of
445
+ # the object is not changed.
446
+ def clear
447
+ delete_all
448
+ self
449
+ end
450
+
451
+ # Reloads the collection from the database. Returns +self+.
452
+ #
453
+ # class Person < ActiveRecord::Base
454
+ # has_many :pets
455
+ # end
456
+ #
457
+ # person.pets # fetches pets from the database
458
+ # # => [#<Pet id: 1, name: "Snoop", group: "dogs", person_id: 1>]
459
+ #
460
+ # person.pets # uses the pets cache
461
+ # # => [#<Pet id: 1, name: "Snoop", group: "dogs", person_id: 1>]
462
+ #
463
+ # person.pets.reload # fetches pets from the database
464
+ # # => [#<Pet id: 1, name: "Snoop", group: "dogs", person_id: 1>]
465
+ def reload
466
+ proxy_association.reload(true)
467
+ reset_scope
468
+ end
469
+
470
+ # Unloads the association. Returns +self+.
471
+ #
472
+ # class Person < ActiveRecord::Base
473
+ # has_many :pets
474
+ # end
475
+ #
476
+ # person.pets # fetches pets from the database
477
+ # # => [#<Pet id: 1, name: "Snoop", group: "dogs", person_id: 1>]
478
+ #
479
+ # person.pets # uses the pets cache
480
+ # # => [#<Pet id: 1, name: "Snoop", group: "dogs", person_id: 1>]
481
+ #
482
+ # person.pets.reset # clears the pets cache
483
+ #
484
+ # person.pets # fetches pets from the database
485
+ # # => [#<Pet id: 1, name: "Snoop", group: "dogs", person_id: 1>]
486
+ def reset
487
+ end
488
+
489
+ def reset_scope # :nodoc:
490
+ self
491
+ end
492
+
493
+ def inspect # :nodoc:
494
+ load_target if find_from_target?
495
+ super
496
+ end
497
+
498
+ private
499
+
500
+ def find_nth_with_limit(index, limit)
501
+ load_target if find_from_target?
502
+ super
503
+ end
504
+
505
+ def find_nth_from_last(index)
506
+ load_target if find_from_target?
507
+ super
508
+ end
509
+
510
+ def find_from_target?
511
+ @association.find_from_target?
512
+ end
513
+
514
+ def exec_queries
515
+ load_target
516
+ end
517
+ end
518
+ end
519
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formed
4
+ module Associations
5
+ module ForeignAssociation # :nodoc:
6
+ def foreign_key_present?
7
+ if reflection.klass.primary_key
8
+ owner.attribute_present?(reflection.active_record_primary_key)
9
+ else
10
+ false
11
+ end
12
+ end
13
+
14
+ def nullified_owner_attributes
15
+ {}.tap do |attrs|
16
+ attrs[reflection.foreign_key] = nil
17
+ attrs[reflection.type] = nil if reflection.type.present?
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ # Sets the owner attributes on the given record
24
+ def set_owner_attributes(record)
25
+ return if options[:through]
26
+
27
+ return
28
+ key = owner.send(:_read_attribute, reflection.join_foreign_key)
29
+ record._write_attribute(reflection.join_primary_key, key)
30
+
31
+ return unless reflection.type
32
+
33
+ record._write_attribute(reflection.type, owner.class.polymorphic_name)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formed
4
+ module Associations
5
+ class HasManyAssociation < CollectionAssociation # :nodoc:
6
+ include ForeignAssociation
7
+
8
+ def insert_record(record, validate = true, raise = false)
9
+ set_owner_attributes(record)
10
+ super
11
+ end
12
+
13
+ private
14
+
15
+ # Returns the number of records in this collection.
16
+ #
17
+ # If the association has a counter cache it gets that value. Otherwise
18
+ # it will attempt to do a count via SQL, bounded to <tt>:limit</tt> if
19
+ # there's one. Some configuration options like :group make it impossible
20
+ # to do an SQL count, in those cases the array count will be used.
21
+ #
22
+ # That does not depend on whether the collection has already been loaded
23
+ # or not. The +size+ method is the one that takes the loaded flag into
24
+ # account and delegates to +count_records+ if needed.
25
+ #
26
+ # If the collection is empty the target is set to an empty array and
27
+ # the loaded flag is set to true as well.
28
+ def count_records
29
+ count = if reflection.has_cached_counter?
30
+ owner.read_attribute(reflection.counter_cache_column).to_i
31
+ else
32
+ scope.count(:all)
33
+ end
34
+
35
+ # If there's nothing in the database, @target should only contain new
36
+ # records or be an empty array. This is a documented side-effect of
37
+ # the method that may avoid an extra SELECT.
38
+ if count.zero?
39
+ target.select!(&:new_record?)
40
+ loaded!
41
+ end
42
+
43
+ [10, count].compact.min
44
+ end
45
+
46
+ def _create_record(attributes, *)
47
+ if attributes.is_a?(Array)
48
+ super
49
+ else
50
+ update_counter_if_success(super, 1)
51
+ end
52
+ end
53
+
54
+ def difference(a, b)
55
+ a - b
56
+ end
57
+
58
+ def intersection(a, b)
59
+ a & b
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formed
4
+ module Associations
5
+ class HasOneAssociation < SingularAssociation # :nodoc:
6
+ include ForeignAssociation
7
+
8
+ private
9
+
10
+ def replace(record, _save = true)
11
+ return target unless load_target || record
12
+
13
+ target
14
+
15
+ self.target = record
16
+ end
17
+
18
+ def set_new_record(record)
19
+ replace(record, false)
20
+ end
21
+
22
+ def nullify_owner_attributes(_record)
23
+ nil
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formed
4
+ module Associations
5
+ class SingularAssociation < Association # :nodoc:
6
+ # Implements the reader method, e.g. foo.bar for Foo.has_one :bar
7
+ def reader
8
+ ensure_klass_exists!
9
+
10
+ reload if !loaded? || stale_target?
11
+
12
+ target
13
+ end
14
+
15
+ # Implements the writer method, e.g. foo.bar= for Foo.belongs_to :bar
16
+ def writer(record)
17
+ record = build_record(record) unless record.class < Formed::Base
18
+ replace(record)
19
+ end
20
+
21
+ def build(attributes = nil, &block)
22
+ record = build_record(attributes, &block)
23
+ set_new_record(record)
24
+ record
25
+ end
26
+
27
+ # Implements the reload reader method, e.g. foo.reload_bar for
28
+ # Foo.has_one :bar
29
+ def force_reload_reader
30
+ reload(true)
31
+ target
32
+ end
33
+
34
+ private
35
+
36
+ def scope_for_create
37
+ super.except!(klass.primary_key)
38
+ end
39
+
40
+ def find_target
41
+ if disable_joins
42
+ scope.first
43
+ else
44
+ super.first
45
+ end
46
+ end
47
+
48
+ def replace(record)
49
+ raise NotImplementedError, "Subclasses must implement a replace(record) method"
50
+ end
51
+
52
+ def set_new_record(record)
53
+ replace(record)
54
+ end
55
+
56
+ def _create_record(attributes, raise_error = false, &block)
57
+ record = build_record(attributes, &block)
58
+ saved = record.save
59
+ set_new_record(record)
60
+ raise RecordInvalid, record if !saved && raise_error
61
+
62
+ record
63
+ end
64
+ end
65
+ end
66
+ end