tallty_duck_record 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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