arid_cache 1.2.0 → 1.3.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.rdoc CHANGED
@@ -1,13 +1,14 @@
1
1
  = AridCache
2
2
 
3
- AridCache makes caching easy and effective. AridCache supports caching on all of your ActiveRecord model named scopes, class and instance methods right out of the box. AridCache keeps caching logic out of your model methods and clarifies your view code by making calls to cached result sets explicit.
3
+ AridCache makes caching easy and effective. AridCache supports caching on all ActiveRecord class and instance methods right out of the box. AridCache keeps caching logic out of your model methods and clarifies your code by making calls to cached result sets explicit.
4
4
 
5
- AridCache supports caching large, expensive ActiveRecord collections by caching only the model IDs, provides efficient in-memory pagination of your cached collections, and gives you collection counts for free. Non-ActiveRecord collection data is cached unchanged allowing you to cache the results of any expensive operation simply by prepending your method call with <tt>cached_</tt>.
5
+ AridCache supports caching large, expensive ActiveRecord collections by caching only the model IDs, provides efficient in-memory pagination of your cached collections, and gives you collection counts for free. Non-ActiveRecord collection data is cached unchanged allowing you to cache the results of anything simply by prepending your method call with <tt>cached_</tt>.
6
6
 
7
- AridCache simplifies caching by supporting auto-expiring cache keys - as well as common options like <tt>:expires_in</tt> - and provides methods to help you manage your caches at the global, model class, model instance and per-cache level.
7
+ AridCache simplifies caching by supporting auto-expiring cache keys - as well as common options like <tt>:expires_in</tt> - and provides methods to help you manage your caches.
8
8
 
9
9
  == Changes
10
10
 
11
+ v1.3.0: Support limits, ordering and pagination on cached Enumerables
11
12
  v1.2.0: Fix Rails 3 ActiveRecord hooks & remove some Rails dependencies
12
13
  v1.0.5: Support <tt>:raw</tt> and <tt>:clear</tt> options.
13
14
 
@@ -33,11 +34,27 @@ Then
33
34
 
34
35
  rake gems:install
35
36
 
37
+ == Features
38
+
39
+ * Include the AridCache module in any Class
40
+ * Rails 2 & 3 compatible with automatic ActiveRecord::Base integration
41
+ * Supports auto-expiring cache keys
42
+ * Supports limits, ordering & pagination of cached Enumerables and ActiveRecord collections
43
+ * Define caches and their options on your class using +instance_caches+ and +class_caches+
44
+ * Counts for free - if you have already cached the result, you get the count for free
45
+ * Supports eager-loading and other options to <tt>ActiveRecord::Base#find</tt> like
46
+ :conditions, :include, :joins, :select, :readonly, :group, :having, :from
47
+ * Provides methods to clear caches individually, at the instance-level, class-level and globally
48
+ * Preserves the order of your cached ActiveRecord collections
49
+ * Optimized to make as few cache and database accesses as absolutely neccessary
50
+
36
51
  == Introduction
37
52
 
38
53
  The name AridCache comes from <b>A</b>ctive<b>R</b>ecord *ID* Cache. It's also very DRY...get it? :)
39
54
 
40
- Out of the box AridCache supports caching on all your ActiveRecord class and instance methods and named scopes...basically if a class or class instance <tt>respond_to?</tt> something, you can cache it.
55
+ Out of the box AridCache supports caching on all your ActiveRecord class and instance methods and named scopes. If a class or class instance <tt>respond_to?</tt> something, you can cache it.
56
+
57
+ AridCache supports limits, pagination and ordering options on cached ActiveRecord collections and any other Enumerable. Options to apply limits are <tt>:limit</tt> and <tt>:offset</tt>. Options for pagination are <tt>:page</tt> and <tt>:per_page</tt> and the <tt>:order</tt> option accepts the same values as ActiveRecord::Base#find. If the cached value is an ActiveRecord collection most other options to ActiveRecord::Base#find are supported too. If the cached value is an Enumerable (e.g. an Array) the value for :order must be a <tt>Proc</tt>. The Proc is passed to Enumerable#sort to do the sorting. Unless the Enumerable is a list of Hashes in which case it can be a String or Symbol giving the hash key to sort by.
41
58
 
42
59
  The way you interact with the cache via your model methods is to prepend the method call with <tt>cached_</tt>. The part of the method call after <tt>cached_</tt> serves as the basis for the cache key. For example,
43
60
 
@@ -46,7 +63,6 @@ The way you interact with the cache via your model methods is to prepend the met
46
63
 
47
64
  You can also define caches that use compositions of methods or named scopes, or other complex queries, without having to add a new method to your class. This way you can also create different caches that all use the same method. For example,
48
65
 
49
- # cache key is arid-cache-user-most_active_users
50
66
  User.cached_most_active_users do
51
67
  active.find(:order => 'activity DESC', :limit => 5)
52
68
  end
@@ -55,17 +71,19 @@ You can also define caches that use compositions of methods or named scopes, or
55
71
 
56
72
  If the result of your <tt>cached_</tt> call is an array of ActiveRecords, AridCache only stores the IDs in the cache (because it's a bad idea to store records in the cache).
57
73
 
58
- On subsequent calls we call <tt>find_all_by_id</tt> on the target class passing in the ActiveRecord IDs that were stored in the cache. AridCache will preserve the original ordering of your collection (you can change this using the <tt>:order</tt>).
74
+ On subsequent calls we call <tt>find_all_by_id</tt> on the target class passing in the ActiveRecord IDs that were stored in the cache. AridCache will preserve the original ordering of your collection (you can change this using the <tt>:order</tt> option).
59
75
 
60
76
  The idea here is to cache collections that are expensive to query. Once the cache is loaded, retrieving the cached records from the database simply involves a <tt>SELECT * FROM table WHERE id IN (ids, ...)</tt>.
61
77
 
62
78
  Consider how long it would take to get the top 10 favorited tracks of all time from a database with a million tracks and 100,000 users. Now compare that to selecting 10 tracks by ID from the track table. The performance gain is huge.
63
79
 
64
- === Base Types and Other Collections
80
+ === Enumerables
81
+
82
+ Cached enumerables support find-like options such as <tt>:limit</tt>, <tt>:offset</tt> and <tt>order</tt> as well as pagination options <tt>:page</tt> and <tt>:per_page</tt>. <tt>:order</tt> must be a <tt>Proc</tt>. It is passed to Enumerable#sort to do the sorting. Unless the enumerable contains hashes in which case <tt>:order</tt> can be a string or symbol hash key to order by.
65
83
 
66
- Arrays of non-ActiveRecords are stored as-is so you can cache arrays of strings and other types without problems.
84
+ === Base Types and Other Collections
67
85
 
68
- Any other objects (including single ActiveRecord objects) are cached and returned as-is.
86
+ Anything that is not an array of ActiveRecords is cached as-is. Numbers, arrays, hashes, nils, whatever.
69
87
 
70
88
  === Example
71
89
 
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.2.0
1
+ 1.3.0
data/arid_cache.gemspec CHANGED
@@ -5,11 +5,11 @@
5
5
 
6
6
  Gem::Specification.new do |s|
7
7
  s.name = %q{arid_cache}
8
- s.version = "1.2.0"
8
+ s.version = "1.3.0"
9
9
 
10
10
  s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
11
  s.authors = ["Karl Varga"]
12
- s.date = %q{2011-03-28}
12
+ s.date = %q{2011-04-05}
13
13
  s.description = %q{AridCache makes caching easy and effective. AridCache supports caching on all your model named scopes, class methods and instance methods right out of the box. AridCache prevents caching logic from cluttering your models and clarifies your logic by making explicit calls to cached result sets.
14
14
  AridCache is designed for handling large, expensive ActiveRecord collections but is equally useful for caching anything else as well.
15
15
  }
@@ -30,6 +30,9 @@ AridCache is designed for handling large, expensive ActiveRecord collections but
30
30
  "lib/arid_cache.rb",
31
31
  "lib/arid_cache/active_record.rb",
32
32
  "lib/arid_cache/cache_proxy.rb",
33
+ "lib/arid_cache/cache_proxy/options.rb",
34
+ "lib/arid_cache/cache_proxy/result_processor.rb",
35
+ "lib/arid_cache/cache_proxy/utilities.rb",
33
36
  "lib/arid_cache/helpers.rb",
34
37
  "lib/arid_cache/inflector.rb",
35
38
  "lib/arid_cache/inflector/inflections.rb",
@@ -38,7 +41,9 @@ AridCache is designed for handling large, expensive ActiveRecord collections but
38
41
  "rails/init.rb",
39
42
  "spec/arid_cache/active_record_spec.rb",
40
43
  "spec/arid_cache/arid_cache_spec.rb",
41
- "spec/arid_cache/cache_proxy_result_spec.rb",
44
+ "spec/arid_cache/cache_proxy/cached_result_spec.rb",
45
+ "spec/arid_cache/cache_proxy/options_spec.rb",
46
+ "spec/arid_cache/cache_proxy/result_processor_spec.rb",
42
47
  "spec/arid_cache/cache_proxy_spec.rb",
43
48
  "spec/spec.opts",
44
49
  "spec/spec_helper.rb",
@@ -66,7 +71,9 @@ AridCache is designed for handling large, expensive ActiveRecord collections but
66
71
  s.test_files = [
67
72
  "spec/arid_cache/active_record_spec.rb",
68
73
  "spec/arid_cache/arid_cache_spec.rb",
69
- "spec/arid_cache/cache_proxy_result_spec.rb",
74
+ "spec/arid_cache/cache_proxy/cached_result_spec.rb",
75
+ "spec/arid_cache/cache_proxy/options_spec.rb",
76
+ "spec/arid_cache/cache_proxy/result_processor_spec.rb",
70
77
  "spec/arid_cache/cache_proxy_spec.rb",
71
78
  "spec/spec_helper.rb",
72
79
  "spec/support/ar_query.rb",
@@ -0,0 +1,73 @@
1
+ module AridCache
2
+ class CacheProxy
3
+ # A class representing a hash of options with methods to return subsets of
4
+ # those options.
5
+ class Options < Hash
6
+ def initialize(opts={})
7
+ self.merge!(opts)
8
+ end
9
+
10
+ # Filter options for paginate. Get the :per_page value from the receiver if it's not set.
11
+ # Set total_entries to +records.size+ if +records+ is supplied
12
+ def opts_for_paginate(records=nil)
13
+ paginate_opts = reject { |k,v| !OPTIONS_FOR_PAGINATE.include?(k) }
14
+ paginate_opts[:finder] = :find_all_by_id unless paginate_opts.include?(:finder)
15
+ if self[:result_klass].respond_to?(:per_page) && !paginate_opts.include?(:per_page)
16
+ paginate_opts[:per_page] = self[:result_klass].per_page
17
+ end
18
+ paginate_opts[:total_entries] = records.size unless records.nil?
19
+ paginate_opts
20
+ end
21
+
22
+ # Return options suitable to pass to ActiveRecord::Base#find.
23
+ # Preserve the original order of the results if no :order option is specified.
24
+ # If an offset is specified but no limit, ActiveRecord will not apply the offset,
25
+ # so pass in a limit that is as big as +ids.size+
26
+ #
27
+ # @arg ids array of ids to order by unless an :order option is specified.
28
+ def opts_for_find(ids)
29
+ find_opts = reject { |k,v| !OPTIONS_FOR_FIND.include?(k) }
30
+ find_opts[:order] = AridCache::CacheProxy::Utilities.order_by(ids, self[:result_klass]) unless find_opts.include?(:order)
31
+ find_opts[:limit] = ids.size unless find_opts.include?(:limit)
32
+ find_opts
33
+ end
34
+
35
+ def opts_for_cache
36
+ reject { |k,v| !OPTIONS_FOR_CACHE.include?(k) }
37
+ end
38
+
39
+ def opts_for_cache_key
40
+ reject { |k,v| !OPTIONS_FOR_CACHE_KEY.include?(k) }
41
+ end
42
+
43
+ # Returns options that affect the cache proxy result
44
+ def opts_for_cache_proxy
45
+ reject { |k,v| !OPTIONS_FOR_CACHE_PROXY.include?(k) }
46
+ end
47
+
48
+ def force?
49
+ !!self[:force]
50
+ end
51
+
52
+ def paginate?
53
+ include?(:page)
54
+ end
55
+
56
+ def raw?
57
+ !!self[:raw]
58
+ end
59
+
60
+ def count_only?
61
+ !!self[:count_only]
62
+ end
63
+
64
+ def order_by_proc?
65
+ include?(:order) && self[:order].is_a?(Proc)
66
+ end
67
+
68
+ def order_by_key?
69
+ include?(:order) && (self[:order].is_a?(Symbol) || self[:order].is_a?(String))
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,199 @@
1
+ module AridCache
2
+ class CacheProxy
3
+ # A class representing a result that is to be processed in some way before
4
+ # being returned to the user.
5
+ #
6
+ # Provides methods to introspect the result. The contents could be a base type,
7
+ # or an enumerable of sorts...any type really. We are only concerned with enumerables,
8
+ # and especially those containing active records.
9
+ class ResultProcessor
10
+
11
+ def initialize(result, opts={})
12
+ @result = result
13
+ @options = opts.is_a?(AridCache::CacheProxy::Options) ? opts : AridCache::CacheProxy::Options.new(opts)
14
+ end
15
+
16
+ # Return true if the result is an enumerable and it is empty.
17
+ def is_empty?
18
+ is_enumerable? && @result.empty?
19
+ end
20
+
21
+ # Return true if the result is an enumerable.
22
+ def is_enumerable?
23
+ @result.is_a?(Enumerable)
24
+ end
25
+
26
+ # Return true if the result is a list of hashes
27
+ def is_hashes?
28
+ is_enumerable? && @result.first.is_a?(Hash)
29
+ end
30
+
31
+ # Order in the database if an order clause has been specified and we
32
+ # have a list of ActiveRecords or a CachedResult.
33
+ def order_in_database?
34
+ is_cached_result? || (@options.order_by_key? && is_activerecord?)
35
+ end
36
+
37
+ # Return true if the result is an enumerable and the first item is
38
+ # an active record.
39
+ def is_activerecord?
40
+ is_enumerable? && @result.first.is_a?(::ActiveRecord::Base)
41
+ end
42
+
43
+ def is_activerecord_reflection?
44
+ @result.respond_to?(:proxy_reflection) || @result.respond_to?(:proxy_options)
45
+ end
46
+
47
+ def is_cached_result?
48
+ @result.is_a?(AridCache::CacheProxy::CachedResult)
49
+ end
50
+
51
+ # Return the result to cache. For base types the original result is
52
+ # returned. ActiveRecords return a CachedResult.
53
+ def to_cache
54
+ # Check if it's an association first, because it doesn't trigger the select if it's
55
+ # a named scope. Calling respond_to? on an association proxy will trigger a select
56
+ # because it loads up the target and passes the respond_to? on to it.
57
+ @cached = if is_activerecord_reflection?
58
+ lazy_cache.klass = @result.proxy_reflection.klass if @result.respond_to?(:proxy_reflection)
59
+ if @options.count_only?
60
+ lazy_cache.count = @result.count
61
+ else
62
+ lazy_cache.ids = @result.collect { |r| r[:id] }
63
+ lazy_cache.count = @result.size
64
+ end
65
+ lazy_cache
66
+ elsif is_activerecord? || is_empty?
67
+ lazy_cache.ids = @result.collect { |r| r[:id] }
68
+ lazy_cache.count = @result.size
69
+ lazy_cache.klass = @result.first.class
70
+ lazy_cache
71
+ else
72
+ @result
73
+ end
74
+ end
75
+
76
+ # Apply any options like pagination or ordering and return the result, which
77
+ # is either some base type, or usually, a list of active records.
78
+ def to_result
79
+ if @options.count_only?
80
+ get_count
81
+ elsif @options.raw? || (!is_cached_result? && !is_enumerable?)
82
+ @result
83
+ else
84
+ if is_cached_result?
85
+ fetch_activerecords(filter_results(@result.ids))
86
+ elsif order_in_database?
87
+ fetch_activerecords(filter_results(@result))
88
+ else
89
+ filter_results(@result)
90
+ end
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ def get_count
97
+ if @cached.is_a?(AridCache::CacheProxy::CachedResult) # use what we put in the cache
98
+ @cached.count
99
+ elsif @result.respond_to?(:count)
100
+ @result.count
101
+ else
102
+ @result
103
+ end
104
+ end
105
+
106
+ # Lazy-initialize a new cached result. Default the klass of the result to
107
+ # that of the receiver.
108
+ def lazy_cache
109
+ return @lazy_cache if @lazy_cache
110
+ @lazy_cache = AridCache::CacheProxy::CachedResult.new
111
+ @lazy_cache.klass = @options[:result_klass]
112
+ @lazy_cache
113
+ end
114
+
115
+ # Return the result after processing it to apply limits or pagination in memory.
116
+ # Doesn't do anything if we have to order in the databse.
117
+ #
118
+ # Options are only applied if the object responds to the appropriate method.
119
+ # So for example pagination will not happen unless the object responds to :paginate.
120
+ #
121
+ # Options:
122
+ # :order - Ordering is done first, before applying limits or paginating.
123
+ # If it's a Proc it is passed to Array#sort to do the sorting.
124
+ # If it is a Symbol or String the results should be Hashes and the
125
+ # list of Hashes are sorted by the values at the given key.
126
+ # :limit - limit the array to the specified size
127
+ # :offset - ignore the first +offset+ items in the array
128
+ # :page / :per_page - paginate the result. If :limit is specified, the array is
129
+ # limited before paginating; similarly if :offset is specified the array is offset
130
+ # before paginating. Pagination only happens if the :page option is passed.
131
+ def filter_results(records)
132
+ return records if order_in_database?
133
+
134
+ # Order in memory
135
+ if records.respond_to?(:sort)
136
+ if @options.order_by_proc?
137
+ records = records.sort(&@options[:order])
138
+ elsif @options.order_by_key? && is_hashes?
139
+ records = records.sort do |a, b|
140
+ a[@options[:order]] <=> b[@options[:order]]
141
+ end
142
+ end
143
+ end
144
+
145
+ # Limit / Offset
146
+ if (@options.include?(:offset) || @options.include?(:limit)) && records.respond_to?(:[])
147
+ records = records[@options[:offset] || 0, @options[:limit] || records.size]
148
+ end
149
+
150
+ # Paginate
151
+ if @options.paginate? && records.respond_to?(:paginate)
152
+ # Convert records to an array before calling paginate. If we don't do this
153
+ # and the result is a named scope, paginate will trigger an additional query
154
+ # to load the page rather than just using the records we have already fetched.
155
+ records = records.respond_to?(:to_a) ? records.to_a : records
156
+ records = records.paginate(@options.opts_for_paginate(records))
157
+ end
158
+ records
159
+ end
160
+
161
+ # Return a list of records from the database. +records+ is a list of
162
+ # ActiveRecords or a list of ActiveRecord ids.
163
+ #
164
+ # If no :order is specified, the current order is preserved with some fancy SQL.
165
+ # If an arder is specified then
166
+ # order, limit and paginate in the database.
167
+ def fetch_activerecords(records)
168
+ if records.empty?
169
+ if @options.paginate?
170
+ return records.paginate(@options.opts_for_paginate(records))
171
+ else
172
+ return records
173
+ end
174
+ end
175
+
176
+ ids = records.first.is_a?(ActiveRecord) ? records.collect { |record| record[:id] } : records
177
+ find_opts = @options.opts_for_find(ids)
178
+ if order_in_database?
179
+ if @options.paginate?
180
+ find_opts.merge!(@options.opts_for_paginate(ids))
181
+ result_klass.paginate(ids, find_opts)
182
+ else
183
+ result_klass.find_all_by_id(ids, find_opts)
184
+ end
185
+ else
186
+ # Limits will have already been applied, remove them from the options for find.
187
+ [:offset, :limit].each { |key| find_opts.delete(key) }
188
+ result = result_klass.find_all_by_id(ids, find_opts)
189
+ records.is_a?(::WillPaginate::Collection) ? records.replace(result) : result
190
+ end
191
+ end
192
+
193
+ # Return the klass to use for building results (only applies to ActiveRecord results)
194
+ def result_klass
195
+ @options[:result_klass] = is_cached_result? ? @result.klass : (@cached.is_a?(AridCache::CacheProxy::CachedResult) ? @cached.klass : Utilities.object_class(@options[:receiver]))
196
+ end
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,35 @@
1
+ module AridCache
2
+ class CacheProxy
3
+ module Utilities
4
+ extend self
5
+
6
+ # Generate an ORDER BY clause that preserves the ordering of the ids in *ids*.
7
+ #
8
+ # The method we use depends on the database adapter because only MySQL
9
+ # supports the ORDER BY FIELD() function. For other databases we use
10
+ # a CASE statement.
11
+ def order_by(ids, klass=nil)
12
+ column = if klass.respond_to?(:table_name)
13
+ ::ActiveRecord::Base.connection.quote_table_name(klass.table_name) + '.id'
14
+ else
15
+ "id"
16
+ end
17
+
18
+ if ids.empty?
19
+ nil
20
+ elsif ::ActiveRecord::Base.is_mysql_adapter?
21
+ "FIELD(#{column},#{ids.join(',')})"
22
+ else
23
+ order = ''
24
+ ids.each_index { |i| order << "WHEN #{column}=#{ids[i]} THEN #{i+1} " }
25
+ "CASE " + order + " END"
26
+ end
27
+ end
28
+
29
+ # Return the object's class or the object if it is a class.
30
+ def object_class(object)
31
+ object.is_a?(Class) ? object : object.class
32
+ end
33
+ end
34
+ end
35
+ end