arid_cache 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,15 @@
1
+ require 'arid_cache/cache_proxy/utilities'
2
+ require 'arid_cache/cache_proxy/options'
3
+ require 'arid_cache/cache_proxy/result_processor'
4
+
1
5
  module AridCache
2
6
  class CacheProxy
3
- attr_accessor :object, :key, :opts, :blueprint, :cached, :cache_key, :block, :records, :combined_options, :klass
4
7
 
5
- # AridCache::CacheProxy::Result
8
+ # AridCache::CacheProxy::CachedResult
6
9
  #
7
- # This struct is stored in the cache and stores information we need
8
- # to re-query for results.
9
- Result = Struct.new(:ids, :klass, :count) do
10
-
10
+ # This struct is stored in the cache and stores information about a
11
+ # collection of ActiveRecords.
12
+ CachedResult = Struct.new(:ids, :klass, :count) do
11
13
  def has_count?
12
14
  !count.nil?
13
15
  end
@@ -25,6 +27,12 @@ module AridCache
25
27
  end
26
28
  end
27
29
 
30
+ OPTIONS_FOR_PAGINATE = [:page, :per_page, :total_entries, :finder]
31
+ OPTIONS_FOR_CACHE_PROXY = [:raw, :clear]
32
+ OPTIONS_FOR_FIND = [ :conditions, :include, :joins, :limit, :offset, :order, :select, :readonly, :group, :having, :from, :lock ]
33
+ OPTIONS_FOR_CACHE = [ :expires_in ]
34
+ OPTIONS_FOR_CACHE_KEY = [ :auto_expire ]
35
+
28
36
  #
29
37
  # Managing your caches
30
38
  #
@@ -34,26 +42,36 @@ module AridCache
34
42
  end
35
43
 
36
44
  def self.clear_class_caches(object)
37
- key = (object.is_a?(Class) ? object : object.class).name.downcase + '-'
45
+ key = (Utilities.object_class(object)).name.downcase + '-'
38
46
  Rails.cache.delete_matched(%r[arid-cache-#{key}.*])
39
47
  end
40
48
 
41
49
  def self.clear_instance_caches(object)
42
- key = AridCache::Inflector.pluralize((object.is_a?(Class) ? object : object.class).name).downcase
50
+ key = AridCache::Inflector.pluralize((Utilities.object_class(object)).name).downcase
43
51
  Rails.cache.delete_matched(%r[arid-cache-#{key}.*])
44
52
  end
45
53
 
46
- def initialize(object, key, opts={}, &block)
47
- self.object = object
48
- self.key = key
49
- self.opts = opts.symbolize_keys
50
- self.blueprint = AridCache.store.find(object, key)
51
- self.block = block
52
- self.records = nil
54
+ # Clear the cached result for this cache only
55
+ def clear_cached
56
+ Rails.cache.delete(@cache_key, @options.opts_for_cache)
57
+ end
58
+
59
+ #
60
+ # Initialize
61
+ #
53
62
 
54
- # The options from the blueprint merged with the options for this call
55
- self.combined_options = self.blueprint.nil? ? self.opts : self.blueprint.opts.merge(self.opts)
56
- self.cache_key = object.arid_cache_key(key, opts_for_cache_key)
63
+ def initialize(receiver, method, opts={}, &block)
64
+ @receiver = receiver
65
+ @method = method
66
+ @block = block
67
+ @blueprint = AridCache.store.find(@receiver, @method)
68
+
69
+ # Combine the options from the blueprint with the options for this call
70
+ opts = opts.symbolize_keys
71
+ @options = Options.new(@blueprint.nil? ? opts : @blueprint.opts.merge(opts))
72
+ @options[:receiver] = receiver
73
+ @cache_key = @receiver.arid_cache_key(@method, @options.opts_for_cache_key)
74
+ @cached = Rails.cache.read(@cache_key, @options.opts_for_cache)
57
75
  end
58
76
 
59
77
  #
@@ -61,308 +79,49 @@ module AridCache
61
79
  #
62
80
 
63
81
  # Return a count of ids in the cache, or return whatever is in the cache if it is
64
- # not a CacheProxy::Result
82
+ # not a CacheProxy::CachedResult
65
83
  def fetch_count
66
- if refresh_cache?
67
- execute_count
68
- elsif cached.is_a?(AridCache::CacheProxy::Result)
69
- cached.has_count? ? cached.count : execute_count
70
- elsif cached.is_a?(Fixnum)
71
- cached
72
- elsif cached.respond_to?(:count)
73
- cached.count
74
- else
75
- cached # what else can we do? return it
76
- end
84
+ @options[:count_only] = true
85
+ result_processor.to_result
77
86
  end
78
87
 
79
88
  # Return a list of records using the options provided. If the item in the cache
80
- # is not a CacheProxy::Result it is returned as-is. If there is nothing in the cache
89
+ # is not a CacheProxy::CachedResult it is returned after applying options. If there is nothing in the cache
81
90
  # the block defining the cache is exectued. If the :raw option is true, returns the
82
- # CacheProxy::Result unmodified, ignoring other options, except where those options
83
- # are used to initialize the cache.
91
+ # CacheProxy::CachedResult unmodified, ignoring other options, except where those options
92
+ # are needed to initialize the cache.
84
93
  def fetch
85
- @raw_result = opts_for_cache_proxy[:raw] == true
86
-
87
- result = if refresh_cache?
88
- execute_find(@raw_result)
89
- elsif cached.is_a?(AridCache::CacheProxy::Result)
90
- if cached.has_ids? && @raw_result
91
- self.cached # return it unmodified
92
- elsif cached.has_ids?
93
- fetch_from_cache # return a list of active records after applying options
94
- else # true if we have only calculated the count thus far
95
- execute_find(@raw_result)
96
- end
97
- else
98
- cached # some base type, return it unmodified
99
- end
100
- end
101
-
102
- # Clear the cached result for this cache only
103
- def clear_cached
104
- Rails.cache.delete(self.cache_key, opts_for_cache)
105
- end
106
-
107
- # Return the cached result for this object's key
108
- def cached
109
- @cached ||= Rails.cache.read(self.cache_key, opts_for_cache)
94
+ result_processor.to_result
110
95
  end
111
96
 
112
- # Return the class of the cached results i.e. if the cached result is a
113
- # list of Album records, then klass returns Album. If there is nothing
114
- # in the cache, then the class is inferred to be the class of the object
115
- # that the cached method is being called on.
116
- def klass
117
- @klass ||= if self.cached && self.cached.is_a?(AridCache::CacheProxy::Result)
118
- self.cached.klass
119
- else
120
- object_base_class
121
- end
122
- end
123
-
124
-
125
97
  private
126
98
 
127
- # Return a list of records from the database using the ids from
128
- # the cached Result.
129
- #
130
- # The result is paginated if the :page option is preset, otherwise
131
- # a regular list of ActiveRecord results is returned.
132
- #
133
- # If no :order is specified, the current ordering of the ids is
134
- # preserved with some fancy SQL.
135
- def fetch_from_cache
136
- if paginate?
137
-
138
- # Return a paginated collection
139
- if cached.ids.empty?
140
-
141
- # No ids, return an empty WillPaginate result
142
- [].paginate(opts_for_paginate)
143
-
144
- elsif combined_options.include?(:order)
145
-
146
- # An order has been specified. We have to go to the database
147
- # and paginate there because the contents of the requested
148
- # page will be different.
149
- klass.paginate(cached.ids, { :total_entries => cached.ids.size }.merge(opts_for_find.merge(opts_for_paginate)))
150
-
151
- else
152
-
153
- # Order is unchanged. We can paginate in memory and only select
154
- # those ids that we actually need. This is the most efficient.
155
- paged_ids = cached.ids.paginate(opts_for_paginate)
156
- paged_ids.replace(klass.find_all_by_id(paged_ids, opts_for_find(paged_ids)))
157
-
158
- end
159
-
160
- elsif cached.ids.empty?
161
-
162
- # We are returning a regular (non-paginated) result.
163
- # If we don't have any ids, that's an empty result
164
- # in any language.
165
- []
166
-
167
- elsif combined_options.include?(:order)
168
-
169
- # An order has been specified, so use it.
170
- klass.find_all_by_id(cached.ids, opts_for_find)
171
-
172
- else
173
-
174
- # No order has been specified, so we have to maintain
175
- # the current order. We do this by passing some extra
176
- # SQL which orders by the current array ordering.
177
- offset, limit = combined_options.delete(:offset) || 0, combined_options.delete(:limit) || cached.count
178
- ids = cached.ids[offset, limit]
179
-
180
- klass.find_all_by_id(ids, opts_for_find(ids))
181
- end
99
+ # Return a ResultProcessor instance. Seed the cache if we need to, otherwise
100
+ # use what is in the cache.
101
+ def result_processor
102
+ seed_cache? ? seed_cache : ResultProcessor.new(@cached, @options)
182
103
  end
183
104
 
184
- def paginate?
185
- combined_options.include?(:page)
105
+ # Return a boolean indicating whether we need to seed the cache. Seed the cache
106
+ # if :force => true, the cache is empty or records have been requested and there
107
+ # are none in the cache yet.
108
+ def seed_cache?
109
+ @cached.nil? || @options.force? || (@cached.is_a?(CachedResult) && !@options.count_only? && !@cached.has_ids?)
186
110
  end
187
111
 
188
- def refresh_cache?
189
- cached.nil? || opts[:force]
190
- end
191
-
192
- def get_records
193
- block = self.block || (blueprint && blueprint.proc)
194
- self.records = block.nil? ? object.instance_eval(key) : object.instance_eval(&block)
195
- end
196
-
197
- # Seed the cache by executing the stored block (or by calling a method on the object).
198
- # Then apply any options like pagination or ordering before returning the result, which
199
- # is either some base type, or usually, a list of active records.
200
- #
201
- # Options:
202
- # raw - if true, return the CacheProxy::Result after seeding the cache, ignoring
203
- # other options. Default is false.
204
- def execute_find(raw = false)
205
- get_records
206
- cached = AridCache::CacheProxy::Result.new
207
-
208
- if !records.is_a?(Enumerable) || (!records.empty? && !records.first.is_a?(::ActiveRecord::Base))
209
- cached = records # some base type, cache it as itself
210
- else
211
- cached.ids = records.collect(&:id)
212
- cached.count = records.size
213
- if records.respond_to?(:proxy_reflection) # association proxy
214
- cached.klass = records.proxy_reflection.klass
215
- elsif !records.empty?
216
- cached.klass = records.first.class
217
- else
218
- cached.klass = object_base_class
219
- end
220
- end
221
- Rails.cache.write(cache_key, cached, opts_for_cache)
222
- self.cached = cached
223
-
224
- # Return the raw result?
225
- return self.cached if raw
226
-
227
- # An order has been specified. We have to go to the database
228
- # to order because we can't be sure that the current order is the same as the cache.
229
- if cached.is_a?(AridCache::CacheProxy::Result) && combined_options.include?(:order)
230
- self.klass = self.cached.klass # TODO used by fetch_from_cache needs refactor
231
- fetch_from_cache
232
- else
233
- process_result_in_memory(records)
234
- end
112
+ # Seed the cache by executing the stored block (or by calling a method on the object)
113
+ # and storing the result in the cache. Return the processed result ready to return
114
+ # to the user.
115
+ def seed_cache
116
+ block = @block || (@blueprint && @blueprint.proc)
117
+ block_result = block.nil? ? @receiver.instance_eval(@method) : @receiver.instance_eval(&block)
118
+ @result = ResultProcessor.new(block_result, @options)
119
+ write_cache(@result.to_cache)
120
+ @result
235
121
  end
236
122
 
237
- # Convert records to an array before calling paginate. If we don't do this
238
- # and the result is a named scope, paginate will trigger an additional query
239
- # to load the page rather than just using the records we have already fetched.
240
- #
241
- # If we are not paginating and the options include :limit (and optionally :offset)
242
- # apply the limit and offset to the records before returning them.
243
- #
244
- # Otherwise we have an issue where all the records are returned the first time
245
- # the collection is loaded, but on subsequent calls the options_for_find are
246
- # included and you get different results. Note that with options like :order
247
- # this cannot be helped. We don't want to modify the query that generates the
248
- # collection because the idea is to allow getting different perspectives of the
249
- # cached collection without relying on modifying the collection as a whole.
250
- #
251
- # If you do want a specialized, modified, or subset of the collection it's best
252
- # to define it in a block and have a new cache for it:
253
- #
254
- # model.my_special_collection { the_collection(:order => 'new order', :limit => 10) }
255
- def process_result_in_memory(records)
256
- if opts.include?(:page)
257
- records = records.respond_to?(:to_a) ? records.to_a : records
258
- records.respond_to?(:paginate) ? records.paginate(opts_for_paginate) : records
259
- elsif opts.include?(:limit)
260
- records = records.respond_to?(:to_a) ? records.to_a : records
261
- offset = opts[:offset] || 0
262
- records[offset, opts[:limit]]
263
- else
264
- records
265
- end
266
- end
267
-
268
- def execute_count
269
- get_records
270
- cached = AridCache::CacheProxy::Result.new
271
-
272
- # Just get the count if we can.
273
- #
274
- # Because of how AssociationProxy works, if we even look at it, it'll
275
- # trigger the query. So don't look.
276
- #
277
- # Association proxy or named scope. Check for an association first, because
278
- # it doesn't trigger the select if it's actually named scope. Calling respond_to?
279
- # on an association proxy will hower trigger a select because it loads up the target
280
- # and passes the respond_to? on to it.
281
- if records.respond_to?(:proxy_reflection) || records.respond_to?(:proxy_options)
282
- cached.count = records.count # just get the count
283
- cached.klass = object_base_class
284
- elsif records.is_a?(Enumerable) && (records.empty? || records.first.is_a?(::ActiveRecord::Base))
285
- cached.ids = records.collect(&:id) # get everything now that we have it
286
- cached.count = records.size
287
- cached.klass = records.empty? ? object_base_class : records.first.class
288
- else
289
- cached = records # some base type, cache it as itself
290
- end
291
-
292
- Rails.cache.write(cache_key, cached, opts_for_cache)
293
- self.cached = cached
294
- cached.respond_to?(:count) ? cached.count : cached
295
- end
296
-
297
- OPTIONS_FOR_PAGINATE = [:page, :per_page, :total_entries, :finder]
298
-
299
- # Filter options for paginate, if *klass* is set, we get the :per_page value from it.
300
- def opts_for_paginate
301
- paginate_opts = combined_options.reject { |k,v| !OPTIONS_FOR_PAGINATE.include?(k) }
302
- paginate_opts[:finder] = :find_all_by_id unless paginate_opts.include?(:finder)
303
- paginate_opts[:per_page] = klass.per_page if klass && !paginate_opts.include?(:per_page)
304
- paginate_opts
305
- end
306
-
307
- OPTIONS_FOR_FIND = [ :conditions, :include, :joins, :limit, :offset, :order, :select, :readonly, :group, :having, :from, :lock ]
308
-
309
- # Preserve the original order of the results if no :order option is specified.
310
- #
311
- # @arg ids array of ids to order by unless an :order option is specified. If not
312
- # specified, cached.ids is used.
313
- def opts_for_find(ids=nil)
314
- ids ||= cached.ids
315
- find_opts = combined_options.reject { |k,v| !OPTIONS_FOR_FIND.include?(k) }
316
- find_opts[:order] = preserve_order(ids) unless find_opts.include?(:order)
317
- find_opts
318
- end
319
-
320
- OPTIONS_FOR_CACHE = [ :expires_in ]
321
-
322
- def opts_for_cache
323
- combined_options.reject { |k,v| !OPTIONS_FOR_CACHE.include?(k) }
324
- end
325
-
326
- OPTIONS_FOR_CACHE_KEY = [ :auto_expire ]
327
-
328
- def opts_for_cache_key
329
- combined_options.reject { |k,v| !OPTIONS_FOR_CACHE_KEY.include?(k) }
330
- end
331
-
332
- OPTIONS_FOR_CACHE_PROXY = [:raw, :clear]
333
-
334
- # Returns options that affect the cache proxy result
335
- def opts_for_cache_proxy
336
- combined_options.reject { |k,v| !OPTIONS_FOR_CACHE_PROXY.include?(k) }
337
- end
338
-
339
- def object_base_class #:nodoc:
340
- object.is_a?(Class) ? object : object.class
341
- end
342
-
343
- # Generate an ORDER BY clause that preserves the ordering of the ids in *ids*.
344
- #
345
- # The method we use depends on the database adapter because only MySQL
346
- # supports the ORDER BY FIELD() function. For other databases we use
347
- # a CASE statement.
348
- #
349
- # TODO: is it quicker to sort in memory?
350
- def preserve_order(ids)
351
- column = if self.klass.respond_to?(:table_name)
352
- ::ActiveRecord::Base.connection.quote_table_name(self.klass.table_name) + '.id'
353
- else
354
- "id"
355
- end
356
-
357
- if ids.empty?
358
- nil
359
- elsif ::ActiveRecord::Base.is_mysql_adapter?
360
- "FIELD(#{column},#{ids.join(',')})"
361
- else
362
- order = ''
363
- ids.each_index { |i| order << "WHEN #{column}=#{ids[i]} THEN #{i+1} " }
364
- "CASE " + order + " END"
365
- end
123
+ def write_cache(data)
124
+ Rails.cache.write(@cache_key, data, @options.opts_for_cache)
366
125
  end
367
126
  end
368
127
  end
data/lib/arid_cache.rb CHANGED
@@ -10,7 +10,7 @@ require 'arid_cache/inflector'
10
10
 
11
11
  module AridCache
12
12
  extend AridCache::Helpers
13
- class Error < StandardError; end #:nodoc:
13
+ Error = Class.new(StandardError) #:nodoc:
14
14
 
15
15
  def self.cache
16
16
  AridCache::CacheProxy
@@ -1,9 +1,9 @@
1
1
  require 'spec_helper'
2
2
 
3
- describe AridCache::CacheProxy::Result do
3
+ describe AridCache::CacheProxy::CachedResult do
4
4
  before :each do
5
5
  class X; end
6
- @result = AridCache::CacheProxy::Result.new
6
+ @result = AridCache::CacheProxy::CachedResult.new
7
7
  end
8
8
 
9
9
  it "should set the klass from a class" do
@@ -0,0 +1,80 @@
1
+ require 'spec_helper'
2
+
3
+ describe AridCache::CacheProxy::Options do
4
+ def new_options(opts={})
5
+ AridCache::CacheProxy::Options.new(opts)
6
+ end
7
+
8
+ describe "defaults" do
9
+ before :each do
10
+ @opt = new_options
11
+ end
12
+
13
+ it "should have default" do
14
+ @opt.force?.should be_false
15
+ @opt.paginate?.should be_false
16
+ @opt.raw?.should be_false
17
+ @opt.count_only?.should be_false
18
+ @opt.order_by_proc?.should be_false
19
+ @opt.order_by_key?.should be_false
20
+ end
21
+ end
22
+
23
+ it "force?" do
24
+ new_options(:force => true).force?.should be_true
25
+ end
26
+
27
+ it "paginate?" do
28
+ new_options(:page => 1).paginate?.should be_true
29
+ new_options(:per_page => 1).paginate?.should be_false
30
+ new_options(:page => 1, :per_page => 1).paginate?.should be_true
31
+ end
32
+
33
+ it "raw?" do
34
+ new_options(:raw => true).raw?.should be_true
35
+ end
36
+
37
+ it "count_only?" do
38
+ new_options(:count_only => true).count_only?.should be_true
39
+ end
40
+
41
+ it "order options" do
42
+ @opt = new_options(:order => 'key')
43
+ @opt.order_by_key?.should be_true
44
+ @opt.order_by_proc?.should be_false
45
+ @opt = new_options(:order => :symbol)
46
+ @opt.order_by_key?.should be_true
47
+ @opt.order_by_proc?.should be_false
48
+ @opt = new_options(:order => Proc.new {})
49
+ @opt.order_by_key?.should be_false
50
+ @opt.order_by_proc?.should be_true
51
+ end
52
+
53
+ describe "options for paginate" do
54
+ before :each do
55
+ @result_klass = Class.new do
56
+ def self.per_page; 23; end
57
+ end
58
+ end
59
+
60
+ it "should get per_page from the result_klass" do
61
+ @opts = new_options(:result_klass => @result_klass).opts_for_paginate
62
+ @opts[:per_page].should == 23
63
+ end
64
+
65
+ it "should use the provided per_page value" do
66
+ @opts = new_options(:result_klass => @result_klass, :per_page => 3).opts_for_paginate
67
+ @opts[:per_page].should == 3
68
+ end
69
+
70
+ it "should set total_entries" do
71
+ new_options.opts_for_paginate[:total_entries].should be_nil
72
+ @opts = new_options.opts_for_paginate((1..10).to_a)
73
+ @opts[:total_entries].should == 10
74
+ end
75
+
76
+ it "should use find_all_by_id as the finder" do
77
+ new_options.opts_for_paginate[:finder].should == :find_all_by_id
78
+ end
79
+ end
80
+ end