volt-repo_cache 0.1.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +19 -0
  3. data/.rspec +2 -0
  4. data/Gemfile +12 -0
  5. data/LICENSE.txt +22 -0
  6. data/README.md +314 -0
  7. data/Rakefile +1 -0
  8. data/lib/volt/repo_cache.rb +6 -0
  9. data/lib/volt/repo_cache/association.rb +100 -0
  10. data/lib/volt/repo_cache/cache.rb +116 -0
  11. data/lib/volt/repo_cache/collection.rb +259 -0
  12. data/lib/volt/repo_cache/model.rb +671 -0
  13. data/lib/volt/repo_cache/model_array.rb +169 -0
  14. data/lib/volt/repo_cache/util.rb +78 -0
  15. data/lib/volt/repo_cache/version.rb +5 -0
  16. data/spec/dummy/.gitignore +9 -0
  17. data/spec/dummy/README.md +4 -0
  18. data/spec/dummy/app/main/assets/css/app.css.scss +1 -0
  19. data/spec/dummy/app/main/config/dependencies.rb +11 -0
  20. data/spec/dummy/app/main/config/initializers/boot.rb +10 -0
  21. data/spec/dummy/app/main/config/routes.rb +14 -0
  22. data/spec/dummy/app/main/controllers/main_controller.rb +27 -0
  23. data/spec/dummy/app/main/models/customer.rb +4 -0
  24. data/spec/dummy/app/main/models/order.rb +6 -0
  25. data/spec/dummy/app/main/models/product.rb +5 -0
  26. data/spec/dummy/app/main/models/user.rb +12 -0
  27. data/spec/dummy/app/main/views/main/about.html +7 -0
  28. data/spec/dummy/app/main/views/main/index.html +6 -0
  29. data/spec/dummy/app/main/views/main/main.html +29 -0
  30. data/spec/dummy/config.ru +4 -0
  31. data/spec/dummy/config/app.rb +147 -0
  32. data/spec/dummy/config/base/index.html +15 -0
  33. data/spec/dummy/config/initializers/boot.rb +4 -0
  34. data/spec/integration/sample_integration_spec.rb +11 -0
  35. data/spec/sample_spec.rb +7 -0
  36. data/spec/spec_helper.rb +18 -0
  37. data/volt-repo_cache.gemspec +38 -0
  38. metadata +287 -0
@@ -0,0 +1,116 @@
1
+ # TODO: implement locking, atomic transactions, ...
2
+
3
+ module Volt
4
+ module RepoCache
5
+ class Cache
6
+ include Volt::RepoCache::Util
7
+
8
+
9
+ attr_reader :options, :collections, :repo
10
+ attr_reader :loaded # returns a promise
11
+
12
+ # Create a cache for a given Volt repo (store, page, local_store...)
13
+ # and the given collections within it.
14
+ #
15
+ # If no repo given then Volt.current_app.store will be used.
16
+ #
17
+ # Collections is an array of hashes each with a collection
18
+ # name as key, and a hash of options as value.
19
+ # Options are:
20
+ # :where => query (as for store._collection.where(query))
21
+ # ...
22
+ # The cache should be used until the 'loaded' promise
23
+ # resolves.
24
+ #
25
+ # For example, to cache all users:
26
+ #
27
+ # RepoCache.new(collections: [:_users]).loaded.then do |cache|
28
+ # puts "cache contains #{cache._users.size} users"
29
+ # end
30
+ #
31
+ # Call #flush! to flush all changes to the repo.
32
+ # Call #clear when finished with the cache to help
33
+ # garbage collection.
34
+ #
35
+ # TODO:
36
+ # read_only should be inherited by has_... targets
37
+ #
38
+ def initialize(**options)
39
+ # debug __method__, __LINE__, "@options = #{@options}"
40
+ @repo = options.delete(:repo) || Volt.current_app.store
41
+ @collection_options = options.delete(:collections) || {}
42
+ load
43
+ end
44
+
45
+ def persistor
46
+ @repo.persistor
47
+ end
48
+
49
+ def query(collection_name, args)
50
+ collections[collection_name].query(args)
51
+ end
52
+
53
+ def method_missing(method, *args, &block)
54
+ collection = @collections[method]
55
+ super unless collection
56
+ collection
57
+ end
58
+
59
+ # Clear all caches, circular references, etc
60
+ # when cache no longer required - can't be
61
+ # used after this.
62
+ def clear
63
+ # debug __method__, __LINE__
64
+ @collections.each do |name, collection|
65
+ # debug __method__, __LINE__, "name=#{name} collection=#{collection}"
66
+ collection.send(:uncache)
67
+ end
68
+ @collections = {}
69
+
70
+ # TODO: this is not nice, but bad things happen if we don't clear the repo persistor's identity map
71
+ # -> ensure we don't leave patched models lying around
72
+ # debug __method__, __LINE__, "calling @repo.persistor.clear_identity_map "
73
+ # @repo.persistor.clear_identity_map # otherwise error if new customer add via repo_cache
74
+ end
75
+
76
+ # Flush all cached collections and in turn
77
+ # all their models. Flushing performs
78
+ # inserts, updates or destroys as required.
79
+ # Returns a promise with this cache as value
80
+ # or error(s) if any occurred.
81
+ def flush!
82
+ flushes = collections.values.map {|c| c.flush! }
83
+ Promise.when(*flushes).then { self }
84
+ end
85
+
86
+ private
87
+
88
+ def load
89
+ @collections = {}
90
+ promises = []
91
+ @collection_options.each do |given_name, options|
92
+ name = collection_name(given_name)
93
+ # debug __method__, __LINE__
94
+ collection = Collection.new(cache: self, name: name, options: options)
95
+ # debug __method__, __LINE__
96
+ @collections[name] = collection
97
+ promises << collection.loaded
98
+ end
99
+ # debug __method__, __LINE__, "promises.size = #{promises.size}"
100
+ t1 = Time.now
101
+ @loaded = Promise.when(*promises).then do
102
+ # t2 = Time.now
103
+ # debug __method__, __LINE__, "@@loaded = Promise.when(*promises).then took #{t2-t1} seconds"
104
+ self
105
+ end
106
+ # debug __method__, __LINE__, "@loaded => #{@loaded.class.name}:#{@loaded.value.class.name}"
107
+ end
108
+
109
+ def collection_name(given_name)
110
+ n = given_name.to_s.underscore.pluralize
111
+ n = '_' + n unless n[0] == '_'
112
+ n.to_sym
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,259 @@
1
+ require 'set'
2
+ require 'volt/reactive/reactive_array'
3
+
4
+ # TODO: find a way around circular reference from collections to cache
5
+ module Volt
6
+ module RepoCache
7
+ class Collection < ModelArray
8
+ include Volt::RepoCache::Util
9
+
10
+ attr_reader :cache
11
+ attr_reader :name
12
+ attr_reader :model_class_name
13
+ attr_reader :model_class
14
+ attr_reader :repo_collection
15
+ attr_reader :load_query
16
+ attr_reader :loaded_ids
17
+ attr_reader :loaded # Promise
18
+ attr_reader :marked_for_destruction
19
+ attr_reader :associations
20
+ attr_reader :read_only
21
+
22
+ def initialize(cache: nil, name: nil, options: {})
23
+ # debug __method__, __LINE__, "name: #{name} options: #{options}"
24
+ super(observer: self)
25
+ # debug __method__, __LINE__
26
+ @cache = cache
27
+ @name = name
28
+ @load_query = options[:query] || options[:where]
29
+ @read_only = options[:read_only].nil? ? true : options[:read_only]
30
+ @marked_for_destruction = {}
31
+ @model_class_name = @name.to_s.singularize.camelize
32
+ @model_class = Object.const_get(@model_class_name)
33
+ @repo_collection = @cache.repo.send(name)
34
+ init_associations(options)
35
+ load
36
+ # debug __method__, __LINE__
37
+ end
38
+
39
+ # hide circular reference to cache
40
+ def inspect
41
+ __tmp = @cache
42
+ @cache = '{{hidden for inspect}}'
43
+ result = super
44
+ @cache = __tmp
45
+ result
46
+ end
47
+
48
+ # Flushes each model in the array.
49
+ # Returns a single Promise when
50
+ # element promises are resolved.
51
+ # TODO: error handling
52
+ def flush!
53
+ promises = []
54
+ unless read_only
55
+ # models are removed from @marked_from_destruction as
56
+ # they are flushed, so we need a copy of them to enumerate
57
+ @marked_for_destruction.values.dup.each do |e|
58
+ promises << e.flush!
59
+ end
60
+ each do |e|
61
+ promises << e.flush!
62
+ end
63
+ end
64
+ Promise.when(*promises)
65
+ end
66
+
67
+ # Create a new model from given hash and append it to the collection.
68
+ # Returns the new model
69
+ def create(hash = {})
70
+ append(hash.to_h)
71
+ end
72
+
73
+ # Appends a model to the collection.
74
+ # Model may be a hash which will be converted.
75
+ # (See #induct for more.)
76
+ # If the model belongs_to any other owners, the foreign id(s)
77
+ # MUST be already set to ensure associational integrity
78
+ # in the cache - it is easier to ask the owner for a new
79
+ # instance (e.g. product.recipe.new_ingredient).
80
+ # NB: Returns the newly appended model.
81
+ def append(model, error_if_present: true, error_unless_new: true, notify: true)
82
+ model = induct(model, error_unless_new: error_unless_new, error_if_present: error_if_present)
83
+ __append__(model, notify: notify)
84
+ model
85
+ end
86
+
87
+ # Returns self after appending the given model
88
+ def <<(model)
89
+ append(model)
90
+ self
91
+ end
92
+
93
+ private
94
+
95
+ def fail_if_read_only(what)
96
+ if read_only
97
+ raise RuntimeError, "cannot #{what} for read only cache collection"
98
+ end
99
+ end
100
+
101
+ def uncache
102
+ each {|e| e.send(:uncache)}
103
+ associations.each_value {|e| e.send(:uncache)}
104
+ @id_table.clear if @id_table
105
+ @cache = @associations = @repo_collection = @id_table = nil
106
+ __clear__
107
+ end
108
+
109
+ # Add the given model to marked_for_destruction list
110
+ # and remove from collection. Should only be called
111
+ # by RepoCache::Model#mark_for_destruction!.
112
+ def mark_model_for_destruction(model)
113
+ fail_if_read_only(__method__)
114
+ # don't add if already in marked bucket
115
+ if @marked_for_destruction[model.id]
116
+ raise RuntimeError, "#{model} already in #{self.name} @marked_for_destruction"
117
+ end
118
+ @marked_for_destruction[model.id] = model
119
+ __remove__(model, error_if_absent: true)
120
+ end
121
+
122
+ # Called by RepoCache::Model#__destroy__.
123
+ # Remove model from marked_for_destruction bucket.
124
+ # Don't worry if we can't find it.
125
+ def destroyed(model)
126
+ @loaded_ids.delete(model.id)
127
+ @marked_for_destruction.delete(model.id)
128
+ end
129
+
130
+ # Collection is being notified (probably by super/self)
131
+ # that a model has been added or removed. Pass
132
+ # this on to associations.
133
+ def observe(action, model)
134
+ # debug __method__, __LINE__, "action=#{action} model=#{model} associations=#{associations}"
135
+ # notify owner model(s) of appended model that it has been added
136
+ notify_associations(action, model)
137
+ end
138
+
139
+ def notify_associations(action, model)
140
+ # debug __method__, __LINE__, "action=#{action} model=#{model} associations=#{associations}"
141
+ associations.each_value do |assoc|
142
+ # debug __method__, __LINE__, "calling notify_associates(#{assoc}, #{action}, #{model})"
143
+ notify_associates(assoc, action, model)
144
+ # debug __method__, __LINE__, "called notify_associates(#{assoc}, #{action}, #{model})"
145
+ end
146
+ end
147
+
148
+ # Notify models in the given association that
149
+ # the given model has been deleted from or
150
+ # appended to this collection. For example,
151
+ # this collection may be for orders, and
152
+ # association may be owner customer - thus
153
+ # association will be belongs_to
154
+ def notify_associates(assoc, action, model)
155
+ # debug __method__, __LINE__, "action=#{action} model=#{model} assoc=#{assoc} reciprocate=#{assoc.reciprocal}"
156
+ if assoc.reciprocal
157
+ local_id = model.send(assoc.local_id_field)
158
+ # debug __method__, __LINE__, "local_id #{assoc.local_id_field}=#{local_id}"
159
+ if local_id # may not be set yet
160
+ assoc.foreign_collection.each do |other|
161
+ # debug __method__, __LINE__, "calling #{assoc.foreign_id_field} on #{other}"
162
+ foreign_id = other.send(assoc.foreign_id_field)
163
+ if local_id == foreign_id
164
+ # debug __method__, __LINE__, "foreign_id==local_id of #{other}, calling other.refresh_association(#{assoc.foreign_name})"
165
+ other.send(:refresh_association, assoc.reciprocal)
166
+ end
167
+ end
168
+ end
169
+ end
170
+ # debug __method__, __LINE__
171
+ end
172
+
173
+ # 'Induct' a model into the cache via this collection.
174
+ #
175
+ # Called by #append.
176
+ #
177
+ # If the model is a hash then converts it to a full model.
178
+ #
179
+ # Patches the model with singleton methods and instance
180
+ # variables required by cached models.
181
+ #
182
+ # Raises error if:
183
+ # - the model has the wrong persistor for the cache
184
+ # - the model class is not appropriate to this collection
185
+ # - the model is not new and argument error_unless_new is true
186
+ # - the model is already in the collection and error_if_present is true
187
+ #
188
+ # TODO: Also checks the model's associations:
189
+ # - if it has no belongs_to associations then it is self sufficient
190
+ # (owned by no other) and can be added to the collection.
191
+ # - if it should belong to (an)other model(s), then we require that
192
+ # the foreign id(s) are already set, otherwise we cannot ensure
193
+ # associational integrity in the cache.
194
+ #
195
+ # Returns the inducted model.
196
+ def induct(model_or_hash, error_unless_new: true, error_if_present: true)
197
+ created_in_cache = false
198
+ if model_or_hash.is_a?(Hash)
199
+ created_in_cache = true
200
+ model = model_class.new(model_or_hash, options: {persistor: cache.persistor})
201
+ else
202
+ model = model_or_hash
203
+ # unless model.persistor.class == cache.persistor.class
204
+ # raise RuntimeError, "model persistor is #{model.persistor} but should be #{cache.persistor}"
205
+ # end
206
+ unless model.class == model_class
207
+ raise ArgumentError, "#{model} must be a #{model_class_name}"
208
+ end
209
+ if error_unless_new && (!model.created_in_cache? || model.new?)
210
+ raise ArgumentError, "#{model} must be new (not stored) or have been created in cache"
211
+ end
212
+ if error_if_present && @loaded_ids.include?(model.id)
213
+ raise RuntimeError, "cannot add #{model} already in cached collection"
214
+ end
215
+ end
216
+ @loaded_ids << model.id
217
+ patch_for_cache(model, created_in_cache)
218
+ model
219
+ end
220
+
221
+ def load
222
+ # debug __method__, __LINE__
223
+ @loaded_ids = Set.new # append/delete will update
224
+ q = @load_query ? repo_collection.where(@load_query) : repo_collection
225
+ # t1 = Time.now
226
+ @loaded = q.all.collect{|e|e}.then do |models|
227
+ # t2 = Time.now
228
+ # debug __method__, __LINE__, "#{name} read_only=#{read_only} query promise resolved to #{models.size} models in #{t2-t1} seconds"
229
+ models.each do |model|
230
+ append(read_only ? model : model.buffer, error_unless_new: false, notify: false)
231
+ end
232
+ # t3 = Time.now
233
+ # debug __method__, __LINE__, "#{name} loaded ids for #{models.size} #{name} in #{t3-t2} seconds"
234
+ self
235
+ end
236
+ # debug __method__, __LINE__, "@loaded => #{@loaded.class.name}:#{@loaded.value.class.name}"
237
+ end
238
+
239
+ def init_associations(options)
240
+ # debug __method__, __LINE__, "options = #{options}"
241
+ @associations = {}
242
+ [:belongs_to, :has_one, :has_many].each do |type|
243
+ arrify(options[type]).map(&:to_sym).each do |foreign_name|
244
+ @associations[foreign_name] = Association.new(self, foreign_name, type)
245
+ # debug __method__, __LINE__, "@associations[#{foreign_name}] = #{@associations[foreign_name].inspect}"
246
+ end
247
+ end
248
+ end
249
+
250
+ def patch_for_cache(model, created_in_cache)
251
+ unless model.respond_to?(:patched_for_cache?)
252
+ RepoCache::Model.patch_for_cache(model, self, created_in_cache)
253
+ end
254
+ model
255
+ end
256
+
257
+ end
258
+ end
259
+ end
@@ -0,0 +1,671 @@
1
+ # used by RepoCache::Collection
2
+ # TODO: relies excessively on singleton methods
3
+ # - look at refactor which moves the
4
+ # methods to the module and provides
5
+ # the instance as argument.
6
+
7
+ module Volt
8
+ module RepoCache
9
+ module Model
10
+ extend Volt::RepoCache::Util
11
+
12
+ def self.patch_for_cache(model, collection, created_in_cache)
13
+ # debug __method__, __LINE__, "patch_for_cache [#{collection.name}] : #{model.to_h}"
14
+
15
+ # Volt sets @new to false if any attribute changes - not what we want
16
+ model.instance_variable_set(:@__cache__created_in_cache, created_in_cache)
17
+ model.instance_variable_set(:@__cache__collection, collection)
18
+ model.instance_variable_set(:@__cache__associations, {})
19
+ model.instance_variable_set(:@__cache__marked_for_destruction, false)
20
+ # TODO: if model is not buffered, then trap all
21
+ # field set value methods and raise exception -
22
+ # unless buffered the model is read only.
23
+
24
+ # create bunch of instance singleton methods
25
+ # for association management
26
+ collection.associations.each_value do |assoc|
27
+ foreign_name = assoc.foreign_name
28
+
29
+ if assoc.belongs_to?
30
+ # ensure id's of owners are set in the model
31
+ unless model.send(assoc.local_id_field)
32
+ raise RuntimeError, "#{assoc.local_id_field} must be set for #{model}"
33
+ end
34
+
35
+ # trapper: `model.owner_id=` for belongs_to associations.
36
+ # e.g. recipe.product_id = product.id
37
+ # - validates the local id is in the foreign cached collection
38
+ # - notifies associated models as required
39
+ # NB this overrides a model's foreign_id set methods
40
+ m = setter(assoc.local_id_field)
41
+ model.define_singleton_method(m) do |new_foreign_id|
42
+ trapped_set_owner_id(assoc, new_foreign_id)
43
+ end
44
+ end
45
+
46
+ # reader: `model.something` method for belongs_to, has_one and has_many
47
+ # e.g. product.recipe
48
+ m = foreign_name
49
+ model.define_singleton_method(m) do
50
+ # debug __method__, __LINE__, "defining #{model.class.name}##{m}"
51
+ get_association(assoc)
52
+ end
53
+
54
+ unless collection.read_only
55
+ # writer: `model.something=` methods for belongs_to, has_one and has_many
56
+ # e.g. product.recipe = Recipe.new
57
+ # e.g. product.recipe.ingredients = [...]
58
+ m = setter(foreign_name)
59
+ model.define_singleton_method(m) do |model_or_array|
60
+ set_association(assoc, model_or_array)
61
+ end
62
+
63
+ # creator: `model.new_something` method for has_one and has_many
64
+ # will set foreign id in the newly created model.
65
+ # e.g. recipe = product.new_recipe
66
+ # e.g. ingredient = product.recipe.new_ingredient({product: flour})
67
+ if assoc.has_any?
68
+ m = creator(foreign_name)
69
+ model.define_singleton_method(m, Proc.new { |args|
70
+ new_association(assoc, args)
71
+ })
72
+ end
73
+
74
+ # add and remove has_many association values
75
+ if assoc.has_many?
76
+ # add to has_many: `model.add_something`
77
+ # e.g. product.recipe.add_ingredient(Ingredient.new)
78
+ m = adder(foreign_name)
79
+ model.define_singleton_method(m) do |other|
80
+ add_to_many(assoc, other)
81
+ end
82
+ # remove from has_many: `model.remove_something`
83
+ # e.g. product.recipe.remove_ingredient(ingredient)
84
+ m = remover(foreign_name)
85
+ model.define_singleton_method(m) do |other|
86
+ remove_from_many(assoc, other)
87
+ end
88
+ end
89
+ end
90
+
91
+ end
92
+
93
+ # Use respond_to?(:patched_for_cache?) to determine
94
+ # whether a model's behaviour has been patched here
95
+ # to operate in the cache (if you called the method
96
+ # directly on a non-patched model you would raise
97
+ # method_missing).
98
+ def model.patched_for_cache?
99
+ true
100
+ end
101
+
102
+ def model.created_in_cache?
103
+ @__cache__created_in_cache
104
+ end
105
+
106
+ # Returns true if the model has been marked
107
+ # for destruction on flush. Otherwise false.
108
+ def model.marked_for_destruction?
109
+ @__cache__marked_for_destruction
110
+ end
111
+
112
+ # Returns the cached collected the model belongs to.
113
+ def model.collection
114
+ @__cache__collection
115
+ end
116
+
117
+ # Returns the cache the model belongs to.
118
+ def model.cache
119
+ @__cache__collection.cache
120
+ end
121
+
122
+ # Hide circular reference to collection
123
+ # when doing inspection.
124
+ def model.inspect
125
+ if @__cache__collection
126
+ __tmp = @__cache__collection
127
+ @__cache__collection = "{{#{@__cache__collection.name}}}"
128
+ result = super
129
+ @__cache__collection = __tmp
130
+ result
131
+ else
132
+ super
133
+ end
134
+ end
135
+
136
+ unless collection.read_only
137
+ # Locks the model in the underlying repo.
138
+ # Not yet implemented.
139
+ def model.lock!
140
+ raise RuntimeError, 'lock support coming'
141
+ end
142
+
143
+ # Marks the model and all its 'has_' associations
144
+ # for destruction when model, collection or cache
145
+ # is flushed.
146
+ def model.mark_for_destruction!
147
+ # prevent collection going in circles on this
148
+ # (we don't know whether initial request was to
149
+ # self or to collection which holds self)
150
+ unless @__cache__marked_for_destruction
151
+ # debug __method__, __LINE__, "marking #{self} for destruction"
152
+ @__cache__marked_for_destruction = true
153
+ @__cache__collection.send(:mark_model_for_destruction, self)
154
+ mark_associations_for_destruction
155
+ end
156
+ end
157
+
158
+ # Flushes changes in the model to the repo.
159
+ #
160
+ # - if new will insert (append) the model to the repo
161
+ #
162
+ # - if dirty will update (save) the buffer to the repo
163
+ #
164
+ # - if new or dirty will flush all has_ associations
165
+ #
166
+ # - if marked_for_destruction will destroy the model
167
+ # and all its has_one and has_many associations
168
+ #
169
+ # Returns a promise with model as value..
170
+ #
171
+ # WARNING
172
+ # - flush! is not (yet) an atomic transaction
173
+ # - any part of it may fail without unwinding the whole
174
+ def model.flush!
175
+ fail_if_read_only(__method__)
176
+ if @__cache__marked_for_destruction
177
+ # debug __method__, __LINE__, "marked for destruction so call destroy on #{to_h}"
178
+ __destroy__
179
+ else
180
+ if @__cache__created_in_cache || dirty?
181
+ # debug __method__, __LINE__, "is dirty: #{to_h}"
182
+ if @__cache__created_in_cache
183
+ # debug __method__, __LINE__, "new: #{self.class.name}::#{self.id}"
184
+ @__cache__created_in_cache = false
185
+ @__cache__collection.repo_collection << self
186
+ else
187
+ # debug __method__, __LINE__,"dirty: #{self.class.name}::#{self.id}"
188
+ __save__
189
+ end
190
+ else
191
+ # debug __method__, __LINE__, "not dirty: #{to_h}"
192
+ # neither new nor dirty but
193
+ # stay in the promise chain
194
+ Promise.value(self)
195
+ end
196
+ end.then do
197
+ self
198
+ end
199
+ end
200
+
201
+ # Returns true if proxy is buffered and the
202
+ # buffer has changed from original model.
203
+ # If proxy is new model return true.
204
+ # Assumes fields defined for model.
205
+ # Does not check associations.
206
+ def model.dirty?
207
+ # fields_data is a core Volt class method
208
+ self.class.fields_data.keys.each do |field|
209
+ return true if changed?(field)
210
+ end
211
+ @__cache__created_in_cache
212
+ end
213
+
214
+ # Destroys (deletes) the model in database.
215
+ # If not called by the model itself
216
+ # the model is marked for destruction and flushed
217
+ # to ensure cache integrity, otherwise super() called.
218
+ # Returns a promise.
219
+ def model.destroy(caller: nil)
220
+ fail_if_read_only(__method__)
221
+ if caller.nil?
222
+ mark_for_destruction!
223
+ flush!
224
+ elsif caller.object_id != self.object_id
225
+ raise RuntimeError, "#{__method__}: unexpected caller #{caller}"
226
+ else
227
+ super()
228
+ end
229
+ end
230
+
231
+ # Saves (creates/updates) the model in database.
232
+ # If not called by the model itself
233
+ # the model is flushed (to ensure cache
234
+ # integrity, otherwise super() called.
235
+ # Returns a promise.
236
+ def model.save!(caller: nil)
237
+ fail_if_read_only(__method__)
238
+ if caller.nil?
239
+ flush!
240
+ elsif caller.object_id != self.object_id
241
+ raise RuntimeError, "#{__method__}: unexpected caller #{caller}"
242
+ else
243
+ super()
244
+ end
245
+ end
246
+ end
247
+
248
+ # #######################################
249
+ # FOLLOWING ARE INTENDED FOR INTERNAL USE
250
+ # Error will be raised unless caller's
251
+ # class namespace is Volt::RepoCache.
252
+ # #######################################
253
+
254
+ def model.fail_if_read_only(what)
255
+ if @__cache__collection.read_only
256
+ raise RuntimeError, "cannot #{what} for read only cache collection/model"
257
+ end
258
+ end
259
+ model.singleton_class.send(:private, :fail_if_read_only)
260
+
261
+ # private
262
+ def model.uncache
263
+ @__cache__associations.clear if @__cache__associations
264
+ if false
265
+ instance_variables.each do |v|
266
+ if v.to_s =~ /__cache__/
267
+ # debug __method__, __LINE__, "removing instance variable '#{v}'"
268
+ set_instance_varirable(v, nil)
269
+ end
270
+ end
271
+ elsif false
272
+ @__cache__associations.clear if @__cache__associations
273
+ instance_variables.each do |v|
274
+ if v.to_s =~ /__cache__/
275
+ # debug __method__, __LINE__, "removing instance variable '#{v}'"
276
+ remove_instance_variable(v)
277
+ end
278
+ end
279
+ # WARNING - assumes no singleton methods other than those we've attached
280
+ singleton_methods.each do |m|
281
+ unless m == :debug || m == :uncache
282
+ # debug __method__, __LINE__, "removing singleton method '#{m}'"
283
+ @@___m___ = m # m is out of scope in class << self TODO: anything nicer?
284
+ class << self # weird syntax to remove singleton method
285
+ remove_method(@@___m___)
286
+ end
287
+ end
288
+ end
289
+ @@___m___ = nil
290
+ class << self
291
+ remove_method(:debug)
292
+ remove_method(:uncache)
293
+ end
294
+ end
295
+ end
296
+ model.singleton_class.send(:private, :uncache)
297
+
298
+ # private
299
+ # Used by cached collections to notify
300
+ # reciprocal associated model(s) that
301
+ # they need to refresh association queries.
302
+ #
303
+ # Raise error unless caller's class namespace is Volt::RepoCache.
304
+ def model.refresh_association(association)
305
+ # debug __method__, __LINE__, "association=#{association.foreign_name}"
306
+ # refresh the association query
307
+ result = get_association(association, refresh: true)
308
+ # debug __method__, __LINE__, "#{self} association=#{association} result=#{result}"
309
+ end
310
+ model.singleton_class.send(:private, :refresh_association)
311
+
312
+ # Returns a promise
313
+ def model.__save__
314
+ save!(caller: self)
315
+ end
316
+ model.singleton_class.send(:private, :__save__)
317
+
318
+ # private
319
+ # Destroys the underlying model in the underlying repository.
320
+ # NB in Volt 0.9.6 there's a problem with destroy if
321
+ # MESSAGE_BUS is on and there's another connection
322
+ # (e.g. console) running.
323
+ # Returns a promise with destroyed model proxy as value.
324
+ def model.__destroy__
325
+ # debug __method__, __LINE__
326
+ fail_if_read_only(__method__)
327
+ # debug __method__, __LINE__
328
+ promise = if created_in_cache? || new?
329
+ Promise.value(self)
330
+ else
331
+ destroy(caller: self)
332
+ end
333
+ # debug __method__, __LINE__
334
+ promise.then do |m|
335
+ # debug __method__, __LINE__, "destroy promise resolved to #{m}"
336
+ @__cache__collection.destroyed(self)
337
+ uncache
338
+ self
339
+ end.fail do |errors|
340
+ # debug __method__, __LINE__, "destroy failed => #{errors}"
341
+ errors
342
+ end
343
+ end
344
+ model.singleton_class.send(:private, :__destroy__)
345
+
346
+ # private
347
+ # Get the model for the given association
348
+ # (belongs_to, has_one or has_many).
349
+ #
350
+ # If refresh is true then re-query from
351
+ # cached foreign collection. Keep result
352
+ # of association in instance variable
353
+ # for later fast access.
354
+ #
355
+ # Relies on cached collections notifying
356
+ # associated models when to refresh.
357
+ def model.get_association(assoc, refresh: false)
358
+ # debug __method__, __LINE__, "#{self.class.name}:#{id} assoc=#{assoc.foreign_name} refresh: #{refresh}"
359
+ foreign_name = assoc.foreign_name
360
+ @__cache__associations[foreign_name] = nil if refresh
361
+ prior = @__cache__associations[foreign_name]
362
+ local_id = self.send(assoc.local_id_field)
363
+ foreign_id_field = assoc.foreign_id_field
364
+ # debug __method__, __LINE__, "foreign_id_field=#{foreign_id_field}"
365
+ result = if prior && match?(prior, foreign_id_field, local_id)
366
+ prior
367
+ else
368
+ q = {foreign_id_field => local_id}
369
+ # debug __method__, __LINE__
370
+ r = assoc.foreign_collection.query(q) || []
371
+ # debug __method__, __LINE__
372
+ @__cache__associations[foreign_name] = assoc.has_many? ? ModelArray.new(contents: r) : r.first
373
+ end
374
+ # debug __method__, __LINE__
375
+ result
376
+ end
377
+ model.singleton_class.send(:private, :get_association)
378
+
379
+ # private
380
+ # For the given has_one or has_many association,
381
+ # create a new instance of the association's
382
+ # foreign model class with its foreign_id set
383
+ # appropriately.
384
+ #
385
+ # WARNING: If the association is has_one,
386
+ # the prior foreign model will be marked for
387
+ # destruction.
388
+ #
389
+ # If the association is has_many, the new
390
+ # foreign model will be added to the many.
391
+ #
392
+ # has_one example: if model is a product and
393
+ # association is has_one :recipe, then
394
+ # `product.new_recipe` will create a new Recipe
395
+ # with `recipe.product_id` set to `product.id`,
396
+ # and `product.recipe` new return the new recipe.
397
+ # NB this will mark any existing recipe for
398
+ # destruction.
399
+ #
400
+ # has_many example: if model is a recipe and
401
+ # association is has_many :ingredients, then
402
+ # `recipe.new_ingredient` will create a new
403
+ # Ingredient with `ingredient.recipe_id` set
404
+ # to `recipe.id`, and `recipe.ingredients` will
405
+ # now include the new ingredient.
406
+ def model.new_association(assoc, _attrs)
407
+ fail_if_read_only(__method__)
408
+ # go through the foreign collection to create
409
+ attrs = _attrs ? _attrs.dup : {}
410
+ attrs[assoc.foreign_id_field] = self.send(assoc.local_id_field)
411
+ new_associate = assoc.foreign_collection.create(attrs)
412
+ if assoc.has_one?
413
+ set_association(assoc, new_associate, false)
414
+ else
415
+ add_to_many(assoc, new_associate, false)
416
+ end
417
+ new_associate
418
+ end
419
+ model.singleton_class.send(:private, :new_association)
420
+
421
+
422
+ # private
423
+ # Set the associated value for the given belongs_to,
424
+ # has_one or has_many association,
425
+ #
426
+ # e.g. has_one: `product.recipe = Recipe.new`
427
+ # e.g. has_many: `product.recipe.ingredients = [...]`
428
+ # e.g. belongs_to: `ingredient.product = cache._products.where(code: 'SDO')`
429
+ #
430
+ # An exception will be raised if given value is
431
+ # not appropriate to the association.
432
+ #
433
+ # WARNING: if the association is has_one or
434
+ # has_many then any prior associated values
435
+ # will be marked for destruction.
436
+ #
437
+ # NB we don't immediately update local @__cache__associations,
438
+ # but wait to be notified by associated collections
439
+ # of changes we make to them. This ensures that
440
+ # if changes are made to those collections that
441
+ # have not gone through this method, that everything
442
+ # is still in sync.
443
+ def model.set_association(assoc, value, append = true)
444
+ if assoc.belongs_to?
445
+ prior = send(assoc.local_id_field)
446
+ if prior
447
+ raise RuntimeError, "#{self} belongs to another #{assoc.foreign_model_class_name}"
448
+ end
449
+ validate_foreign_class(assoc, value)
450
+ # Set the local id to the foreign id
451
+ send(Util.setter(assoc.local_id_field), value.id)
452
+ else
453
+ prior = get_association(assoc)
454
+ if assoc.has_one?
455
+ set_one(assoc, value, prior, append)
456
+ elsif assoc.has_many?
457
+ set_many(assoc, value, prior, append)
458
+ else
459
+ raise RuntimeError, "set_association cannot handle #{assoc.inspect}"
460
+ end
461
+ end
462
+ end
463
+ model.singleton_class.send(:private, :set_association)
464
+
465
+ # private
466
+ def model.set_one(assoc, other, prior, append = true)
467
+ fail_if_read_only(__method__)
468
+ validate_foreign_class(assoc, other)
469
+ # the prior is no longer required
470
+ prior.mark_for_destruction! if prior
471
+ # Set the foreign_id of the new_value to this model's id.
472
+ set_foreign_id(assoc, other)
473
+ # Add to cache if not already there, which will raise an exception
474
+ # if the new_Value is not new or is not the appropriate class.
475
+ assoc.foreign_collection.append(other, error_if_present: false) if append
476
+ other
477
+ end
478
+ model.singleton_class.send(:private, :set_one)
479
+
480
+ # private
481
+ def model.set_many(assoc, new_values, prior_values, append = true)
482
+ fail_if_read_only(__method__)
483
+ unless new_values.respond_to?(:to_a)
484
+ raise RuntimeError, "value for setting has_many #{assoc.foreign_name} must respond to :to_a"
485
+ end
486
+ new_values = new_values.to_a
487
+ # set foreign_id of all new values to this model's id
488
+ new_values.each do |model|
489
+ set_foreign_id(assoc, model)
490
+ end
491
+ if prior_values
492
+ # destroy any prior values not in new values
493
+ prior_values.each do |p|
494
+ unless new_values.detect {|n| p.id == n.id}
495
+ p.mark_for_destruction!
496
+ end
497
+ end
498
+ end
499
+ # add any new values - #add_to_many
500
+ # handle case where new value is in
501
+ # prior values
502
+ new_values.each do |new_value|
503
+ add_to_many(new_value, append)
504
+ end
505
+ end
506
+ model.singleton_class.send(:private, :set_many)
507
+
508
+ # private
509
+ # Add model to has_many association if not already there.
510
+ # Will raise an exception if the new association
511
+ # is not new or is not the appropriate class.
512
+ def model.add_to_many(assoc, other, append)
513
+ fail_if_read_only(__method__)
514
+ set_foreign_id(assoc, other)
515
+ assoc.foreign_collection.append(other, error_if_present: false) if append
516
+ end
517
+ model.singleton_class.send(:private, :add_to_many)
518
+
519
+ # private
520
+ # Mark the given associated model for destruction if
521
+ # it's owner id equals this model's id. Return the
522
+ # the marked model. Raises exception if the given
523
+ # associated model does not belongs to this model.
524
+ def model.remove_from_many(assoc, other)
525
+ validate_ownership(assoc, other)
526
+ other.mark_for_destruction!
527
+ end
528
+ model.singleton_class.send(:private, :remove_from_many)
529
+
530
+ # private
531
+ # Sets the appropriate foreign_id of the other model
532
+ # to this model's id. Raises an exception if the
533
+ # foreign_id is already set and not this model's
534
+ # (i.e. if the associate belongs to another model).
535
+ def model.set_foreign_id(assoc, other)
536
+ fail_if_read_only(__method__)
537
+ validate_ownership(assoc, other, require_foreign_id: false) do |prior_foreign_id|
538
+ # after validation we can be sure prior_foreign_id == self.id
539
+ # debug __method__, __LINE__
540
+ unless prior_foreign_id
541
+ other.send(Util.setter(assoc.foreign_id_field), id)
542
+ end
543
+ # debug __method__, __LINE__
544
+ end
545
+ end
546
+ model.singleton_class.send(:private, :set_foreign_id)
547
+
548
+ # private
549
+ # An owner id in the model has been set to a new value
550
+ # for the given belongs_to association. Find the value in the
551
+ # association's foreign_collection for the new owner
552
+ # id. If not found raise an exception. Then use
553
+ # set_association method to do the rest, including notification
554
+ # of owner/reciprocal association.
555
+ def model.trapped_set_owner_id(assoc, new_owner_id)
556
+ fail_if_read_only(__method__)
557
+ new_value = assoc.foreign_collection.detect do |e|
558
+ e.id == new_owner_id
559
+ end
560
+ unless new_value
561
+ raise RuntimeError, "no model found in foreign collection #{assoc.foreign_collection_name} for #{assoc.local_id} { #{new_owner_id}"
562
+ end
563
+ set_association(assoc, new_value)
564
+ end
565
+ model.singleton_class.send(:private, :trapped_set_owner_id)
566
+
567
+ # private
568
+ # Validate that the appropriate foreign_id in the associate
569
+ # matches this model's id. If the associate's foreign_id is
570
+ # nil (not yet set) raise an error if require_foreign_id is
571
+ # true. If the associate's foreign_id is set, raise an error
572
+ # if it does not match this model's id. Otherwise return true
573
+ # if the foreign id is not nil. Yield to given block if provided.
574
+ def model.validate_ownership(assoc, other, require_foreign_id: true, &block)
575
+ # debug __method__, __LINE__
576
+ foreign_id = other.send(assoc.foreign_id_field)
577
+ # debug __method__, __LINE__
578
+ if (foreign_id && foreign_id != self.id) || (require_foreign_id && foreign_id.nil?)
579
+ raise RuntimeError, "#{other} should belong to #{self} or no-one else"
580
+ end
581
+ yield(foreign_id) if block
582
+ end
583
+ model.singleton_class.send(:private, :validate_ownership)
584
+
585
+ # private
586
+ def model.validate_foreign_class(assoc, other)
587
+ unless other.is_a?(assoc.foreign_model_class)
588
+ raise RuntimeError, "#{self.class.name}##{assoc.foreign_name}= must be a #{assoc.foreign_model_class_name}"
589
+ end
590
+ end
591
+ model.singleton_class.send(:private, :validate_foreign_class)
592
+
593
+ # private
594
+ def model.validate_patched_for_cache(model_or_array)
595
+ Util.arrify(model_or_array).each do |other|
596
+ unless other.respond_to?(:patched_for_cache?)
597
+ raise RuntimeError, "#{other} must be loaded into or created via cache"
598
+ end
599
+ end
600
+ end
601
+ model.singleton_class.send(:private, :validate_patched_for_cache)
602
+
603
+ # private
604
+ # Returns whether the id in the foreign_id_field
605
+ # matches the given local id. If the target is
606
+ # an array then we check whether it's first element
607
+ # matches (or if it's empty assume true?).
608
+ def model.match?(other, foreign_id_field, local_id)
609
+ target = other.respond_to?(:to_a) ? other.first : other
610
+ target ? target.send(foreign_id_field) == local_id : true
611
+ end
612
+ model.singleton_class.send(:private, :match?)
613
+
614
+ # private
615
+ # Calls flush on each has_one and has_many association.
616
+ # Returns a single promise which collates all promises
617
+ # from flushing associates.
618
+ def model.flush_associations
619
+ promises = []
620
+ @__cache__collection.associations.values.each do |assoc|
621
+ if assoc.has_any?
622
+ # debug __method__, __LINE__, "association => '#{association}'"
623
+ model_or_array = send(assoc.foreign_name)
624
+ # debug __method__, __LINE__, "model_or_array => '#{model_or_array}'"
625
+ Util.arrify(model_or_array).each do |model|
626
+ promises << model.flush!
627
+ end
628
+ # debug __method__, __LINE__
629
+ end
630
+ end
631
+ Promise.when(*promises)
632
+ end
633
+ model.singleton_class.send(:private, :flush_associations)
634
+
635
+ # private
636
+ # Marks all has_one or has_many models for destruction
637
+ def model.mark_associations_for_destruction
638
+ fail_if_read_only(__method__)
639
+ @__cache__collection.associations.values.each do |assoc|
640
+ if assoc.has_any?
641
+ # debug __method__, __LINE__, "association => '#{association}'"
642
+ model_or_array = send(assoc.foreign_name)
643
+ if model_or_array
644
+ # debug __method__, __LINE__, "model_or_array => '#{model_or_array}'"
645
+ Util.arrify(model_or_array).each do |model|
646
+ model.mark_for_destruction!
647
+ end
648
+ # debug __method__, __LINE__
649
+ end
650
+ end
651
+ end
652
+ end
653
+ model.singleton_class.send(:private, :mark_associations_for_destruction)
654
+
655
+ def model.debug(method, line, msg = nil)
656
+ s = ">>> #{self.class.name}##{method}[#{line}] : #{msg}"
657
+ if RUBY_PLATFORM == 'opal'
658
+ Volt.logger.debug s
659
+ else
660
+ puts s
661
+ end
662
+ end
663
+ model.singleton_class.send(:private, :debug)
664
+
665
+ end
666
+
667
+ end
668
+ end
669
+ end
670
+
671
+