volt-repo_cache 0.1.4

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.
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
+