ocean-dynamo 0.5.0 → 0.5.1
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.
- checksums.yaml +4 -4
- data/README.rdoc +2 -2
- data/lib/ocean-dynamo.rb +3 -0
- data/lib/ocean-dynamo/active_record_stuff/association.rb +244 -0
- data/lib/ocean-dynamo/active_record_stuff/collection_association.rb +580 -0
- data/lib/ocean-dynamo/active_record_stuff/collection_proxy.rb +2 -8
- data/lib/ocean-dynamo/active_record_stuff/has_and_belongs_to_many_association.rb +57 -0
- data/lib/ocean-dynamo/active_record_stuff/has_many_association.rb +132 -0
- data/lib/ocean-dynamo/active_record_stuff/reflection.rb +2 -2
- data/lib/ocean-dynamo/active_record_stuff/relation.rb +2 -2
- data/lib/ocean-dynamo/associations/association.rb +158 -0
- data/lib/ocean-dynamo/associations/associations.rb +4 -0
- data/lib/ocean-dynamo/associations/belongs_to.rb +32 -4
- data/lib/ocean-dynamo/associations/collection_association.rb +39 -0
- data/lib/ocean-dynamo/associations/relation.rb +29 -0
- data/lib/ocean-dynamo/attributes.rb +1 -0
- data/lib/ocean-dynamo/version.rb +1 -1
- metadata +9 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 050bb409c80700917429d0826f6e7f2f57665ff8
|
4
|
+
data.tar.gz: 04dfbcbcf7a927b5616ae070f56ec7d536550689
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 0158fc7b07d5f594a88efd0499fe0466233a6de48fbc120e760968003c26b4a8c337e81110defb98cf66dd8d9093510056e2b7313696d7ec9035d19c598a50e5
|
7
|
+
data.tar.gz: 1705bacb9d7df7102790ce612b5fc6753b30965094e5ede5300b241251e577e11c01a09f1d509bfd570463feaafb311c50d82b015a6db981be80fe10075b7408
|
data/README.rdoc
CHANGED
@@ -170,8 +170,8 @@ controllers. OceanDynamo implements much of the infrastructure of ActiveRecord;
|
|
170
170
|
for instance, +read_attribute+, +write_attribute+, and much of the control logic and
|
171
171
|
internal organisation.
|
172
172
|
|
173
|
-
*
|
174
|
-
|
173
|
+
* <tt>belongs_to :thingy now defines <tt>.build_thingy</tt> and <tt>.create_thingy</tt>.
|
174
|
+
* Work begun on collection proxies, etc.
|
175
175
|
|
176
176
|
=== Future milestones
|
177
177
|
|
data/lib/ocean-dynamo.rb
CHANGED
@@ -14,6 +14,9 @@ require "ocean-dynamo/attributes"
|
|
14
14
|
require "ocean-dynamo/persistence"
|
15
15
|
require "ocean-dynamo/queries"
|
16
16
|
require "ocean-dynamo/associations/associations"
|
17
|
+
require "ocean-dynamo/associations/association"
|
18
|
+
require "ocean-dynamo/associations/collection_association"
|
19
|
+
require "ocean-dynamo/associations/relation"
|
17
20
|
require "ocean-dynamo/associations/belongs_to"
|
18
21
|
require "ocean-dynamo/associations/has_many"
|
19
22
|
|
@@ -0,0 +1,244 @@
|
|
1
|
+
require 'active_support/core_ext/array/wrap'
|
2
|
+
|
3
|
+
module ActiveRecord #:nodoc:
|
4
|
+
module Associations #:nodoc:
|
5
|
+
# = Active Record Associations
|
6
|
+
#
|
7
|
+
# This is the root class of all associations ('+ Foo' signifies an included module Foo):
|
8
|
+
#
|
9
|
+
# Association
|
10
|
+
# SingularAssociation
|
11
|
+
# HasOneAssociation
|
12
|
+
# HasOneThroughAssociation + ThroughAssociation
|
13
|
+
# BelongsToAssociation
|
14
|
+
# BelongsToPolymorphicAssociation
|
15
|
+
# CollectionAssociation
|
16
|
+
# HasAndBelongsToManyAssociation
|
17
|
+
# HasManyAssociation
|
18
|
+
# HasManyThroughAssociation + ThroughAssociation
|
19
|
+
class Association #:nodoc:
|
20
|
+
attr_reader :owner, :target, :reflection
|
21
|
+
|
22
|
+
delegate :options, :to => :reflection
|
23
|
+
|
24
|
+
def initialize(owner, reflection)
|
25
|
+
reflection.check_validity!
|
26
|
+
|
27
|
+
@owner, @reflection = owner, reflection
|
28
|
+
|
29
|
+
reset
|
30
|
+
reset_scope
|
31
|
+
end
|
32
|
+
|
33
|
+
# Returns the name of the table of the associated class:
|
34
|
+
#
|
35
|
+
# post.comments.aliased_table_name # => "comments"
|
36
|
+
#
|
37
|
+
def aliased_table_name
|
38
|
+
klass.table_name
|
39
|
+
end
|
40
|
+
|
41
|
+
# Resets the \loaded flag to +false+ and sets the \target to +nil+.
|
42
|
+
def reset
|
43
|
+
@loaded = false
|
44
|
+
@target = nil
|
45
|
+
@stale_state = nil
|
46
|
+
end
|
47
|
+
|
48
|
+
# Reloads the \target and returns +self+ on success.
|
49
|
+
def reload
|
50
|
+
reset
|
51
|
+
reset_scope
|
52
|
+
load_target
|
53
|
+
self unless target.nil?
|
54
|
+
end
|
55
|
+
|
56
|
+
# Has the \target been already \loaded?
|
57
|
+
def loaded?
|
58
|
+
@loaded
|
59
|
+
end
|
60
|
+
|
61
|
+
# Asserts the \target has been loaded setting the \loaded flag to +true+.
|
62
|
+
def loaded!
|
63
|
+
@loaded = true
|
64
|
+
@stale_state = stale_state
|
65
|
+
end
|
66
|
+
|
67
|
+
# The target is stale if the target no longer points to the record(s) that the
|
68
|
+
# relevant foreign_key(s) refers to. If stale, the association accessor method
|
69
|
+
# on the owner will reload the target. It's up to subclasses to implement the
|
70
|
+
# stale_state method if relevant.
|
71
|
+
#
|
72
|
+
# Note that if the target has not been loaded, it is not considered stale.
|
73
|
+
def stale_target?
|
74
|
+
loaded? && @stale_state != stale_state
|
75
|
+
end
|
76
|
+
|
77
|
+
# Sets the target of this association to <tt>\target</tt>, and the \loaded flag to +true+.
|
78
|
+
def target=(target)
|
79
|
+
@target = target
|
80
|
+
loaded!
|
81
|
+
end
|
82
|
+
|
83
|
+
def scope
|
84
|
+
target_scope.merge(association_scope)
|
85
|
+
end
|
86
|
+
|
87
|
+
def scoped
|
88
|
+
ActiveSupport::Deprecation.warn "#scoped is deprecated. use #scope instead."
|
89
|
+
scope
|
90
|
+
end
|
91
|
+
|
92
|
+
# The scope for this association.
|
93
|
+
#
|
94
|
+
# Note that the association_scope is merged into the target_scope only when the
|
95
|
+
# scope method is called. This is because at that point the call may be surrounded
|
96
|
+
# by scope.scoping { ... } or with_scope { ... } etc, which affects the scope which
|
97
|
+
# actually gets built.
|
98
|
+
def association_scope
|
99
|
+
if klass
|
100
|
+
@association_scope ||= AssociationScope.new(self).scope
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def reset_scope
|
105
|
+
@association_scope = nil
|
106
|
+
end
|
107
|
+
|
108
|
+
# Set the inverse association, if possible
|
109
|
+
def set_inverse_instance(record)
|
110
|
+
if record && invertible_for?(record)
|
111
|
+
inverse = record.association(inverse_reflection_for(record).name)
|
112
|
+
inverse.target = owner
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
# Returns the class of the target. belongs_to polymorphic overrides this to look at the
|
117
|
+
# polymorphic_type field on the owner.
|
118
|
+
def klass
|
119
|
+
reflection.klass
|
120
|
+
end
|
121
|
+
|
122
|
+
# Can be overridden (i.e. in ThroughAssociation) to merge in other scopes (i.e. the
|
123
|
+
# through association's scope)
|
124
|
+
def target_scope
|
125
|
+
klass.all
|
126
|
+
end
|
127
|
+
|
128
|
+
# Loads the \target if needed and returns it.
|
129
|
+
#
|
130
|
+
# This method is abstract in the sense that it relies on +find_target+,
|
131
|
+
# which is expected to be provided by descendants.
|
132
|
+
#
|
133
|
+
# If the \target is already \loaded it is just returned. Thus, you can call
|
134
|
+
# +load_target+ unconditionally to get the \target.
|
135
|
+
#
|
136
|
+
# ActiveRecord::RecordNotFound is rescued within the method, and it is
|
137
|
+
# not reraised. The proxy is \reset and +nil+ is the return value.
|
138
|
+
def load_target
|
139
|
+
@target = find_target if (@stale_state && stale_target?) || find_target?
|
140
|
+
|
141
|
+
loaded! unless loaded?
|
142
|
+
target
|
143
|
+
rescue ActiveRecord::RecordNotFound
|
144
|
+
reset
|
145
|
+
end
|
146
|
+
|
147
|
+
def interpolate(sql, record = nil)
|
148
|
+
if sql.respond_to?(:to_proc)
|
149
|
+
owner.instance_exec(record, &sql)
|
150
|
+
else
|
151
|
+
sql
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
# We can't dump @reflection since it contains the scope proc
|
156
|
+
def marshal_dump
|
157
|
+
ivars = (instance_variables - [:@reflection]).map { |name| [name, instance_variable_get(name)] }
|
158
|
+
[@reflection.name, ivars]
|
159
|
+
end
|
160
|
+
|
161
|
+
def marshal_load(data)
|
162
|
+
reflection_name, ivars = data
|
163
|
+
ivars.each { |name, val| instance_variable_set(name, val) }
|
164
|
+
@reflection = @owner.class.reflect_on_association(reflection_name)
|
165
|
+
end
|
166
|
+
|
167
|
+
private
|
168
|
+
|
169
|
+
def find_target?
|
170
|
+
!loaded? && (!owner.new_record? || foreign_key_present?) && klass
|
171
|
+
end
|
172
|
+
|
173
|
+
def creation_attributes
|
174
|
+
attributes = {}
|
175
|
+
|
176
|
+
if (reflection.macro == :has_one || reflection.macro == :has_many) && !options[:through]
|
177
|
+
attributes[reflection.foreign_key] = owner[reflection.active_record_primary_key]
|
178
|
+
|
179
|
+
if reflection.options[:as]
|
180
|
+
attributes[reflection.type] = owner.class.base_class.name
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
attributes
|
185
|
+
end
|
186
|
+
|
187
|
+
# Sets the owner attributes on the given record
|
188
|
+
def set_owner_attributes(record)
|
189
|
+
creation_attributes.each { |key, value| record[key] = value }
|
190
|
+
end
|
191
|
+
|
192
|
+
# Should be true if there is a foreign key present on the owner which
|
193
|
+
# references the target. This is used to determine whether we can load
|
194
|
+
# the target if the owner is currently a new record (and therefore
|
195
|
+
# without a key).
|
196
|
+
#
|
197
|
+
# Currently implemented by belongs_to (vanilla and polymorphic) and
|
198
|
+
# has_one/has_many :through associations which go through a belongs_to
|
199
|
+
def foreign_key_present?
|
200
|
+
false
|
201
|
+
end
|
202
|
+
|
203
|
+
# Raises ActiveRecord::AssociationTypeMismatch unless +record+ is of
|
204
|
+
# the kind of the class of the associated objects. Meant to be used as
|
205
|
+
# a sanity check when you are about to assign an associated record.
|
206
|
+
def raise_on_type_mismatch!(record)
|
207
|
+
unless record.is_a?(reflection.klass) || record.is_a?(reflection.class_name.constantize)
|
208
|
+
message = "#{reflection.class_name}(##{reflection.klass.object_id}) expected, got #{record.class}(##{record.class.object_id})"
|
209
|
+
raise ActiveRecord::AssociationTypeMismatch, message
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
213
|
+
# Can be redefined by subclasses, notably polymorphic belongs_to
|
214
|
+
# The record parameter is necessary to support polymorphic inverses as we must check for
|
215
|
+
# the association in the specific class of the record.
|
216
|
+
def inverse_reflection_for(record)
|
217
|
+
reflection.inverse_of
|
218
|
+
end
|
219
|
+
|
220
|
+
# Returns true if inverse association on the given record needs to be set.
|
221
|
+
# This method is redefined by subclasses.
|
222
|
+
def invertible_for?(record)
|
223
|
+
inverse_reflection_for(record)
|
224
|
+
end
|
225
|
+
|
226
|
+
# This should be implemented to return the values of the relevant key(s) on the owner,
|
227
|
+
# so that when stale_state is different from the value stored on the last find_target,
|
228
|
+
# the target is stale.
|
229
|
+
#
|
230
|
+
# This is only relevant to certain associations, which is why it returns nil by default.
|
231
|
+
def stale_state
|
232
|
+
end
|
233
|
+
|
234
|
+
def build_record(attributes)
|
235
|
+
reflection.build_association(attributes) do |record|
|
236
|
+
skip_assign = [reflection.foreign_key, reflection.type].compact
|
237
|
+
attributes = create_scope.except(*(record.changed - skip_assign))
|
238
|
+
record.assign_attributes(attributes)
|
239
|
+
set_inverse_instance(record)
|
240
|
+
end
|
241
|
+
end
|
242
|
+
end
|
243
|
+
end
|
244
|
+
end
|
@@ -0,0 +1,580 @@
|
|
1
|
+
module ActiveRecord #:nodoc:
|
2
|
+
module Associations #:nodoc:
|
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 AssociationProxy.
|
8
|
+
#
|
9
|
+
# CollectionAssociation:
|
10
|
+
# HasAndBelongsToManyAssociation => has_and_belongs_to_many
|
11
|
+
# HasManyAssociation => has_many
|
12
|
+
# HasManyThroughAssociation + ThroughAssociation => has_many :through
|
13
|
+
#
|
14
|
+
# CollectionAssociation class provides common methods to the collections
|
15
|
+
# defined by +has_and_belongs_to_many+, +has_many+ or +has_many+ with
|
16
|
+
# +:through association+ option.
|
17
|
+
#
|
18
|
+
# You need to be careful with assumptions regarding the target: The proxy
|
19
|
+
# does not fetch records from the database until it needs them, but new
|
20
|
+
# ones created with +build+ are added to the target. So, the target may be
|
21
|
+
# non-empty and still lack children waiting to be read from the database.
|
22
|
+
# If you look directly to the database you cannot assume that's the entire
|
23
|
+
# collection because new records may have been added to the target, etc.
|
24
|
+
#
|
25
|
+
# If you need to work on all current children, new and existing records,
|
26
|
+
# +load_target+ and the +loaded+ flag are your friends.
|
27
|
+
class CollectionAssociation < Association #:nodoc:
|
28
|
+
|
29
|
+
# Implements the reader method, e.g. foo.items for Foo.has_many :items
|
30
|
+
def reader(force_reload = false)
|
31
|
+
if force_reload
|
32
|
+
klass.uncached { reload }
|
33
|
+
elsif stale_target?
|
34
|
+
reload
|
35
|
+
end
|
36
|
+
|
37
|
+
@proxy ||= CollectionProxy.create(klass, self)
|
38
|
+
end
|
39
|
+
|
40
|
+
# Implements the writer method, e.g. foo.items= for Foo.has_many :items
|
41
|
+
def writer(records)
|
42
|
+
replace(records)
|
43
|
+
end
|
44
|
+
|
45
|
+
# Implements the ids reader method, e.g. foo.item_ids for Foo.has_many :items
|
46
|
+
def ids_reader
|
47
|
+
if loaded?
|
48
|
+
load_target.map do |record|
|
49
|
+
record.send(reflection.association_primary_key)
|
50
|
+
end
|
51
|
+
else
|
52
|
+
column = "#{reflection.quoted_table_name}.#{reflection.association_primary_key}"
|
53
|
+
scope.pluck(column)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
# Implements the ids writer method, e.g. foo.item_ids= for Foo.has_many :items
|
58
|
+
def ids_writer(ids)
|
59
|
+
pk_column = reflection.primary_key_column
|
60
|
+
ids = Array(ids).reject { |id| id.blank? }
|
61
|
+
ids.map! { |i| pk_column.type_cast(i) }
|
62
|
+
replace(klass.find(ids).index_by { |r| r.id }.values_at(*ids))
|
63
|
+
end
|
64
|
+
|
65
|
+
def reset
|
66
|
+
super
|
67
|
+
@target = []
|
68
|
+
end
|
69
|
+
|
70
|
+
def select(select = nil)
|
71
|
+
if block_given?
|
72
|
+
load_target.select.each { |e| yield e }
|
73
|
+
else
|
74
|
+
scope.select(select)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def find(*args)
|
79
|
+
if block_given?
|
80
|
+
load_target.find(*args) { |*block_args| yield(*block_args) }
|
81
|
+
else
|
82
|
+
if options[:inverse_of] && loaded?
|
83
|
+
args = args.flatten
|
84
|
+
raise RecordNotFound, "Couldn't find #{scope.klass.name} without an ID" if args.blank?
|
85
|
+
|
86
|
+
result = find_by_scan(*args)
|
87
|
+
|
88
|
+
result_size = Array(result).size
|
89
|
+
if !result || result_size != args.size
|
90
|
+
scope.raise_record_not_found_exception!(args, result_size, args.size)
|
91
|
+
else
|
92
|
+
result
|
93
|
+
end
|
94
|
+
else
|
95
|
+
scope.find(*args)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
def first(*args)
|
101
|
+
first_or_last(:first, *args)
|
102
|
+
end
|
103
|
+
|
104
|
+
def last(*args)
|
105
|
+
first_or_last(:last, *args)
|
106
|
+
end
|
107
|
+
|
108
|
+
def build(attributes = {}, &block)
|
109
|
+
if attributes.is_a?(Array)
|
110
|
+
attributes.collect { |attr| build(attr, &block) }
|
111
|
+
else
|
112
|
+
add_to_target(build_record(attributes)) do |record|
|
113
|
+
yield(record) if block_given?
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def create(attributes = {}, &block)
|
119
|
+
create_record(attributes, &block)
|
120
|
+
end
|
121
|
+
|
122
|
+
def create!(attributes = {}, &block)
|
123
|
+
create_record(attributes, true, &block)
|
124
|
+
end
|
125
|
+
|
126
|
+
# Add +records+ to this association. Returns +self+ so method calls may
|
127
|
+
# be chained. Since << flattens its argument list and inserts each record,
|
128
|
+
# +push+ and +concat+ behave identically.
|
129
|
+
def concat(*records)
|
130
|
+
load_target if owner.new_record?
|
131
|
+
|
132
|
+
if owner.new_record?
|
133
|
+
concat_records(records)
|
134
|
+
else
|
135
|
+
transaction { concat_records(records) }
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
# Starts a transaction in the association class's database connection.
|
140
|
+
#
|
141
|
+
# class Author < ActiveRecord::Base
|
142
|
+
# has_many :books
|
143
|
+
# end
|
144
|
+
#
|
145
|
+
# Author.first.books.transaction do
|
146
|
+
# # same effect as calling Book.transaction
|
147
|
+
# end
|
148
|
+
def transaction(*args)
|
149
|
+
reflection.klass.transaction(*args) do
|
150
|
+
yield
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
# Removes all records from the association without calling callbacks
|
155
|
+
# on the associated records. It honors the `:dependent` option. However
|
156
|
+
# if the `:dependent` value is `:destroy` then in that case the default
|
157
|
+
# deletion strategy for the association is applied.
|
158
|
+
#
|
159
|
+
# You can force a particular deletion strategy by passing a parameter.
|
160
|
+
#
|
161
|
+
# Example:
|
162
|
+
#
|
163
|
+
# @author.books.delete_all(:nullify)
|
164
|
+
# @author.books.delete_all(:delete_all)
|
165
|
+
#
|
166
|
+
# See delete for more info.
|
167
|
+
def delete_all(dependent = nil)
|
168
|
+
if dependent.present? && ![:nullify, :delete_all].include?(dependent)
|
169
|
+
raise ArgumentError, "Valid values are :nullify or :delete_all"
|
170
|
+
end
|
171
|
+
|
172
|
+
dependent = if dependent.present?
|
173
|
+
dependent
|
174
|
+
elsif options[:dependent] == :destroy
|
175
|
+
# since delete_all should not invoke callbacks so use the default deletion strategy
|
176
|
+
# for :destroy
|
177
|
+
reflection.is_a?(ActiveRecord::Reflection::ThroughReflection) ? :delete_all : :nullify
|
178
|
+
else
|
179
|
+
options[:dependent]
|
180
|
+
end
|
181
|
+
|
182
|
+
delete(:all, dependent: dependent).tap do
|
183
|
+
reset
|
184
|
+
loaded!
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
# Destroy all the records from this association.
|
189
|
+
#
|
190
|
+
# See destroy for more info.
|
191
|
+
def destroy_all
|
192
|
+
destroy(load_target).tap do
|
193
|
+
reset
|
194
|
+
loaded!
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
# Count all records using SQL. Construct options and pass them with
|
199
|
+
# scope to the target class's +count+.
|
200
|
+
def count(column_name = nil, count_options = {})
|
201
|
+
column_name, count_options = nil, column_name if column_name.is_a?(Hash)
|
202
|
+
|
203
|
+
relation = scope
|
204
|
+
if association_scope.distinct_value
|
205
|
+
# This is needed because 'SELECT count(DISTINCT *)..' is not valid SQL.
|
206
|
+
column_name ||= reflection.klass.primary_key
|
207
|
+
relation = relation.distinct
|
208
|
+
end
|
209
|
+
|
210
|
+
value = relation.count(column_name)
|
211
|
+
|
212
|
+
limit = options[:limit]
|
213
|
+
offset = options[:offset]
|
214
|
+
|
215
|
+
if limit || offset
|
216
|
+
[ [value - offset.to_i, 0].max, limit.to_i ].min
|
217
|
+
else
|
218
|
+
value
|
219
|
+
end
|
220
|
+
end
|
221
|
+
|
222
|
+
# Removes +records+ from this association calling +before_remove+ and
|
223
|
+
# +after_remove+ callbacks.
|
224
|
+
#
|
225
|
+
# This method is abstract in the sense that +delete_records+ has to be
|
226
|
+
# provided by descendants. Note this method does not imply the records
|
227
|
+
# are actually removed from the database, that depends precisely on
|
228
|
+
# +delete_records+. They are in any case removed from the collection.
|
229
|
+
def delete(*records)
|
230
|
+
_options = records.extract_options!
|
231
|
+
dependent = _options[:dependent] || options[:dependent]
|
232
|
+
|
233
|
+
if records.first == :all
|
234
|
+
if loaded? || dependent == :destroy
|
235
|
+
delete_or_destroy(load_target, dependent)
|
236
|
+
else
|
237
|
+
delete_records(:all, dependent)
|
238
|
+
end
|
239
|
+
else
|
240
|
+
records = find(records) if records.any? { |record| record.kind_of?(Fixnum) || record.kind_of?(String) }
|
241
|
+
delete_or_destroy(records, dependent)
|
242
|
+
end
|
243
|
+
end
|
244
|
+
|
245
|
+
# Deletes the +records+ and removes them from this association calling
|
246
|
+
# +before_remove+ , +after_remove+ , +before_destroy+ and +after_destroy+ callbacks.
|
247
|
+
#
|
248
|
+
# Note that this method removes records from the database ignoring the
|
249
|
+
# +:dependent+ option.
|
250
|
+
def destroy(*records)
|
251
|
+
records = find(records) if records.any? { |record| record.kind_of?(Fixnum) || record.kind_of?(String) }
|
252
|
+
delete_or_destroy(records, :destroy)
|
253
|
+
end
|
254
|
+
|
255
|
+
# Returns the size of the collection by executing a SELECT COUNT(*)
|
256
|
+
# query if the collection hasn't been loaded, and calling
|
257
|
+
# <tt>collection.size</tt> if it has.
|
258
|
+
#
|
259
|
+
# If the collection has been already loaded +size+ and +length+ are
|
260
|
+
# equivalent. If not and you are going to need the records anyway
|
261
|
+
# +length+ will take one less query. Otherwise +size+ is more efficient.
|
262
|
+
#
|
263
|
+
# This method is abstract in the sense that it relies on
|
264
|
+
# +count_records+, which is a method descendants have to provide.
|
265
|
+
def size
|
266
|
+
if !find_target? || loaded?
|
267
|
+
if association_scope.distinct_value
|
268
|
+
target.uniq.size
|
269
|
+
else
|
270
|
+
target.size
|
271
|
+
end
|
272
|
+
elsif !loaded? && !association_scope.group_values.empty?
|
273
|
+
load_target.size
|
274
|
+
elsif !loaded? && !association_scope.distinct_value && target.is_a?(Array)
|
275
|
+
unsaved_records = target.select { |r| r.new_record? }
|
276
|
+
unsaved_records.size + count_records
|
277
|
+
else
|
278
|
+
count_records
|
279
|
+
end
|
280
|
+
end
|
281
|
+
|
282
|
+
# Returns the size of the collection calling +size+ on the target.
|
283
|
+
#
|
284
|
+
# If the collection has been already loaded +length+ and +size+ are
|
285
|
+
# equivalent. If not and you are going to need the records anyway this
|
286
|
+
# method will take one less query. Otherwise +size+ is more efficient.
|
287
|
+
def length
|
288
|
+
load_target.size
|
289
|
+
end
|
290
|
+
|
291
|
+
# Returns true if the collection is empty.
|
292
|
+
#
|
293
|
+
# If the collection has been loaded
|
294
|
+
# it is equivalent to <tt>collection.size.zero?</tt>. If the
|
295
|
+
# collection has not been loaded, it is equivalent to
|
296
|
+
# <tt>collection.exists?</tt>. If the collection has not already been
|
297
|
+
# loaded and you are going to fetch the records anyway it is better to
|
298
|
+
# check <tt>collection.length.zero?</tt>.
|
299
|
+
def empty?
|
300
|
+
if loaded?
|
301
|
+
size.zero?
|
302
|
+
else
|
303
|
+
@target.blank? && !scope.exists?
|
304
|
+
end
|
305
|
+
end
|
306
|
+
|
307
|
+
# Returns true if the collections is not empty.
|
308
|
+
# Equivalent to +!collection.empty?+.
|
309
|
+
def any?
|
310
|
+
if block_given?
|
311
|
+
load_target.any? { |*block_args| yield(*block_args) }
|
312
|
+
else
|
313
|
+
!empty?
|
314
|
+
end
|
315
|
+
end
|
316
|
+
|
317
|
+
# Returns true if the collection has more than 1 record.
|
318
|
+
# Equivalent to +collection.size > 1+.
|
319
|
+
def many?
|
320
|
+
if block_given?
|
321
|
+
load_target.many? { |*block_args| yield(*block_args) }
|
322
|
+
else
|
323
|
+
size > 1
|
324
|
+
end
|
325
|
+
end
|
326
|
+
|
327
|
+
def distinct
|
328
|
+
seen = {}
|
329
|
+
load_target.find_all do |record|
|
330
|
+
seen[record.id] = true unless seen.key?(record.id)
|
331
|
+
end
|
332
|
+
end
|
333
|
+
alias uniq distinct
|
334
|
+
|
335
|
+
# Replace this collection with +other_array+. This will perform a diff
|
336
|
+
# and delete/add only records that have changed.
|
337
|
+
def replace(other_array)
|
338
|
+
other_array.each { |val| raise_on_type_mismatch!(val) }
|
339
|
+
original_target = load_target.dup
|
340
|
+
|
341
|
+
if owner.new_record?
|
342
|
+
replace_records(other_array, original_target)
|
343
|
+
else
|
344
|
+
transaction { replace_records(other_array, original_target) }
|
345
|
+
end
|
346
|
+
end
|
347
|
+
|
348
|
+
def include?(record)
|
349
|
+
if record.is_a?(reflection.klass)
|
350
|
+
if record.new_record?
|
351
|
+
include_in_memory?(record)
|
352
|
+
else
|
353
|
+
loaded? ? target.include?(record) : scope.exists?(record)
|
354
|
+
end
|
355
|
+
else
|
356
|
+
false
|
357
|
+
end
|
358
|
+
end
|
359
|
+
|
360
|
+
def load_target
|
361
|
+
if find_target?
|
362
|
+
@target = merge_target_lists(find_target, target)
|
363
|
+
end
|
364
|
+
|
365
|
+
loaded!
|
366
|
+
target
|
367
|
+
end
|
368
|
+
|
369
|
+
def add_to_target(record, skip_callbacks = false)
|
370
|
+
callback(:before_add, record) unless skip_callbacks
|
371
|
+
yield(record) if block_given?
|
372
|
+
|
373
|
+
if association_scope.distinct_value && index = @target.index(record)
|
374
|
+
@target[index] = record
|
375
|
+
else
|
376
|
+
@target << record
|
377
|
+
end
|
378
|
+
|
379
|
+
callback(:after_add, record) unless skip_callbacks
|
380
|
+
set_inverse_instance(record)
|
381
|
+
|
382
|
+
record
|
383
|
+
end
|
384
|
+
|
385
|
+
def scope(opts = {})
|
386
|
+
scope = super()
|
387
|
+
scope.none! if opts.fetch(:nullify, true) && null_scope?
|
388
|
+
scope
|
389
|
+
end
|
390
|
+
|
391
|
+
def null_scope?
|
392
|
+
owner.new_record? && !foreign_key_present?
|
393
|
+
end
|
394
|
+
|
395
|
+
private
|
396
|
+
|
397
|
+
def find_target
|
398
|
+
records = scope.to_a
|
399
|
+
records.each { |record| set_inverse_instance(record) }
|
400
|
+
records
|
401
|
+
end
|
402
|
+
|
403
|
+
# We have some records loaded from the database (persisted) and some that are
|
404
|
+
# in-memory (memory). The same record may be represented in the persisted array
|
405
|
+
# and in the memory array.
|
406
|
+
#
|
407
|
+
# So the task of this method is to merge them according to the following rules:
|
408
|
+
#
|
409
|
+
# * The final array must not have duplicates
|
410
|
+
# * The order of the persisted array is to be preserved
|
411
|
+
# * Any changes made to attributes on objects in the memory array are to be preserved
|
412
|
+
# * Otherwise, attributes should have the value found in the database
|
413
|
+
def merge_target_lists(persisted, memory)
|
414
|
+
return persisted if memory.empty?
|
415
|
+
return memory if persisted.empty?
|
416
|
+
|
417
|
+
persisted.map! do |record|
|
418
|
+
if mem_record = memory.delete(record)
|
419
|
+
|
420
|
+
((record.attribute_names & mem_record.attribute_names) - mem_record.changes.keys).each do |name|
|
421
|
+
mem_record[name] = record[name]
|
422
|
+
end
|
423
|
+
|
424
|
+
mem_record
|
425
|
+
else
|
426
|
+
record
|
427
|
+
end
|
428
|
+
end
|
429
|
+
|
430
|
+
persisted + memory
|
431
|
+
end
|
432
|
+
|
433
|
+
def create_record(attributes, raise = false, &block)
|
434
|
+
unless owner.persisted?
|
435
|
+
raise ActiveRecord::RecordNotSaved, "You cannot call create unless the parent is saved"
|
436
|
+
end
|
437
|
+
|
438
|
+
if attributes.is_a?(Array)
|
439
|
+
attributes.collect { |attr| create_record(attr, raise, &block) }
|
440
|
+
else
|
441
|
+
transaction do
|
442
|
+
add_to_target(build_record(attributes)) do |record|
|
443
|
+
yield(record) if block_given?
|
444
|
+
insert_record(record, true, raise)
|
445
|
+
end
|
446
|
+
end
|
447
|
+
end
|
448
|
+
end
|
449
|
+
|
450
|
+
# Do the relevant stuff to insert the given record into the association collection.
|
451
|
+
def insert_record(record, validate = true, raise = false)
|
452
|
+
raise NotImplementedError
|
453
|
+
end
|
454
|
+
|
455
|
+
def create_scope
|
456
|
+
scope.scope_for_create.stringify_keys
|
457
|
+
end
|
458
|
+
|
459
|
+
def delete_or_destroy(records, method)
|
460
|
+
records = records.flatten
|
461
|
+
records.each { |record| raise_on_type_mismatch!(record) }
|
462
|
+
existing_records = records.reject { |r| r.new_record? }
|
463
|
+
|
464
|
+
if existing_records.empty?
|
465
|
+
remove_records(existing_records, records, method)
|
466
|
+
else
|
467
|
+
transaction { remove_records(existing_records, records, method) }
|
468
|
+
end
|
469
|
+
end
|
470
|
+
|
471
|
+
def remove_records(existing_records, records, method)
|
472
|
+
records.each { |record| callback(:before_remove, record) }
|
473
|
+
|
474
|
+
delete_records(existing_records, method) if existing_records.any?
|
475
|
+
records.each { |record| target.delete(record) }
|
476
|
+
|
477
|
+
records.each { |record| callback(:after_remove, record) }
|
478
|
+
end
|
479
|
+
|
480
|
+
# Delete the given records from the association, using one of the methods :destroy,
|
481
|
+
# :delete_all or :nullify (or nil, in which case a default is used).
|
482
|
+
def delete_records(records, method)
|
483
|
+
raise NotImplementedError
|
484
|
+
end
|
485
|
+
|
486
|
+
def replace_records(new_target, original_target)
|
487
|
+
delete(target - new_target)
|
488
|
+
|
489
|
+
unless concat(new_target - target)
|
490
|
+
@target = original_target
|
491
|
+
raise RecordNotSaved, "Failed to replace #{reflection.name} because one or more of the " \
|
492
|
+
"new records could not be saved."
|
493
|
+
end
|
494
|
+
|
495
|
+
target
|
496
|
+
end
|
497
|
+
|
498
|
+
def concat_records(records)
|
499
|
+
result = true
|
500
|
+
|
501
|
+
records.flatten.each do |record|
|
502
|
+
raise_on_type_mismatch!(record)
|
503
|
+
add_to_target(record) do |rec|
|
504
|
+
result &&= insert_record(rec) unless owner.new_record?
|
505
|
+
end
|
506
|
+
end
|
507
|
+
|
508
|
+
result && records
|
509
|
+
end
|
510
|
+
|
511
|
+
def callback(method, record)
|
512
|
+
callbacks_for(method).each do |callback|
|
513
|
+
callback.call(method, owner, record)
|
514
|
+
end
|
515
|
+
end
|
516
|
+
|
517
|
+
def callbacks_for(callback_name)
|
518
|
+
full_callback_name = "#{callback_name}_for_#{reflection.name}"
|
519
|
+
owner.class.send(full_callback_name)
|
520
|
+
end
|
521
|
+
|
522
|
+
# Should we deal with assoc.first or assoc.last by issuing an independent query to
|
523
|
+
# the database, or by getting the target, and then taking the first/last item from that?
|
524
|
+
#
|
525
|
+
# If the args is just a non-empty options hash, go to the database.
|
526
|
+
#
|
527
|
+
# Otherwise, go to the database only if none of the following are true:
|
528
|
+
# * target already loaded
|
529
|
+
# * owner is new record
|
530
|
+
# * target contains new or changed record(s)
|
531
|
+
# * the first arg is an integer (which indicates the number of records to be returned)
|
532
|
+
def fetch_first_or_last_using_find?(args)
|
533
|
+
if args.first.is_a?(Hash)
|
534
|
+
true
|
535
|
+
else
|
536
|
+
!(loaded? ||
|
537
|
+
owner.new_record? ||
|
538
|
+
target.any? { |record| record.new_record? || record.changed? } ||
|
539
|
+
args.first.kind_of?(Integer))
|
540
|
+
end
|
541
|
+
end
|
542
|
+
|
543
|
+
def include_in_memory?(record)
|
544
|
+
if reflection.is_a?(ActiveRecord::Reflection::ThroughReflection)
|
545
|
+
owner.send(reflection.through_reflection.name).any? { |source|
|
546
|
+
target = source.send(reflection.source_reflection.name)
|
547
|
+
target.respond_to?(:include?) ? target.include?(record) : target == record
|
548
|
+
} || target.include?(record)
|
549
|
+
else
|
550
|
+
target.include?(record)
|
551
|
+
end
|
552
|
+
end
|
553
|
+
|
554
|
+
# If the :inverse_of option has been
|
555
|
+
# specified, then #find scans the entire collection.
|
556
|
+
def find_by_scan(*args)
|
557
|
+
expects_array = args.first.kind_of?(Array)
|
558
|
+
ids = args.flatten.compact.map{ |arg| arg.to_i }.uniq
|
559
|
+
|
560
|
+
if ids.size == 1
|
561
|
+
id = ids.first
|
562
|
+
record = load_target.detect { |r| id == r.id }
|
563
|
+
expects_array ? [ record ] : record
|
564
|
+
else
|
565
|
+
load_target.select { |r| ids.include?(r.id) }
|
566
|
+
end
|
567
|
+
end
|
568
|
+
|
569
|
+
# Fetches the first/last using SQL if possible, otherwise from the target array.
|
570
|
+
def first_or_last(type, *args)
|
571
|
+
args.shift if args.first.is_a?(Hash) && args.first.empty?
|
572
|
+
|
573
|
+
collection = fetch_first_or_last_using_find?(args) ? scope : load_target
|
574
|
+
collection.send(type, *args).tap do |record|
|
575
|
+
set_inverse_instance record if record.is_a? ActiveRecord::Base
|
576
|
+
end
|
577
|
+
end
|
578
|
+
end
|
579
|
+
end
|
580
|
+
end
|