zermelo 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (69) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +16 -0
  3. data/.rspec +10 -0
  4. data/.travis.yml +27 -0
  5. data/Gemfile +20 -0
  6. data/LICENSE.txt +22 -0
  7. data/README.md +512 -0
  8. data/Rakefile +1 -0
  9. data/lib/zermelo/associations/association_data.rb +24 -0
  10. data/lib/zermelo/associations/belongs_to.rb +115 -0
  11. data/lib/zermelo/associations/class_methods.rb +244 -0
  12. data/lib/zermelo/associations/has_and_belongs_to_many.rb +128 -0
  13. data/lib/zermelo/associations/has_many.rb +120 -0
  14. data/lib/zermelo/associations/has_one.rb +109 -0
  15. data/lib/zermelo/associations/has_sorted_set.rb +124 -0
  16. data/lib/zermelo/associations/index.rb +50 -0
  17. data/lib/zermelo/associations/index_data.rb +18 -0
  18. data/lib/zermelo/associations/unique_index.rb +44 -0
  19. data/lib/zermelo/backends/base.rb +115 -0
  20. data/lib/zermelo/backends/influxdb_backend.rb +178 -0
  21. data/lib/zermelo/backends/redis_backend.rb +281 -0
  22. data/lib/zermelo/filters/base.rb +235 -0
  23. data/lib/zermelo/filters/influxdb_filter.rb +162 -0
  24. data/lib/zermelo/filters/redis_filter.rb +558 -0
  25. data/lib/zermelo/filters/steps/base_step.rb +22 -0
  26. data/lib/zermelo/filters/steps/diff_range_step.rb +17 -0
  27. data/lib/zermelo/filters/steps/diff_step.rb +17 -0
  28. data/lib/zermelo/filters/steps/intersect_range_step.rb +17 -0
  29. data/lib/zermelo/filters/steps/intersect_step.rb +17 -0
  30. data/lib/zermelo/filters/steps/limit_step.rb +17 -0
  31. data/lib/zermelo/filters/steps/offset_step.rb +17 -0
  32. data/lib/zermelo/filters/steps/sort_step.rb +17 -0
  33. data/lib/zermelo/filters/steps/union_range_step.rb +17 -0
  34. data/lib/zermelo/filters/steps/union_step.rb +17 -0
  35. data/lib/zermelo/locks/no_lock.rb +16 -0
  36. data/lib/zermelo/locks/redis_lock.rb +221 -0
  37. data/lib/zermelo/records/base.rb +62 -0
  38. data/lib/zermelo/records/class_methods.rb +127 -0
  39. data/lib/zermelo/records/collection.rb +14 -0
  40. data/lib/zermelo/records/errors.rb +24 -0
  41. data/lib/zermelo/records/influxdb_record.rb +35 -0
  42. data/lib/zermelo/records/instance_methods.rb +224 -0
  43. data/lib/zermelo/records/key.rb +19 -0
  44. data/lib/zermelo/records/redis_record.rb +27 -0
  45. data/lib/zermelo/records/type_validator.rb +20 -0
  46. data/lib/zermelo/version.rb +3 -0
  47. data/lib/zermelo.rb +102 -0
  48. data/spec/lib/zermelo/associations/belongs_to_spec.rb +6 -0
  49. data/spec/lib/zermelo/associations/has_many_spec.rb +6 -0
  50. data/spec/lib/zermelo/associations/has_one_spec.rb +6 -0
  51. data/spec/lib/zermelo/associations/has_sorted_set.spec.rb +6 -0
  52. data/spec/lib/zermelo/associations/index_spec.rb +6 -0
  53. data/spec/lib/zermelo/associations/unique_index_spec.rb +6 -0
  54. data/spec/lib/zermelo/backends/influxdb_backend_spec.rb +0 -0
  55. data/spec/lib/zermelo/backends/moneta_backend_spec.rb +0 -0
  56. data/spec/lib/zermelo/filters/influxdb_filter_spec.rb +0 -0
  57. data/spec/lib/zermelo/filters/redis_filter_spec.rb +0 -0
  58. data/spec/lib/zermelo/locks/redis_lock_spec.rb +170 -0
  59. data/spec/lib/zermelo/records/influxdb_record_spec.rb +258 -0
  60. data/spec/lib/zermelo/records/key_spec.rb +6 -0
  61. data/spec/lib/zermelo/records/redis_record_spec.rb +1426 -0
  62. data/spec/lib/zermelo/records/type_validator_spec.rb +6 -0
  63. data/spec/lib/zermelo/version_spec.rb +6 -0
  64. data/spec/lib/zermelo_spec.rb +6 -0
  65. data/spec/spec_helper.rb +67 -0
  66. data/spec/support/profile_all_formatter.rb +44 -0
  67. data/spec/support/uncolored_doc_formatter.rb +74 -0
  68. data/zermelo.gemspec +30 -0
  69. 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