zermelo 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.
- checksums.yaml +7 -0
- data/.gitignore +16 -0
- data/.rspec +10 -0
- data/.travis.yml +27 -0
- data/Gemfile +20 -0
- data/LICENSE.txt +22 -0
- data/README.md +512 -0
- data/Rakefile +1 -0
- data/lib/zermelo/associations/association_data.rb +24 -0
- data/lib/zermelo/associations/belongs_to.rb +115 -0
- data/lib/zermelo/associations/class_methods.rb +244 -0
- data/lib/zermelo/associations/has_and_belongs_to_many.rb +128 -0
- data/lib/zermelo/associations/has_many.rb +120 -0
- data/lib/zermelo/associations/has_one.rb +109 -0
- data/lib/zermelo/associations/has_sorted_set.rb +124 -0
- data/lib/zermelo/associations/index.rb +50 -0
- data/lib/zermelo/associations/index_data.rb +18 -0
- data/lib/zermelo/associations/unique_index.rb +44 -0
- data/lib/zermelo/backends/base.rb +115 -0
- data/lib/zermelo/backends/influxdb_backend.rb +178 -0
- data/lib/zermelo/backends/redis_backend.rb +281 -0
- data/lib/zermelo/filters/base.rb +235 -0
- data/lib/zermelo/filters/influxdb_filter.rb +162 -0
- data/lib/zermelo/filters/redis_filter.rb +558 -0
- data/lib/zermelo/filters/steps/base_step.rb +22 -0
- data/lib/zermelo/filters/steps/diff_range_step.rb +17 -0
- data/lib/zermelo/filters/steps/diff_step.rb +17 -0
- data/lib/zermelo/filters/steps/intersect_range_step.rb +17 -0
- data/lib/zermelo/filters/steps/intersect_step.rb +17 -0
- data/lib/zermelo/filters/steps/limit_step.rb +17 -0
- data/lib/zermelo/filters/steps/offset_step.rb +17 -0
- data/lib/zermelo/filters/steps/sort_step.rb +17 -0
- data/lib/zermelo/filters/steps/union_range_step.rb +17 -0
- data/lib/zermelo/filters/steps/union_step.rb +17 -0
- data/lib/zermelo/locks/no_lock.rb +16 -0
- data/lib/zermelo/locks/redis_lock.rb +221 -0
- data/lib/zermelo/records/base.rb +62 -0
- data/lib/zermelo/records/class_methods.rb +127 -0
- data/lib/zermelo/records/collection.rb +14 -0
- data/lib/zermelo/records/errors.rb +24 -0
- data/lib/zermelo/records/influxdb_record.rb +35 -0
- data/lib/zermelo/records/instance_methods.rb +224 -0
- data/lib/zermelo/records/key.rb +19 -0
- data/lib/zermelo/records/redis_record.rb +27 -0
- data/lib/zermelo/records/type_validator.rb +20 -0
- data/lib/zermelo/version.rb +3 -0
- data/lib/zermelo.rb +102 -0
- data/spec/lib/zermelo/associations/belongs_to_spec.rb +6 -0
- data/spec/lib/zermelo/associations/has_many_spec.rb +6 -0
- data/spec/lib/zermelo/associations/has_one_spec.rb +6 -0
- data/spec/lib/zermelo/associations/has_sorted_set.spec.rb +6 -0
- data/spec/lib/zermelo/associations/index_spec.rb +6 -0
- data/spec/lib/zermelo/associations/unique_index_spec.rb +6 -0
- data/spec/lib/zermelo/backends/influxdb_backend_spec.rb +0 -0
- data/spec/lib/zermelo/backends/moneta_backend_spec.rb +0 -0
- data/spec/lib/zermelo/filters/influxdb_filter_spec.rb +0 -0
- data/spec/lib/zermelo/filters/redis_filter_spec.rb +0 -0
- data/spec/lib/zermelo/locks/redis_lock_spec.rb +170 -0
- data/spec/lib/zermelo/records/influxdb_record_spec.rb +258 -0
- data/spec/lib/zermelo/records/key_spec.rb +6 -0
- data/spec/lib/zermelo/records/redis_record_spec.rb +1426 -0
- data/spec/lib/zermelo/records/type_validator_spec.rb +6 -0
- data/spec/lib/zermelo/version_spec.rb +6 -0
- data/spec/lib/zermelo_spec.rb +6 -0
- data/spec/spec_helper.rb +67 -0
- data/spec/support/profile_all_formatter.rb +44 -0
- data/spec/support/uncolored_doc_formatter.rb +74 -0
- data/zermelo.gemspec +30 -0
- metadata +174 -0
@@ -0,0 +1,115 @@
|
|
1
|
+
# The other side of a has_one, has_many, or has_sorted_set association
|
2
|
+
|
3
|
+
module Zermelo
|
4
|
+
module Associations
|
5
|
+
class BelongsTo
|
6
|
+
|
7
|
+
# NB a single instance of this class doesn't need to care about the hash
|
8
|
+
# used for storage, that should be done in the save method of the parent
|
9
|
+
|
10
|
+
def initialize(parent, name)
|
11
|
+
@parent = parent
|
12
|
+
@name = name
|
13
|
+
|
14
|
+
@backend = parent.send(:backend)
|
15
|
+
|
16
|
+
@record_ids_key = Zermelo::Records::Key.new(
|
17
|
+
:klass => parent.class.send(:class_key),
|
18
|
+
:id => parent.id,
|
19
|
+
:name => 'belongs_to',
|
20
|
+
:type => :hash,
|
21
|
+
:object => :association
|
22
|
+
)
|
23
|
+
|
24
|
+
parent.class.send(:with_association_data, name.to_sym) do |data|
|
25
|
+
@associated_class = data.data_klass
|
26
|
+
@lock_klasses = [data.data_klass] + data.related_klasses
|
27
|
+
@inverse = data.inverse
|
28
|
+
@callbacks = data.callbacks
|
29
|
+
end
|
30
|
+
|
31
|
+
raise ':inverse_of must be set' if @inverse.nil?
|
32
|
+
@inverse_key = "#{name}_id"
|
33
|
+
end
|
34
|
+
|
35
|
+
def value=(record)
|
36
|
+
if record.nil?
|
37
|
+
@parent.class.lock(*@lock_klasses) do
|
38
|
+
r = @associated_class.send(:load, @backend.get(@record_ids_key)[@inverse_key.to_s])
|
39
|
+
bc = @callbacks[:before_clear]
|
40
|
+
if bc.nil? || !@parent.respond_to?(bc) || !@parent.send(bc, r).is_a?(FalseClass)
|
41
|
+
new_txn = @backend.begin_transaction
|
42
|
+
@backend.delete(@record_ids_key, @inverse_key)
|
43
|
+
@backend.commit_transaction if new_txn
|
44
|
+
ac = @callbacks[:after_clear]
|
45
|
+
@parent.send(ac, r) if !ac.nil? && @parent.respond_to?(ac)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
else
|
49
|
+
raise 'Invalid record class' unless record.is_a?(@associated_class)
|
50
|
+
raise 'Record must have been saved' unless record.persisted?
|
51
|
+
@parent.class.lock(*@lock_klasses) do
|
52
|
+
bs = @callbacks[:before_set]
|
53
|
+
if bs.nil? || !@parent.respond_to?(bs) || !@parent.send(bs, r).is_a?(FalseClass)
|
54
|
+
new_txn = @backend.begin_transaction
|
55
|
+
@backend.add(@record_ids_key, @inverse_key => record.id)
|
56
|
+
@backend.commit_transaction if new_txn
|
57
|
+
as = @callbacks[:after_set]
|
58
|
+
@parent.send(as, record) if !as.nil? && @parent.respond_to?(as)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def value
|
65
|
+
@parent.class.lock(*@lock_klasses) do
|
66
|
+
# FIXME uses hgetall, need separate getter for hash/list/set
|
67
|
+
if id = @backend.get(@record_ids_key)[@inverse_key.to_s]
|
68
|
+
# if id = @backend.get_hash_value(@record_ids_key, @inverse_key.to_s)
|
69
|
+
@associated_class.send(:load, id)
|
70
|
+
else
|
71
|
+
nil
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
|
78
|
+
# on_remove already runs inside a lock & transaction
|
79
|
+
def on_remove
|
80
|
+
unless value.nil?
|
81
|
+
assoc = value.send("#{@inverse}_proxy".to_sym)
|
82
|
+
if assoc.respond_to?(:delete)
|
83
|
+
assoc.send(:delete, @parent)
|
84
|
+
elsif assoc.respond_to?(:value=)
|
85
|
+
assoc.send(:value=, nil)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
@backend.clear(@record_ids_key)
|
89
|
+
end
|
90
|
+
|
91
|
+
def self.associated_ids_for(backend, class_key, name, inversed, *these_ids)
|
92
|
+
these_ids.each_with_object({}) do |this_id, memo|
|
93
|
+
key = Zermelo::Records::Key.new(
|
94
|
+
:klass => class_key,
|
95
|
+
:id => this_id,
|
96
|
+
:name => 'belongs_to',
|
97
|
+
:type => :hash,
|
98
|
+
:object => :association
|
99
|
+
)
|
100
|
+
|
101
|
+
assoc_id = backend.get(key)["#{name}_id"]
|
102
|
+
# assoc_id = backend.get_hash_value(key, "#{name}_id")
|
103
|
+
|
104
|
+
if inversed
|
105
|
+
memo[assoc_id] ||= []
|
106
|
+
memo[assoc_id] << this_id
|
107
|
+
else
|
108
|
+
memo[this_id] = assoc_id
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
@@ -0,0 +1,244 @@
|
|
1
|
+
require 'zermelo/associations/association_data'
|
2
|
+
require 'zermelo/associations/index_data'
|
3
|
+
|
4
|
+
require 'zermelo/associations/belongs_to'
|
5
|
+
require 'zermelo/associations/has_and_belongs_to_many'
|
6
|
+
require 'zermelo/associations/has_many'
|
7
|
+
require 'zermelo/associations/has_one'
|
8
|
+
require 'zermelo/associations/has_sorted_set'
|
9
|
+
require 'zermelo/associations/index'
|
10
|
+
require 'zermelo/associations/unique_index'
|
11
|
+
|
12
|
+
# NB: this module gets mixed in to Zermelo::Record as class methods
|
13
|
+
|
14
|
+
# TODO update other side of associations without having to load the record (?)
|
15
|
+
# TODO callbacks on before/after add/delete on association?
|
16
|
+
|
17
|
+
module Zermelo
|
18
|
+
module Associations
|
19
|
+
module ClassMethods
|
20
|
+
|
21
|
+
protected
|
22
|
+
|
23
|
+
# used by classes including a Zermelo Record to set up
|
24
|
+
# indices and associations
|
25
|
+
def index_by(*args)
|
26
|
+
att_types = attribute_types
|
27
|
+
args.each do |arg|
|
28
|
+
index(::Zermelo::Associations::Index, arg, :type => att_types[arg])
|
29
|
+
end
|
30
|
+
nil
|
31
|
+
end
|
32
|
+
|
33
|
+
def unique_index_by(*args)
|
34
|
+
att_types = attribute_types
|
35
|
+
args.each do |arg|
|
36
|
+
index(::Zermelo::Associations::UniqueIndex, arg, :type => att_types[arg])
|
37
|
+
end
|
38
|
+
nil
|
39
|
+
end
|
40
|
+
|
41
|
+
def has_many(name, args = {})
|
42
|
+
associate(::Zermelo::Associations::HasMany, name, args)
|
43
|
+
nil
|
44
|
+
end
|
45
|
+
|
46
|
+
def has_one(name, args = {})
|
47
|
+
associate(::Zermelo::Associations::HasOne, name, args)
|
48
|
+
nil
|
49
|
+
end
|
50
|
+
|
51
|
+
def has_sorted_set(name, args = {})
|
52
|
+
associate(::Zermelo::Associations::HasSortedSet, name, args)
|
53
|
+
nil
|
54
|
+
end
|
55
|
+
|
56
|
+
def has_and_belongs_to_many(name, args = {})
|
57
|
+
associate(::Zermelo::Associations::HasAndBelongsToMany, name, args)
|
58
|
+
nil
|
59
|
+
end
|
60
|
+
|
61
|
+
def belongs_to(name, args = {})
|
62
|
+
associate(::Zermelo::Associations::BelongsTo, name, args)
|
63
|
+
nil
|
64
|
+
end
|
65
|
+
# end used by client classes
|
66
|
+
|
67
|
+
# used internally by other parts of Zermelo to implement the above
|
68
|
+
# configuration
|
69
|
+
|
70
|
+
# Works out which classes should be locked when updating associations
|
71
|
+
# TODO work out if this can be replaced by 'related_klasses' assoc data
|
72
|
+
def associated_classes(visited = [], cascade = true)
|
73
|
+
visited |= [self]
|
74
|
+
return visited unless cascade
|
75
|
+
@lock.synchronize do
|
76
|
+
@association_data ||= {}
|
77
|
+
@association_data.values.each do |data|
|
78
|
+
klass = data.data_klass
|
79
|
+
next if visited.include?(klass)
|
80
|
+
visited |= klass.associated_classes(visited, false)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
visited
|
84
|
+
end
|
85
|
+
|
86
|
+
# TODO for each association: check whether it has changed
|
87
|
+
# would need an instance-level hash with association name as key,
|
88
|
+
# boolean 'changed' value
|
89
|
+
def with_associations(record)
|
90
|
+
@lock.synchronize do
|
91
|
+
@association_data ||= {}
|
92
|
+
@association_data.keys.each do |name|
|
93
|
+
yield record.send("#{name}_proxy".to_sym)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
def with_association_data(name = nil)
|
99
|
+
@lock.synchronize do
|
100
|
+
@association_data ||= {}
|
101
|
+
assoc_data = name.nil? ? @association_data : @association_data[name]
|
102
|
+
yield assoc_data unless assoc_data.nil?
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
def with_index_data(name = nil)
|
107
|
+
@lock.synchronize do
|
108
|
+
@index_data ||= {}
|
109
|
+
idx_data = name.nil? ? @index_data : @index_data[name]
|
110
|
+
yield idx_data unless idx_data.nil?
|
111
|
+
end
|
112
|
+
end
|
113
|
+
# end used internally within Zermelo
|
114
|
+
|
115
|
+
# # TODO can remove need for some of the inverse mapping
|
116
|
+
# # was inverse_of(source, klass)
|
117
|
+
# with_association_data do |d|
|
118
|
+
# d.detect {|name, data| data.klass == klass && data.inverse == source}
|
119
|
+
# end
|
120
|
+
|
121
|
+
private
|
122
|
+
|
123
|
+
def add_index_data(klass, name, args = {})
|
124
|
+
return if name.nil?
|
125
|
+
|
126
|
+
data = Zermelo::Associations::IndexData.new(
|
127
|
+
:name => name,
|
128
|
+
:type => args[:type],
|
129
|
+
:index_klass => klass
|
130
|
+
)
|
131
|
+
|
132
|
+
@lock.synchronize do
|
133
|
+
@index_data ||= {}
|
134
|
+
@index_data[name] = data
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
def index(klass, name, args = {})
|
139
|
+
return if name.nil?
|
140
|
+
|
141
|
+
add_index_data(klass, name, args)
|
142
|
+
|
143
|
+
idx = %Q{
|
144
|
+
private
|
145
|
+
|
146
|
+
def #{name}_index
|
147
|
+
@#{name}_index ||= #{klass.name}.new(self, '#{name}')
|
148
|
+
@#{name}_index
|
149
|
+
end
|
150
|
+
}
|
151
|
+
instance_eval idx, __FILE__, __LINE__
|
152
|
+
end
|
153
|
+
|
154
|
+
def add_association_data(klass, name, args = {})
|
155
|
+
|
156
|
+
# TODO have inverse be a reference (or copy?) of the association data
|
157
|
+
# record for that inverse association; would need to defer lookup until
|
158
|
+
# all data in place for all assocs, so might be best if looked up and
|
159
|
+
# cached on first use
|
160
|
+
inverse = if args[:inverse_of].nil? || args[:inverse_of].to_s.empty?
|
161
|
+
nil
|
162
|
+
else
|
163
|
+
args[:inverse_of].to_s
|
164
|
+
end
|
165
|
+
|
166
|
+
callbacks = case klass.name
|
167
|
+
when ::Zermelo::Associations::HasMany.name,
|
168
|
+
::Zermelo::Associations::HasSortedSet.name,
|
169
|
+
::Zermelo::Associations::HasAndBelongsToMany.name
|
170
|
+
[:before_add, :after_add, :before_remove, :after_remove]
|
171
|
+
when ::Zermelo::Associations::HasOne.name,
|
172
|
+
::Zermelo::Associations::BelongsTo.name
|
173
|
+
[:before_set, :after_set, :before_clear, :after_clear]
|
174
|
+
else
|
175
|
+
[]
|
176
|
+
end
|
177
|
+
|
178
|
+
data = Zermelo::Associations::AssociationData.new(
|
179
|
+
:name => name,
|
180
|
+
:data_klass_name => args[:class_name],
|
181
|
+
:type_klass => klass,
|
182
|
+
:inverse => inverse,
|
183
|
+
:related_klass_names => args[:related_class_names],
|
184
|
+
:callbacks => callbacks.each_with_object({}) {|c, memo|
|
185
|
+
memo[c] = args[c]
|
186
|
+
}
|
187
|
+
)
|
188
|
+
|
189
|
+
if klass.name == Zermelo::Associations::HasSortedSet.name
|
190
|
+
data.sort_key = (args[:key] || :id)
|
191
|
+
end
|
192
|
+
|
193
|
+
@lock.synchronize do
|
194
|
+
@association_data ||= {}
|
195
|
+
@association_data[name] = data
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
def associate(klass, name, args = {})
|
200
|
+
return if name.nil?
|
201
|
+
|
202
|
+
add_association_data(klass, name, args)
|
203
|
+
|
204
|
+
assoc = case klass.name
|
205
|
+
when ::Zermelo::Associations::HasMany.name,
|
206
|
+
::Zermelo::Associations::HasSortedSet.name,
|
207
|
+
::Zermelo::Associations::HasAndBelongsToMany.name
|
208
|
+
|
209
|
+
%Q{
|
210
|
+
def #{name}
|
211
|
+
#{name}_proxy
|
212
|
+
end
|
213
|
+
}
|
214
|
+
|
215
|
+
when ::Zermelo::Associations::HasOne.name,
|
216
|
+
::Zermelo::Associations::BelongsTo.name
|
217
|
+
|
218
|
+
%Q{
|
219
|
+
def #{name}
|
220
|
+
#{name}_proxy.value
|
221
|
+
end
|
222
|
+
|
223
|
+
def #{name}=(obj)
|
224
|
+
#{name}_proxy.value = obj
|
225
|
+
end
|
226
|
+
}
|
227
|
+
end
|
228
|
+
|
229
|
+
return if assoc.nil?
|
230
|
+
|
231
|
+
proxy = %Q{
|
232
|
+
def #{name}_proxy
|
233
|
+
raise "Associations cannot be invoked for records without an id" if self.id.nil?
|
234
|
+
|
235
|
+
@#{name}_proxy ||= #{klass.name}.new(self, '#{name}')
|
236
|
+
end
|
237
|
+
private :#{name}_proxy
|
238
|
+
}
|
239
|
+
class_eval proxy, __FILE__, __LINE__
|
240
|
+
class_eval assoc, __FILE__, __LINE__
|
241
|
+
end
|
242
|
+
end
|
243
|
+
end
|
244
|
+
end
|
@@ -0,0 +1,128 @@
|
|
1
|
+
require 'forwardable'
|
2
|
+
|
3
|
+
# much like a has_many, but with different add/remove behaviour, as it's paired
|
4
|
+
# with another has_and_belongs_to_many association. both sides must set the
|
5
|
+
# inverse association name.
|
6
|
+
|
7
|
+
module Zermelo
|
8
|
+
module Associations
|
9
|
+
class HasAndBelongsToMany
|
10
|
+
|
11
|
+
extend Forwardable
|
12
|
+
|
13
|
+
def_delegators :filter, :intersect, :union, :diff, :sort,
|
14
|
+
:find_by_id, :find_by_ids, :find_by_id!, :find_by_ids!,
|
15
|
+
:page, :all, :each, :collect, :map,
|
16
|
+
:select, :find_all, :reject, :destroy_all,
|
17
|
+
:ids, :count, :empty?, :exists?,
|
18
|
+
:associated_ids_for
|
19
|
+
|
20
|
+
def initialize(parent, name)
|
21
|
+
@parent = parent
|
22
|
+
|
23
|
+
@backend = parent.send(:backend)
|
24
|
+
|
25
|
+
@record_ids_key = Zermelo::Records::Key.new(
|
26
|
+
:klass => parent.class.send(:class_key),
|
27
|
+
:id => parent.id,
|
28
|
+
:name => "#{name}_ids",
|
29
|
+
:type => :set,
|
30
|
+
:object => :association
|
31
|
+
)
|
32
|
+
|
33
|
+
parent.class.send(:with_association_data, name.to_sym) do |data|
|
34
|
+
@associated_class = data.data_klass
|
35
|
+
@lock_klasses = [data.data_klass] + data.related_klasses
|
36
|
+
@inverse = data.inverse
|
37
|
+
@callbacks = data.callbacks
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def <<(record)
|
42
|
+
add(record)
|
43
|
+
self # for << 'a' << 'b'
|
44
|
+
end
|
45
|
+
|
46
|
+
def add(*records)
|
47
|
+
raise 'No records to add' if records.empty?
|
48
|
+
raise 'Invalid record class' unless records.all? {|r| r.is_a?(@associated_class)}
|
49
|
+
raise "Record(s) must have been saved" unless records.all? {|r| r.persisted?}
|
50
|
+
@parent.class.lock(*@lock_klasses) do
|
51
|
+
ba = @callbacks[:before_add]
|
52
|
+
if ba.nil? || !@parent.respond_to?(ba) || !@parent.send(ba, *records).is_a?(FalseClass)
|
53
|
+
records.each do |record|
|
54
|
+
@associated_class.send(:load, record.id).send(@inverse.to_sym).
|
55
|
+
send(:add_without_inverse, @parent)
|
56
|
+
end
|
57
|
+
add_without_inverse(*records)
|
58
|
+
aa = @callbacks[:after_add]
|
59
|
+
@parent.send(aa, *records) if !aa.nil? && @parent.respond_to?(aa)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# TODO support dependent delete, for now just deletes the association
|
65
|
+
def delete(*records)
|
66
|
+
raise 'No records to delete' if records.empty?
|
67
|
+
raise 'Invalid record class' unless records.all? {|r| r.is_a?(@associated_class)}
|
68
|
+
raise "Record(s) must have been saved" unless records.all? {|r| r.persisted?}
|
69
|
+
@parent.class.lock(*@lock_klasses) do
|
70
|
+
br = @callbacks[:before_remove]
|
71
|
+
if br.nil? || !@parent.respond_to?(br) || !@parent.send(br, *records).is_a?(FalseClass)
|
72
|
+
records.each do |record|
|
73
|
+
@associated_class.send(:load, record.id).send(@inverse.to_sym).
|
74
|
+
send(:delete_without_inverse, @parent)
|
75
|
+
end
|
76
|
+
delete_without_inverse(*records)
|
77
|
+
ar = @callbacks[:after_remove]
|
78
|
+
@parent.send(ar, *records) if !ar.nil? && @parent.respond_to?(ar)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
private
|
84
|
+
|
85
|
+
def add_without_inverse(*records)
|
86
|
+
new_txn = @backend.begin_transaction
|
87
|
+
@backend.add(@record_ids_key, records.map(&:id))
|
88
|
+
@backend.commit_transaction if new_txn
|
89
|
+
end
|
90
|
+
|
91
|
+
def delete_without_inverse(*records)
|
92
|
+
new_txn = @backend.begin_transaction
|
93
|
+
@backend.delete(@record_ids_key, records.map(&:id))
|
94
|
+
@backend.commit_transaction if new_txn
|
95
|
+
end
|
96
|
+
|
97
|
+
# associated will be the other side of the HaBTM; on_remove is always
|
98
|
+
# called inside a lock
|
99
|
+
def on_remove
|
100
|
+
ids.each do |record_id|
|
101
|
+
@associated_class.send(:load, record_id).send(@inverse.to_sym).
|
102
|
+
send(:delete_without_inverse, @parent)
|
103
|
+
end
|
104
|
+
@backend.purge(@record_ids_key)
|
105
|
+
end
|
106
|
+
|
107
|
+
# creates a new filter class each time it's called, to store the
|
108
|
+
# state for this particular filter chain
|
109
|
+
def filter
|
110
|
+
@backend.filter(@record_ids_key, @associated_class)
|
111
|
+
end
|
112
|
+
|
113
|
+
def self.associated_ids_for(backend, class_key, name, *these_ids)
|
114
|
+
these_ids.each_with_object({}) do |this_id, memo|
|
115
|
+
key = Zermelo::Records::Key.new(
|
116
|
+
:klass => class_key,
|
117
|
+
:id => this_id,
|
118
|
+
:name => "#{name}_ids",
|
119
|
+
:type => :set,
|
120
|
+
:object => :association
|
121
|
+
)
|
122
|
+
memo[this_id] = backend.get(key)
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
@@ -0,0 +1,120 @@
|
|
1
|
+
require 'forwardable'
|
2
|
+
|
3
|
+
module Zermelo
|
4
|
+
module Associations
|
5
|
+
class HasMany
|
6
|
+
|
7
|
+
extend Forwardable
|
8
|
+
|
9
|
+
def_delegators :filter, :intersect, :union, :diff, :sort,
|
10
|
+
:find_by_id, :find_by_ids, :find_by_id!, :find_by_ids!,
|
11
|
+
:page, :all, :each, :collect, :map,
|
12
|
+
:select, :find_all, :reject, :destroy_all,
|
13
|
+
:ids, :count, :empty?, :exists?,
|
14
|
+
:associated_ids_for
|
15
|
+
|
16
|
+
def initialize(parent, name)
|
17
|
+
@parent = parent
|
18
|
+
|
19
|
+
@backend = parent.send(:backend)
|
20
|
+
|
21
|
+
@record_ids_key = Zermelo::Records::Key.new(
|
22
|
+
:klass => parent.class.send(:class_key),
|
23
|
+
:id => parent.id,
|
24
|
+
:name => "#{name}_ids",
|
25
|
+
:type => :set,
|
26
|
+
:object => :association
|
27
|
+
)
|
28
|
+
|
29
|
+
parent.class.send(:with_association_data, name.to_sym) do |data|
|
30
|
+
@associated_class = data.data_klass
|
31
|
+
@lock_klasses = [data.data_klass] + data.related_klasses
|
32
|
+
@inverse = data.inverse
|
33
|
+
@callbacks = data.callbacks
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def <<(record)
|
38
|
+
add(record)
|
39
|
+
self # for << 'a' << 'b'
|
40
|
+
end
|
41
|
+
|
42
|
+
def add(*records)
|
43
|
+
raise 'No records to add' if records.empty?
|
44
|
+
raise 'Invalid record class' if records.any? {|r| !r.is_a?(@associated_class)}
|
45
|
+
raise 'Record(s) must have been saved' unless records.all? {|r| r.persisted?} # may need to be moved
|
46
|
+
@parent.class.lock(*@lock_klasses) do
|
47
|
+
ba = @callbacks[:before_add]
|
48
|
+
if ba.nil? || !@parent.respond_to?(ba) || !@parent.send(ba, *records).is_a?(FalseClass)
|
49
|
+
unless @inverse.nil?
|
50
|
+
records.each do |record|
|
51
|
+
@associated_class.send(:load, record.id).send("#{@inverse}=", @parent)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
new_txn = @backend.begin_transaction
|
56
|
+
@backend.add(@record_ids_key, records.map(&:id))
|
57
|
+
@backend.commit_transaction if new_txn
|
58
|
+
aa = @callbacks[:after_add]
|
59
|
+
@parent.send(aa, *records) if !aa.nil? && @parent.respond_to?(aa)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# TODO support dependent delete, for now just deletes the association
|
65
|
+
def delete(*records)
|
66
|
+
raise 'No records to delete' if records.empty?
|
67
|
+
raise 'Invalid record class' if records.any? {|r| !r.is_a?(@associated_class)}
|
68
|
+
raise 'Record(s) must have been saved' unless records.all? {|r| r.persisted?} # may need to be moved
|
69
|
+
@parent.class.lock(*@lock_klasses) do
|
70
|
+
br = @callbacks[:before_remove]
|
71
|
+
if br.nil? || !@parent.respond_to?(br) || !@parent.send(br, *records).is_a?(FalseClass)
|
72
|
+
unless @inverse.nil?
|
73
|
+
records.each do |record|
|
74
|
+
@associated_class.send(:load, record.id).send("#{@inverse}=", nil)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
new_txn = @backend.begin_transaction
|
79
|
+
@backend.delete(@record_ids_key, records.map(&:id))
|
80
|
+
@backend.commit_transaction if new_txn
|
81
|
+
ar = @callbacks[:after_remove]
|
82
|
+
@parent.send(ar, *records) if !ar.nil? && @parent.respond_to?(ar)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
private
|
88
|
+
|
89
|
+
# associated will be a belongs_to; on remove already runs inside a lock and transaction
|
90
|
+
def on_remove
|
91
|
+
unless @inverse.nil?
|
92
|
+
self.ids.each do |record_id|
|
93
|
+
@associated_class.send(:load, record_id).send("#{@inverse}=", nil)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
@backend.clear(@record_ids_key)
|
97
|
+
end
|
98
|
+
|
99
|
+
# creates a new filter class each time it's called, to store the
|
100
|
+
# state for this particular filter chain
|
101
|
+
def filter
|
102
|
+
@backend.filter(@record_ids_key, @associated_class)
|
103
|
+
end
|
104
|
+
|
105
|
+
def self.associated_ids_for(backend, class_key, name, *these_ids)
|
106
|
+
these_ids.each_with_object({}) do |this_id, memo|
|
107
|
+
key = Zermelo::Records::Key.new(
|
108
|
+
:klass => class_key,
|
109
|
+
:id => this_id,
|
110
|
+
:name => "#{name}_ids",
|
111
|
+
:type => :set,
|
112
|
+
:object => :association
|
113
|
+
)
|
114
|
+
memo[this_id] = backend.get(key)
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|