tallty_duck_record 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 (79) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +41 -0
  3. data/README.md +82 -0
  4. data/Rakefile +28 -0
  5. data/lib/core_ext/array_without_blank.rb +46 -0
  6. data/lib/duck_record.rb +65 -0
  7. data/lib/duck_record/associations.rb +130 -0
  8. data/lib/duck_record/associations/association.rb +271 -0
  9. data/lib/duck_record/associations/belongs_to_association.rb +71 -0
  10. data/lib/duck_record/associations/builder/association.rb +127 -0
  11. data/lib/duck_record/associations/builder/belongs_to.rb +44 -0
  12. data/lib/duck_record/associations/builder/collection_association.rb +45 -0
  13. data/lib/duck_record/associations/builder/embeds_many.rb +9 -0
  14. data/lib/duck_record/associations/builder/embeds_one.rb +9 -0
  15. data/lib/duck_record/associations/builder/has_many.rb +11 -0
  16. data/lib/duck_record/associations/builder/has_one.rb +20 -0
  17. data/lib/duck_record/associations/builder/singular_association.rb +33 -0
  18. data/lib/duck_record/associations/collection_association.rb +476 -0
  19. data/lib/duck_record/associations/collection_proxy.rb +1160 -0
  20. data/lib/duck_record/associations/embeds_association.rb +92 -0
  21. data/lib/duck_record/associations/embeds_many_association.rb +203 -0
  22. data/lib/duck_record/associations/embeds_many_proxy.rb +892 -0
  23. data/lib/duck_record/associations/embeds_one_association.rb +48 -0
  24. data/lib/duck_record/associations/foreign_association.rb +11 -0
  25. data/lib/duck_record/associations/has_many_association.rb +17 -0
  26. data/lib/duck_record/associations/has_one_association.rb +39 -0
  27. data/lib/duck_record/associations/singular_association.rb +73 -0
  28. data/lib/duck_record/attribute.rb +213 -0
  29. data/lib/duck_record/attribute/user_provided_default.rb +30 -0
  30. data/lib/duck_record/attribute_assignment.rb +118 -0
  31. data/lib/duck_record/attribute_decorators.rb +89 -0
  32. data/lib/duck_record/attribute_methods.rb +325 -0
  33. data/lib/duck_record/attribute_methods/before_type_cast.rb +76 -0
  34. data/lib/duck_record/attribute_methods/dirty.rb +107 -0
  35. data/lib/duck_record/attribute_methods/read.rb +78 -0
  36. data/lib/duck_record/attribute_methods/serialization.rb +66 -0
  37. data/lib/duck_record/attribute_methods/write.rb +70 -0
  38. data/lib/duck_record/attribute_mutation_tracker.rb +108 -0
  39. data/lib/duck_record/attribute_set.rb +98 -0
  40. data/lib/duck_record/attribute_set/yaml_encoder.rb +41 -0
  41. data/lib/duck_record/attributes.rb +262 -0
  42. data/lib/duck_record/base.rb +300 -0
  43. data/lib/duck_record/callbacks.rb +324 -0
  44. data/lib/duck_record/coders/json.rb +13 -0
  45. data/lib/duck_record/coders/yaml_column.rb +48 -0
  46. data/lib/duck_record/core.rb +262 -0
  47. data/lib/duck_record/define_callbacks.rb +23 -0
  48. data/lib/duck_record/enum.rb +139 -0
  49. data/lib/duck_record/errors.rb +71 -0
  50. data/lib/duck_record/inheritance.rb +130 -0
  51. data/lib/duck_record/locale/en.yml +46 -0
  52. data/lib/duck_record/model_schema.rb +71 -0
  53. data/lib/duck_record/nested_attributes.rb +555 -0
  54. data/lib/duck_record/nested_validate_association.rb +262 -0
  55. data/lib/duck_record/persistence.rb +39 -0
  56. data/lib/duck_record/readonly_attributes.rb +36 -0
  57. data/lib/duck_record/reflection.rb +650 -0
  58. data/lib/duck_record/serialization.rb +26 -0
  59. data/lib/duck_record/translation.rb +22 -0
  60. data/lib/duck_record/type.rb +77 -0
  61. data/lib/duck_record/type/array.rb +36 -0
  62. data/lib/duck_record/type/array_without_blank.rb +36 -0
  63. data/lib/duck_record/type/date.rb +7 -0
  64. data/lib/duck_record/type/date_time.rb +7 -0
  65. data/lib/duck_record/type/decimal_without_scale.rb +13 -0
  66. data/lib/duck_record/type/internal/abstract_json.rb +33 -0
  67. data/lib/duck_record/type/internal/timezone.rb +15 -0
  68. data/lib/duck_record/type/json.rb +6 -0
  69. data/lib/duck_record/type/registry.rb +97 -0
  70. data/lib/duck_record/type/serialized.rb +63 -0
  71. data/lib/duck_record/type/text.rb +9 -0
  72. data/lib/duck_record/type/time.rb +19 -0
  73. data/lib/duck_record/type/unsigned_integer.rb +15 -0
  74. data/lib/duck_record/validations.rb +67 -0
  75. data/lib/duck_record/validations/subset.rb +74 -0
  76. data/lib/duck_record/validations/uniqueness_on_real_record.rb +248 -0
  77. data/lib/duck_record/version.rb +3 -0
  78. data/lib/tasks/acts_as_record_tasks.rake +4 -0
  79. metadata +181 -0
@@ -0,0 +1,71 @@
1
+ module DuckRecord
2
+ # = Active Record Belongs To Association
3
+ module Associations
4
+ class BelongsToAssociation < SingularAssociation #:nodoc:
5
+ def replace(record)
6
+ if owner.class.readonly_attributes.include?(reflection.foreign_key.to_s)
7
+ return
8
+ end
9
+
10
+ if record
11
+ raise_on_type_mismatch!(record)
12
+ replace_keys(record)
13
+ @updated = true
14
+ else
15
+ remove_keys
16
+ end
17
+
18
+ self.target = record
19
+ end
20
+
21
+ def default(&block)
22
+ writer(owner.instance_exec(&block)) if reader.nil?
23
+ end
24
+
25
+ def reset
26
+ super
27
+ @updated = false
28
+ end
29
+
30
+ def updated?
31
+ @updated
32
+ end
33
+
34
+ private
35
+
36
+ def find_target?
37
+ !loaded? && foreign_key_present? && klass
38
+ end
39
+
40
+ # Checks whether record is different to the current target, without loading it
41
+ def different_target?(record)
42
+ record.id != owner._read_attribute(reflection.foreign_key)
43
+ end
44
+
45
+ def replace_keys(record)
46
+ owner[reflection.foreign_key] = record._read_attribute(reflection.association_primary_key(record.class))
47
+ end
48
+
49
+ def remove_keys
50
+ owner[reflection.foreign_key] = nil
51
+ end
52
+
53
+ def foreign_key_present?
54
+ owner._read_attribute(reflection.foreign_key)
55
+ end
56
+
57
+ def target_id
58
+ if options[:primary_key]
59
+ owner.send(reflection.name).try(:id)
60
+ else
61
+ owner._read_attribute(reflection.foreign_key)
62
+ end
63
+ end
64
+
65
+ def stale_state
66
+ result = owner._read_attribute(reflection.foreign_key) { |n| owner.send(:missing_attribute, n, caller) }
67
+ result&.to_s
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,127 @@
1
+ # This is the parent Association class which defines the variables
2
+ # used by all associations.
3
+ #
4
+ # The hierarchy is defined as follows:
5
+ # Association
6
+ # - SingularAssociation
7
+ # - BelongsToAssociation
8
+ # - HasOneAssociation
9
+ # - CollectionAssociation
10
+ # - HasManyAssociation
11
+
12
+ module DuckRecord::Associations::Builder # :nodoc:
13
+ class Association #:nodoc:
14
+ class << self
15
+ attr_accessor :extensions
16
+ end
17
+ self.extensions = []
18
+
19
+ VALID_OPTIONS = [:class_name, :anonymous_class, :foreign_key, :validate] # :nodoc:
20
+
21
+ def self.build(model, name, scope, options, &block)
22
+ if model.dangerous_attribute_method?(name)
23
+ raise ArgumentError, "You tried to define an association named #{name} on the model #{model.name}, but " \
24
+ "this will conflict with a method #{name} already defined by Active Record. " \
25
+ "Please choose a different association name."
26
+ end
27
+
28
+ extension = define_extensions model, name, &block
29
+ reflection = create_reflection model, name, scope, options, extension
30
+ define_accessors model, reflection
31
+ define_callbacks model, reflection
32
+ define_validations model, reflection
33
+ reflection
34
+ end
35
+
36
+ def self.create_reflection(model, name, scope, options, extension = nil)
37
+ raise ArgumentError, "association names must be a Symbol" unless name.kind_of?(Symbol)
38
+
39
+ if scope.is_a?(Hash)
40
+ options = scope
41
+ scope = nil
42
+ end
43
+
44
+ validate_options(options)
45
+
46
+ scope = build_scope(scope, extension)
47
+
48
+ DuckRecord::Reflection.create(macro, name, scope, options, model)
49
+ end
50
+
51
+ def self.build_scope(scope, extension)
52
+ new_scope = scope
53
+
54
+ if scope && scope.arity == 0
55
+ new_scope = proc { instance_exec(&scope) }
56
+ end
57
+
58
+ if extension
59
+ new_scope = wrap_scope new_scope, extension
60
+ end
61
+
62
+ new_scope
63
+ end
64
+
65
+ def self.wrap_scope(scope, _extension)
66
+ scope
67
+ end
68
+
69
+ def self.macro
70
+ raise NotImplementedError
71
+ end
72
+
73
+ def self.valid_options(options)
74
+ VALID_OPTIONS + Association.extensions.flat_map(&:valid_options)
75
+ end
76
+
77
+ def self.validate_options(options)
78
+ options.assert_valid_keys(valid_options(options))
79
+ end
80
+
81
+ def self.define_extensions(model, name)
82
+ end
83
+
84
+ def self.define_callbacks(model, reflection)
85
+ Association.extensions.each do |extension|
86
+ extension.build model, reflection
87
+ end
88
+ end
89
+
90
+ # Defines the setter and getter methods for the association
91
+ # class Post < ActiveRecord::Base
92
+ # has_many :comments
93
+ # end
94
+ #
95
+ # Post.first.comments and Post.first.comments= methods are defined by this method...
96
+ def self.define_accessors(model, reflection)
97
+ mixin = model.generated_association_methods
98
+ name = reflection.name
99
+ define_readers(mixin, name)
100
+ define_writers(mixin, name)
101
+ end
102
+
103
+ def self.define_readers(mixin, name)
104
+ mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
105
+ def #{name}(*args)
106
+ association(:#{name}).reader(*args)
107
+ end
108
+ CODE
109
+ end
110
+
111
+ def self.define_writers(mixin, name)
112
+ mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
113
+ def #{name}=(value)
114
+ if self.class.readonly_attributes.include?("#{name}") && attr_readonly_enabled?
115
+ return
116
+ end
117
+
118
+ association(:#{name}).writer(value)
119
+ end
120
+ CODE
121
+ end
122
+
123
+ def self.define_validations(model, reflection)
124
+ # noop
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,44 @@
1
+ module DuckRecord::Associations::Builder # :nodoc:
2
+ class BelongsTo < SingularAssociation #:nodoc:
3
+ def self.macro
4
+ :belongs_to
5
+ end
6
+
7
+ def self.valid_options(_options)
8
+ super + [:optional, :default]
9
+ end
10
+
11
+ def self.define_callbacks(model, reflection)
12
+ super
13
+ add_default_callbacks(model, reflection) if reflection.options[:default]
14
+ end
15
+
16
+ def self.define_accessors(mixin, reflection)
17
+ super
18
+ end
19
+
20
+ def self.add_default_callbacks(model, reflection)
21
+ model.before_validation lambda { |o|
22
+ o.association(reflection.name).default(&reflection.options[:default])
23
+ }
24
+ end
25
+
26
+ def self.define_validations(model, reflection)
27
+ if reflection.options.key?(:required)
28
+ reflection.options[:optional] = !reflection.options.delete(:required)
29
+ end
30
+
31
+ if reflection.options[:optional].nil?
32
+ required = true
33
+ else
34
+ required = !reflection.options[:optional]
35
+ end
36
+
37
+ super
38
+
39
+ if required
40
+ model.validates_presence_of reflection.name, message: :required
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,45 @@
1
+ # This class is inherited by the has_many and has_many_and_belongs_to_many association classes
2
+ module DuckRecord::Associations::Builder # :nodoc:
3
+ class CollectionAssociation < Association #:nodoc:
4
+ CALLBACKS = [:before_add, :after_add, :before_remove, :after_remove]
5
+
6
+ def self.valid_options(_options)
7
+ super + [:index_errors] + CALLBACKS
8
+ end
9
+
10
+ def self.define_callbacks(model, reflection)
11
+ super
12
+ name = reflection.name
13
+ options = reflection.options
14
+ CALLBACKS.each { |callback_name|
15
+ define_callback(model, callback_name, name, options)
16
+ }
17
+ end
18
+
19
+ def self.define_extensions(model, name)
20
+ if block_given?
21
+ extension_module_name = "#{model.name.demodulize}#{name.to_s.camelize}AssociationExtension"
22
+ extension = Module.new(&Proc.new)
23
+ model.parent.const_set(extension_module_name, extension)
24
+ end
25
+ end
26
+
27
+ def self.define_callback(model, callback_name, name, options)
28
+ full_callback_name = "#{callback_name}_for_#{name}"
29
+
30
+ # TODO : why do i need method_defined? I think its because of the inheritance chain
31
+ model.class_attribute full_callback_name unless model.method_defined?(full_callback_name)
32
+ callbacks = Array(options[callback_name.to_sym]).map do |callback|
33
+ case callback
34
+ when Symbol
35
+ ->(_method, owner, record) { owner.send(callback, record) }
36
+ when Proc
37
+ ->(_method, owner, record) { callback.call(owner, record) }
38
+ else
39
+ ->(method, owner, record) { callback.send(method, owner, record) }
40
+ end
41
+ end
42
+ model.send "#{full_callback_name}=", callbacks
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,9 @@
1
+ # This class is inherited by the has_many and has_many_and_belongs_to_many association classes
2
+
3
+ module DuckRecord::Associations::Builder # :nodoc:
4
+ class EmbedsMany < CollectionAssociation #:nodoc:
5
+ def self.macro
6
+ :embeds_many
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # This class is inherited by the has_one and belongs_to association classes
2
+
3
+ module DuckRecord::Associations::Builder # :nodoc:
4
+ class EmbedsOne < SingularAssociation #:nodoc:
5
+ def self.macro
6
+ :embeds_one
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,11 @@
1
+ module DuckRecord::Associations::Builder # :nodoc:
2
+ class HasMany < CollectionAssociation #:nodoc:
3
+ def self.macro
4
+ :has_many
5
+ end
6
+
7
+ def self.valid_options(_options)
8
+ super + [:primary_key, :through, :source, :source_type, :join_table, :foreign_type, :index_errors]
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,20 @@
1
+ module DuckRecord::Associations::Builder # :nodoc:
2
+ class HasOne < SingularAssociation #:nodoc:
3
+ def self.macro
4
+ :has_one
5
+ end
6
+
7
+ def self.valid_options(options)
8
+ valid = super
9
+ valid += [:through, :source, :source_type] if options[:through]
10
+ valid
11
+ end
12
+
13
+ def self.define_validations(model, reflection)
14
+ super
15
+ if reflection.options[:required]
16
+ model.validates_presence_of reflection.name, message: :required
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,33 @@
1
+ # This class is inherited by the has_one and belongs_to association classes
2
+ module DuckRecord::Associations::Builder # :nodoc:
3
+ class SingularAssociation < Association #:nodoc:
4
+ def self.valid_options(options)
5
+ super + [:primary_key, :required]
6
+ end
7
+
8
+ def self.define_validations(model, reflection)
9
+ super
10
+
11
+ if reflection.options[:required]
12
+ model.validates_presence_of reflection.name, message: :required
13
+ end
14
+ end
15
+
16
+ def self.define_accessors(model, reflection)
17
+ super
18
+ mixin = model.generated_association_methods
19
+ name = reflection.name
20
+
21
+ define_constructors(mixin, name) if reflection.constructable?
22
+ end
23
+
24
+ # Defines the (build|create)_association methods for belongs_to or has_one association
25
+ def self.define_constructors(mixin, name)
26
+ mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
27
+ def build_#{name}(*args, &block)
28
+ association(:#{name}).build(*args, &block)
29
+ end
30
+ CODE
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,476 @@
1
+ module DuckRecord
2
+ module Associations
3
+ # = Active Record Association Collection
4
+ #
5
+ # CollectionAssociation is an abstract class that provides common stuff to
6
+ # ease the implementation of association proxies that represent
7
+ # collections. See the class hierarchy in Association.
8
+ #
9
+ # CollectionAssociation:
10
+ # HasManyAssociation => has_many
11
+ # HasManyThroughAssociation + ThroughAssociation => has_many :through
12
+ #
13
+ # The CollectionAssociation class provides common methods to the collections
14
+ # defined by +has_and_belongs_to_many+, +has_many+ or +has_many+ with
15
+ # the +:through association+ option.
16
+ #
17
+ # You need to be careful with assumptions regarding the target: The proxy
18
+ # does not fetch records from the database until it needs them, but new
19
+ # ones created with +build+ are added to the target. So, the target may be
20
+ # non-empty and still lack children waiting to be read from the database.
21
+ # If you look directly to the database you cannot assume that's the entire
22
+ # collection because new records may have been added to the target, etc.
23
+ #
24
+ # If you need to work on all current children, new and existing records,
25
+ # +load_target+ and the +loaded+ flag are your friends.
26
+ class CollectionAssociation < Association #:nodoc:
27
+ # Implements the reader method, e.g. foo.items for Foo.has_many :items
28
+ def reader
29
+ if stale_target?
30
+ reload
31
+ end
32
+
33
+ @proxy ||= CollectionProxy.new(klass, self)
34
+ @proxy.reset_scope
35
+ end
36
+
37
+ # Implements the writer method, e.g. foo.items= for Foo.has_many :items
38
+ def writer(records)
39
+ replace(records)
40
+ end
41
+
42
+ # Implements the ids reader method, e.g. foo.item_ids for Foo.has_many :items
43
+ def ids_reader
44
+ if loaded?
45
+ target.pluck(reflection.association_primary_key)
46
+ else
47
+ @association_ids ||= scope.pluck(reflection.association_primary_key)
48
+ end
49
+ end
50
+
51
+ # Implements the ids writer method, e.g. foo.item_ids= for Foo.has_many :items
52
+ def ids_writer(ids)
53
+ pk_type = reflection.association_primary_key_type
54
+ ids = Array(ids).reject(&:blank?)
55
+ ids.map! { |i| pk_type.cast(i) }
56
+
57
+ primary_key = reflection.association_primary_key
58
+ records = klass.where(primary_key => ids).index_by do |r|
59
+ r.public_send(primary_key)
60
+ end.values_at(*ids).compact
61
+
62
+ if records.size != ids.size
63
+ klass.all.raise_record_not_found_exception!(ids, records.size, ids.size, primary_key)
64
+ else
65
+ replace(records)
66
+ end
67
+ end
68
+
69
+ def reset
70
+ super
71
+ @target = []
72
+ end
73
+
74
+ def find(*args)
75
+ if block_given?
76
+ load_target.find(*args) { |*block_args| yield(*block_args) }
77
+ else
78
+ scope.find(*args)
79
+ end
80
+ end
81
+
82
+ def build(attributes = {}, &block)
83
+ if attributes.is_a?(Array)
84
+ attributes.collect { |attr| build(attr, &block) }
85
+ else
86
+ add_to_target(build_record(attributes)) do |record|
87
+ yield(record) if block_given?
88
+ end
89
+ end
90
+ end
91
+
92
+ # Add +records+ to this association. Returns +self+ so method calls may
93
+ # be chained. Since << flattens its argument list and inserts each record,
94
+ # +push+ and +concat+ behave identically.
95
+ def concat(*records)
96
+ records = records.flatten
97
+ if owner.new_record?
98
+ load_target
99
+ concat_records(records)
100
+ else
101
+ transaction { concat_records(records) }
102
+ end
103
+ end
104
+
105
+ # Starts a transaction in the association class's database connection.
106
+ #
107
+ # class Author < ActiveRecord::Base
108
+ # has_many :books
109
+ # end
110
+ #
111
+ # Author.first.books.transaction do
112
+ # # same effect as calling Book.transaction
113
+ # end
114
+ def transaction(*args)
115
+ reflection.klass.transaction(*args) do
116
+ yield
117
+ end
118
+ end
119
+
120
+ # Removes all records from the association without calling callbacks
121
+ # on the associated records. It honors the +:dependent+ option. However
122
+ # if the +:dependent+ value is +:destroy+ then in that case the +:delete_all+
123
+ # deletion strategy for the association is applied.
124
+ #
125
+ # You can force a particular deletion strategy by passing a parameter.
126
+ #
127
+ # Example:
128
+ #
129
+ # @author.books.delete_all(:nullify)
130
+ # @author.books.delete_all(:delete_all)
131
+ #
132
+ # See delete for more info.
133
+ def delete_all
134
+ delete_or_nullify_all_records(:delete_all).tap do
135
+ reset
136
+ loaded!
137
+ end
138
+ end
139
+
140
+ # Destroy all the records from this association.
141
+ #
142
+ # See destroy for more info.
143
+ def destroy_all
144
+ destroy(load_target).tap do
145
+ reset
146
+ loaded!
147
+ end
148
+ end
149
+
150
+ # Removes +records+ from this association calling +before_remove+ and
151
+ # +after_remove+ callbacks.
152
+ #
153
+ # This method is abstract in the sense that +delete_records+ has to be
154
+ # provided by descendants. Note this method does not imply the records
155
+ # are actually removed from the database, that depends precisely on
156
+ # +delete_records+. They are in any case removed from the collection.
157
+ def delete(*records)
158
+ return if records.empty?
159
+ records = find(records) if records.any? { |record| record.kind_of?(Integer) || record.kind_of?(String) }
160
+ delete_or_destroy(records, options[:dependent])
161
+ end
162
+
163
+ # Deletes the +records+ and removes them from this association calling
164
+ # +before_remove+ , +after_remove+ , +before_destroy+ and +after_destroy+ callbacks.
165
+ #
166
+ # Note that this method removes records from the database ignoring the
167
+ # +:dependent+ option.
168
+ def destroy(*records)
169
+ return if records.empty?
170
+ records = find(records) if records.any? { |record| record.kind_of?(Integer) || record.kind_of?(String) }
171
+ delete_or_destroy(records, :destroy)
172
+ end
173
+
174
+ # Returns the size of the collection by executing a SELECT COUNT(*)
175
+ # query if the collection hasn't been loaded, and calling
176
+ # <tt>collection.size</tt> if it has.
177
+ #
178
+ # If the collection has been already loaded +size+ and +length+ are
179
+ # equivalent. If not and you are going to need the records anyway
180
+ # +length+ will take one less query. Otherwise +size+ is more efficient.
181
+ #
182
+ # This method is abstract in the sense that it relies on
183
+ # +count_records+, which is a method descendants have to provide.
184
+ def size
185
+ if !find_target? || loaded?
186
+ target.size
187
+ elsif !association_scope.group_values.empty?
188
+ load_target.size
189
+ elsif !association_scope.distinct_value && target.is_a?(Array)
190
+ unsaved_records = target.select(&:new_record?)
191
+ unsaved_records.size + count_records
192
+ else
193
+ count_records
194
+ end
195
+ end
196
+
197
+ # Returns true if the collection is empty.
198
+ #
199
+ # If the collection has been loaded
200
+ # it is equivalent to <tt>collection.size.zero?</tt>. If the
201
+ # collection has not been loaded, it is equivalent to
202
+ # <tt>collection.exists?</tt>. If the collection has not already been
203
+ # loaded and you are going to fetch the records anyway it is better to
204
+ # check <tt>collection.length.zero?</tt>.
205
+ def empty?
206
+ if loaded?
207
+ size.zero?
208
+ else
209
+ @target.blank? && !scope.exists?
210
+ end
211
+ end
212
+
213
+ # Replace this collection with +other_array+. This will perform a diff
214
+ # and delete/add only records that have changed.
215
+ def replace(other_array)
216
+ other_array.each { |val| raise_on_type_mismatch!(val) }
217
+ original_target = load_target.dup
218
+
219
+ if owner.new_record?
220
+ replace_records(other_array, original_target)
221
+ else
222
+ replace_common_records_in_memory(other_array, original_target)
223
+ if other_array != original_target
224
+ transaction { replace_records(other_array, original_target) }
225
+ else
226
+ other_array
227
+ end
228
+ end
229
+ end
230
+
231
+ def include?(record)
232
+ if record.is_a?(reflection.klass)
233
+ if record.new_record?
234
+ include_in_memory?(record)
235
+ else
236
+ loaded? ? target.include?(record) : scope.exists?(record.id)
237
+ end
238
+ else
239
+ false
240
+ end
241
+ end
242
+
243
+ def load_target
244
+ if find_target?
245
+ @target = merge_target_lists(find_target, target)
246
+ end
247
+
248
+ loaded!
249
+ target
250
+ end
251
+
252
+ def add_to_target(record, skip_callbacks = false, &block)
253
+ if association_scope.distinct_value
254
+ index = @target.index(record)
255
+ end
256
+ replace_on_target(record, index, skip_callbacks, &block)
257
+ end
258
+
259
+ def scope
260
+ scope = super
261
+ scope.none! if null_scope?
262
+ scope
263
+ end
264
+
265
+ def null_scope?
266
+ owner.new_record? && !foreign_key_present?
267
+ end
268
+
269
+ def find_from_target?
270
+ loaded? ||
271
+ owner.new_record? ||
272
+ target.any? { |record| record.new_record? || record.changed? }
273
+ end
274
+
275
+ private
276
+
277
+ def find_target
278
+ return scope.to_a if skip_statement_cache?
279
+
280
+ conn = klass.connection
281
+ sc = reflection.association_scope_cache(conn, owner) do
282
+ ActiveRecord::StatementCache.create(conn) { |params|
283
+ as = ActiveRecord::Associations::AssociationScope.create { params.bind }
284
+ target_scope.merge as.scope(self, conn)
285
+ }
286
+ end
287
+
288
+ binds = ActiveRecord::Associations::AssociationScope.get_bind_values(owner, reflection.chain)
289
+ sc.execute(binds, klass, conn) do |record|
290
+ set_inverse_instance(record)
291
+ end
292
+ end
293
+
294
+ # We have some records loaded from the database (persisted) and some that are
295
+ # in-memory (memory). The same record may be represented in the persisted array
296
+ # and in the memory array.
297
+ #
298
+ # So the task of this method is to merge them according to the following rules:
299
+ #
300
+ # * The final array must not have duplicates
301
+ # * The order of the persisted array is to be preserved
302
+ # * Any changes made to attributes on objects in the memory array are to be preserved
303
+ # * Otherwise, attributes should have the value found in the database
304
+ def merge_target_lists(persisted, memory)
305
+ return persisted if memory.empty?
306
+ return memory if persisted.empty?
307
+
308
+ persisted.map! do |record|
309
+ if mem_record = memory.delete(record)
310
+
311
+ ((record.attribute_names & mem_record.attribute_names) - mem_record.changed_attribute_names_to_save).each do |name|
312
+ mem_record[name] = record[name]
313
+ end
314
+
315
+ mem_record
316
+ else
317
+ record
318
+ end
319
+ end
320
+
321
+ persisted + memory
322
+ end
323
+
324
+ def _create_record(attributes, raise = false, &block)
325
+ unless owner.persisted?
326
+ raise ActiveRecord::RecordNotSaved, "You cannot call create unless the parent is saved"
327
+ end
328
+
329
+ if attributes.is_a?(Array)
330
+ attributes.collect { |attr| _create_record(attr, raise, &block) }
331
+ else
332
+ transaction do
333
+ add_to_target(build_record(attributes)) do |record|
334
+ yield(record) if block_given?
335
+ insert_record(record, true, raise) { @_was_loaded = loaded? }
336
+ end
337
+ end
338
+ end
339
+ end
340
+
341
+ # Do the relevant stuff to insert the given record into the association collection.
342
+ def insert_record(record, validate = true, raise = false, &block)
343
+ if raise
344
+ record.save!(validate: validate, &block)
345
+ else
346
+ record.save(validate: validate, &block)
347
+ end
348
+ end
349
+
350
+ def create_scope
351
+ scope.scope_for_create.stringify_keys
352
+ end
353
+
354
+ def delete_or_destroy(records, method)
355
+ records = records.flatten
356
+ records.each { |record| raise_on_type_mismatch!(record) }
357
+ existing_records = records.reject(&:new_record?)
358
+
359
+ if existing_records.empty?
360
+ remove_records(existing_records, records, method)
361
+ else
362
+ transaction { remove_records(existing_records, records, method) }
363
+ end
364
+ end
365
+
366
+ def remove_records(existing_records, records, method)
367
+ records.each { |record| callback(:before_remove, record) }
368
+
369
+ delete_records(existing_records, method) if existing_records.any?
370
+ records.each { |record| target.delete(record) }
371
+
372
+ records.each { |record| callback(:after_remove, record) }
373
+ end
374
+
375
+ # Delete the given records from the association,
376
+ # using one of the methods +:destroy+, +:delete_all+
377
+ # or +:nullify+ (or +nil+, in which case a default is used).
378
+ def delete_records(records, method)
379
+ raise NotImplementedError
380
+ end
381
+
382
+ def replace_records(new_target, original_target)
383
+ delete(target - new_target)
384
+
385
+ unless concat(new_target - target)
386
+ @target = original_target
387
+ raise RecordNotSaved, "Failed to replace #{reflection.name} because one or more of the " \
388
+ "new records could not be saved."
389
+ end
390
+
391
+ target
392
+ end
393
+
394
+ def replace_common_records_in_memory(new_target, original_target)
395
+ common_records = new_target & original_target
396
+ common_records.each do |record|
397
+ skip_callbacks = true
398
+ replace_on_target(record, @target.index(record), skip_callbacks)
399
+ end
400
+ end
401
+
402
+ def concat_records(records, raise = false)
403
+ result = true
404
+
405
+ records.each do |record|
406
+ raise_on_type_mismatch!(record)
407
+ add_to_target(record) do
408
+ result &&= insert_record(record, true, raise) { @_was_loaded = loaded? } unless owner.new_record?
409
+ end
410
+ end
411
+
412
+ result && records
413
+ end
414
+
415
+ def replace_on_target(record, index, skip_callbacks)
416
+ callback(:before_add, record) unless skip_callbacks
417
+
418
+ set_inverse_instance(record)
419
+
420
+ @_was_loaded = true
421
+
422
+ yield(record) if block_given?
423
+
424
+ if index
425
+ target[index] = record
426
+ elsif @_was_loaded || !loaded?
427
+ target << record
428
+ end
429
+
430
+ callback(:after_add, record) unless skip_callbacks
431
+
432
+ record
433
+ ensure
434
+ @_was_loaded = nil
435
+ end
436
+
437
+ def callback(method, record)
438
+ callbacks_for(method).each do |callback|
439
+ callback.call(method, owner, record)
440
+ end
441
+ end
442
+
443
+ def callbacks_for(callback_name)
444
+ full_callback_name = "#{callback_name}_for_#{reflection.name}"
445
+ owner.class.send(full_callback_name)
446
+ end
447
+
448
+ def include_in_memory?(record)
449
+ if reflection.is_a?(DuckRecord::Reflection::ThroughReflection)
450
+ assoc = owner.association(reflection.through_reflection.name)
451
+ assoc.reader.any? { |source|
452
+ target_reflection = source.send(reflection.source_reflection.name)
453
+ target_reflection.respond_to?(:include?) ? target_reflection.include?(record) : target_reflection == record
454
+ } || target.include?(record)
455
+ else
456
+ target.include?(record)
457
+ end
458
+ end
459
+
460
+ # If the :inverse_of option has been
461
+ # specified, then #find scans the entire collection.
462
+ def find_by_scan(*args)
463
+ expects_array = args.first.kind_of?(Array)
464
+ ids = args.flatten.compact.map(&:to_s).uniq
465
+
466
+ if ids.size == 1
467
+ id = ids.first
468
+ record = load_target.detect { |r| id == r.id.to_s }
469
+ expects_array ? [ record ] : record
470
+ else
471
+ load_target.select { |r| ids.include?(r.id.to_s) }
472
+ end
473
+ end
474
+ end
475
+ end
476
+ end