volt-repo_cache 0.1.4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +19 -0
- data/.rspec +2 -0
- data/Gemfile +12 -0
- data/LICENSE.txt +22 -0
- data/README.md +314 -0
- data/Rakefile +1 -0
- data/lib/volt/repo_cache.rb +6 -0
- data/lib/volt/repo_cache/association.rb +100 -0
- data/lib/volt/repo_cache/cache.rb +116 -0
- data/lib/volt/repo_cache/collection.rb +259 -0
- data/lib/volt/repo_cache/model.rb +671 -0
- data/lib/volt/repo_cache/model_array.rb +169 -0
- data/lib/volt/repo_cache/util.rb +78 -0
- data/lib/volt/repo_cache/version.rb +5 -0
- data/spec/dummy/.gitignore +9 -0
- data/spec/dummy/README.md +4 -0
- data/spec/dummy/app/main/assets/css/app.css.scss +1 -0
- data/spec/dummy/app/main/config/dependencies.rb +11 -0
- data/spec/dummy/app/main/config/initializers/boot.rb +10 -0
- data/spec/dummy/app/main/config/routes.rb +14 -0
- data/spec/dummy/app/main/controllers/main_controller.rb +27 -0
- data/spec/dummy/app/main/models/customer.rb +4 -0
- data/spec/dummy/app/main/models/order.rb +6 -0
- data/spec/dummy/app/main/models/product.rb +5 -0
- data/spec/dummy/app/main/models/user.rb +12 -0
- data/spec/dummy/app/main/views/main/about.html +7 -0
- data/spec/dummy/app/main/views/main/index.html +6 -0
- data/spec/dummy/app/main/views/main/main.html +29 -0
- data/spec/dummy/config.ru +4 -0
- data/spec/dummy/config/app.rb +147 -0
- data/spec/dummy/config/base/index.html +15 -0
- data/spec/dummy/config/initializers/boot.rb +4 -0
- data/spec/integration/sample_integration_spec.rb +11 -0
- data/spec/sample_spec.rb +7 -0
- data/spec/spec_helper.rb +18 -0
- data/volt-repo_cache.gemspec +38 -0
- 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
|
+
|