├── .gitignore
├── .travis.yml
├── Appraisals
├── Gemfile
├── Gemfile.lock
├── MIT-LICENSE
├── README.markdown
├── Rakefile
├── gemfiles
├── rails_30.gemfile
├── rails_30.gemfile.lock
├── rails_31.gemfile
├── rails_31.gemfile.lock
├── rails_32.gemfile
├── rails_32.gemfile.lock
├── rails_40.gemfile
├── rails_40.gemfile.lock
├── rails_41.gemfile
└── rails_41.gemfile.lock
├── init.rb
├── lib
├── record-cache.rb
├── record_cache.rb
└── record_cache
│ ├── base.rb
│ ├── datastore
│ ├── active_record.rb
│ ├── active_record_30.rb
│ ├── active_record_31.rb
│ ├── active_record_32.rb
│ ├── active_record_40.rb
│ └── active_record_41.rb
│ ├── dispatcher.rb
│ ├── multi_read.rb
│ ├── query.rb
│ ├── statistics.rb
│ ├── strategy
│ ├── base.rb
│ ├── full_table_cache.rb
│ ├── index_cache.rb
│ ├── unique_index_cache.rb
│ └── util.rb
│ ├── test
│ └── resettable_version_store.rb
│ ├── version.rb
│ └── version_store.rb
├── record-cache.gemspec
└── spec
├── db
├── create-record-cache-db_and_user.sql
├── database.yml
├── schema.rb
└── seeds.rb
├── initializers
├── backward_compatibility.rb
└── record_cache.rb
├── lib
├── active_record
│ └── visitor_spec.rb
├── base_spec.rb
├── dispatcher_spec.rb
├── multi_read_spec.rb
├── query_spec.rb
├── statistics_spec.rb
├── strategy
│ ├── base_spec.rb
│ ├── full_table_cache_spec.rb
│ ├── index_cache_spec.rb
│ ├── query_cache_spec.rb
│ ├── unique_index_on_id_cache_spec.rb
│ ├── unique_index_on_string_cache_spec.rb
│ └── util_spec.rb
└── version_store_spec.rb
├── models
├── address.rb
├── apple.rb
├── banana.rb
├── language.rb
├── pear.rb
├── person.rb
└── store.rb
├── spec_helper.rb
└── support
└── matchers
├── hit_cache_matcher.rb
├── log.rb
├── miss_cache_matcher.rb
└── use_cache_matcher.rb
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.bundle
3 | /.project
4 | /.loadpath
5 | /.rvmrc
6 | /coverage
7 | /doc
8 | /pkg
9 | /tags
10 | /spec/log
11 | *.log
12 | *.gem
13 | *.sqlite3
14 | .idea/**
15 |
16 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: ruby
2 | rvm:
3 | - 1.9.3
4 | - 2.0.0
5 | - 2.1.0
6 | - jruby-19mode
7 | - ruby-head
8 | - jruby-head
9 |
10 | matrix:
11 | allow_failures:
12 | - rvm: rbx2
13 | - rvm: jruby-19mode
14 | - rvm: ruby-head
15 | - rvm: jruby-head
16 |
17 | gemfile:
18 | - gemfiles/rails_30.gemfile
19 | - gemfiles/rails_31.gemfile
20 | - gemfiles/rails_32.gemfile
21 | - gemfiles/rails_40.gemfile
22 | - gemfiles/rails_41.gemfile
23 |
24 | before_install: 'gem install bundler'
25 | script: 'bundle exec rake'
26 |
--------------------------------------------------------------------------------
/Appraisals:
--------------------------------------------------------------------------------
1 | appraise 'rails-30' do
2 | gem 'rails', '3.0.20'
3 | end
4 |
5 | appraise 'rails-31' do
6 | gem 'rails', '3.1.12'
7 | end
8 |
9 | appraise 'rails-32' do
10 | gem 'rails', '3.2.21'
11 | end
12 |
13 | appraise 'rails-40' do
14 | gem 'rails', '4.0.13'
15 | end
16 |
17 | appraise 'rails-41' do
18 | gem 'rails', '4.1.13'
19 | end
20 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source 'https://rubygems.org'
2 |
3 | gemspec
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | PATH
2 | remote: .
3 | specs:
4 | record-cache (0.1.6)
5 | rails
6 |
7 | GEM
8 | remote: https://rubygems.org/
9 | specs:
10 | actionmailer (4.0.12)
11 | actionpack (= 4.0.12)
12 | mail (~> 2.5, >= 2.5.4)
13 | actionpack (4.0.12)
14 | activesupport (= 4.0.12)
15 | builder (~> 3.1.0)
16 | erubis (~> 2.7.0)
17 | rack (~> 1.5.2)
18 | rack-test (~> 0.6.2)
19 | activemodel (4.0.12)
20 | activesupport (= 4.0.12)
21 | builder (~> 3.1.0)
22 | activerecord (4.0.12)
23 | activemodel (= 4.0.12)
24 | activerecord-deprecated_finders (~> 1.0.2)
25 | activesupport (= 4.0.12)
26 | arel (~> 4.0.0)
27 | activerecord-deprecated_finders (1.0.3)
28 | activesupport (4.0.12)
29 | i18n (~> 0.6, >= 0.6.9)
30 | minitest (~> 4.2)
31 | multi_json (~> 1.3)
32 | thread_safe (~> 0.1)
33 | tzinfo (~> 0.3.37)
34 | appraisal (1.0.2)
35 | bundler
36 | rake
37 | thor (>= 0.14.0)
38 | arel (4.0.2)
39 | builder (3.1.4)
40 | coderay (1.1.0)
41 | database_cleaner (1.3.0)
42 | diff-lcs (1.2.5)
43 | docile (1.1.5)
44 | erubis (2.7.0)
45 | i18n (0.7.0)
46 | mail (2.6.3)
47 | mime-types (>= 1.16, < 3)
48 | method_source (0.8.2)
49 | mime-types (2.6.2)
50 | minitest (4.7.5)
51 | multi_json (1.10.1)
52 | mysql2 (0.4.1)
53 | pry (0.10.2)
54 | coderay (~> 1.1.0)
55 | method_source (~> 0.8.1)
56 | slop (~> 3.4)
57 | rack (1.5.5)
58 | rack-test (0.6.3)
59 | rack (>= 1.0)
60 | rails (4.0.12)
61 | actionmailer (= 4.0.12)
62 | actionpack (= 4.0.12)
63 | activerecord (= 4.0.12)
64 | activesupport (= 4.0.12)
65 | bundler (>= 1.3.0, < 2.0)
66 | railties (= 4.0.12)
67 | sprockets-rails (~> 2.0)
68 | railties (4.0.12)
69 | actionpack (= 4.0.12)
70 | activesupport (= 4.0.12)
71 | rake (>= 0.8.7)
72 | thor (>= 0.18.1, < 2.0)
73 | rake (10.4.2)
74 | rspec (3.1.0)
75 | rspec-core (~> 3.1.0)
76 | rspec-expectations (~> 3.1.0)
77 | rspec-mocks (~> 3.1.0)
78 | rspec-core (3.1.7)
79 | rspec-support (~> 3.1.0)
80 | rspec-expectations (3.1.2)
81 | diff-lcs (>= 1.2.0, < 2.0)
82 | rspec-support (~> 3.1.0)
83 | rspec-mocks (3.1.3)
84 | rspec-support (~> 3.1.0)
85 | rspec-support (3.1.2)
86 | simplecov (0.9.1)
87 | docile (~> 1.1.0)
88 | multi_json (~> 1.0)
89 | simplecov-html (~> 0.8.0)
90 | simplecov-html (0.8.0)
91 | slop (3.6.0)
92 | sprockets (3.4.0)
93 | rack (> 1, < 3)
94 | sprockets-rails (2.3.3)
95 | actionpack (>= 3.0)
96 | activesupport (>= 3.0)
97 | sprockets (>= 2.8, < 4.0)
98 | sqlite3 (1.3.10)
99 | test_after_commit (0.4.0)
100 | activerecord (>= 3.2)
101 | thor (0.19.1)
102 | thread_safe (0.3.4)
103 | timecop (0.8.0)
104 | tzinfo (0.3.42)
105 |
106 | PLATFORMS
107 | ruby
108 |
109 | DEPENDENCIES
110 | activerecord (< 4.2)
111 | appraisal
112 | bundler
113 | database_cleaner
114 | mysql2
115 | pry
116 | rake
117 | record-cache!
118 | rspec (~> 3.0)
119 | simplecov
120 | sqlite3
121 | test_after_commit
122 | timecop
123 |
--------------------------------------------------------------------------------
/MIT-LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2009-2011 Lawrence Pit
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining
4 | a copy of this software and associated documentation files (the
5 | "Software"), to deal in the Software without restriction, including
6 | without limitation the rights to use, copy, modify, merge, publish,
7 | distribute, sublicense, and/or sell copies of the Software, and to
8 | permit persons to whom the Software is furnished to do so, subject to
9 | the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be
12 | included in all copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/README.markdown:
--------------------------------------------------------------------------------
1 | Record Cache
2 | ============
3 |
4 | [](https://travis-ci.org/orslumen/record-cache)
5 | [](https://rubygems.org/gems/record-cache)
6 |
7 | *Cache Active Model Records in Rails 3 and Rails 4*
8 |
9 | Record Cache transparently stores Records in a Cache Store to retrieve those Records from the store when queried using Active Model.
10 | Cache invalidation is performed automatically when Records are created, updated or destroyed. Currently only Active Record is supported, but more
11 | data stores may be added in the future.
12 |
13 | Usage
14 | -----
15 |
16 | #### Installation
17 |
18 | Add the following line to your Gemfile:
19 |
20 | gem 'record-cache'
21 |
22 |
23 | #### Initializer
24 |
25 | In /config/initializers/record_cache.rb:
26 |
27 | # --- Version Store
28 | # All Workers that use the Record Cache should point to the same Version Store
29 | # E.g. a MemCached cluster or a Redis Store (defaults to Rails.cache)
30 | RecordCache::Base.version_store = Rails.cache
31 |
32 | # --- Record Stores
33 | # Register Cache Stores for the Records themselves
34 | # Note: A different Cache Store could be used per Model, but in most configurations the following 2 stores will suffice:
35 |
36 | # The :local store is used to keep records in Worker memory
37 | RecordCache::Base.register_store(:local, ActiveSupport::Cache.lookup_store(:memory_store))
38 |
39 | # The :shared store is used to share Records between multiple Workers
40 | RecordCache::Base.register_store(:shared, Rails.cache)
41 |
42 | # Different logger
43 | # RecordCache::Base.logger = Logger.new(STDOUT)
44 |
45 |
46 | #### Models
47 |
48 | Define the Caching Strategy in your models.
49 |
50 | Typical Example: /app/models/person.rb:
51 |
52 | class Person < ActiveRecord::Base
53 | cache_records :store => :shared, :key => "pers"
54 | end
55 |
56 | Example with Index Cache: /app/models/permission.rb:
57 |
58 | class Permission < ActiveRecord::Base
59 | cache_records :store => :shared, :key => "perm", :index => [:person_id]
60 |
61 | belongs_to :person
62 | end
63 |
64 | Example with Full Table Cache: /app/models/priority.rb:
65 |
66 | class Priority < ActiveRecord::Base
67 | cache_records :store => :local, :key => "prio", :full_table => true
68 | end
69 |
70 | The following options are available:
71 |
72 | - `:store`: The name of the Cache Store for the Records (default: `Rails.cache`)
73 |
74 | _@see Initializer section above how to define named Cache Stores_
75 |
76 | - `:key`: Provide a short (unique) name to be used in the cache keys (default: `.name`)
77 |
78 | _Using shorter cache keys will improve performance as less data is sent to the Cache Stores_
79 |
80 | - `:unique_index`: The name(s) of the unique index column (default: `id`)
81 |
82 | _Choose a different column as the unqiue index column in case it is not `id`_
83 |
84 | - `:index`: An array of `:belongs_to` attributes to cache `:has_many` relations (default: `[]`)
85 |
86 | _`has_many` relations will lead to queries like: `SELECT * FROM permissions WHERE permission.person_id = 10`
87 | As Record Cache only caches records by ID, this query would always hit the DB. If an index is set
88 | on person_id (like in the example above), Record Cache will keep track of the Permission IDs per
89 | Person ID.
90 | Using that information the query will be translated to: `SELECT * FROM permissions WHERE permission.id IN (14,15,...)`
91 | and the permissions can be retrieved from cache.
92 | Note: The administration overhead for the Permission IDs per Person ID leads to more calls to the Version Store and the Record
93 | Store. Whether or not it is profitable to add specific indexes for has_many relations will differ per use-case._
94 |
95 | - `:full_table`: Whether the whole table should be stored as a single block in the cache (default: `false`)
96 |
97 | _Use this option in case this table is small, is only rarely updated and needs to be retrieved as a whole in most cases.
98 | For example to fill a Language or Country drop-down._
99 |
100 | - `:ttl`: Time to live (default: `infinitely`)
101 |
102 | _In case not all updates go through Rails (not a recommended design) this option makes it possible to specify a TTL for the cached
103 | records._
104 |
105 | It is also possible to listen to write failures on the Version Store that could lead to stale results:
106 |
107 | RecordCache::Base.version_store.on_write_failure{ |key| clear_this_key_after_2_seconds(key) }
108 |
109 |
110 | #### Tests
111 |
112 | To switch off Record Cache during the tests, add the following line to /config/environments/test.rb:
113 |
114 | RecordCache::Base.disable!
115 |
116 | But it is also possible (and preferable during Integration Tests) to keep the Record Cache switched on.
117 | To make sure the cache is invalidated for all updated Records after each test/scenario, require the
118 | resettable_version_store and reset the Version Store after each test/scenario.
119 |
120 | RSpec 2 example, in spec/spec_helper.rb:
121 |
122 | require 'record_cache/test/resettable_version_store'
123 |
124 | RSpec.configure do |config|
125 | config.after(:each) do
126 | RecordCache::Base.version_store.reset!
127 | end
128 | end
129 |
130 | Cucumber example, in features/support/env.rb:
131 |
132 | require 'record_cache/test/resettable_version_store'
133 |
134 | After do |scenario|
135 | RecordCache::Base.version_store.reset!
136 | end
137 |
138 |
139 | Restrictions
140 | ------------
141 |
142 | 1. This gem is dependent on Rails 3 or Rails 4
143 |
144 | 2. Only Active Record is supported as a data store.
145 |
146 | 3. All servers that host Workers should be time-synchronized (otherwise the Version Store may return stale results).
147 |
148 | #### Caveats
149 |
150 | 1. Record Cache sorting mimics the MySQL sort order being case-insensitive and using collation.
151 | _If you need a different sort order, check out the code in `/lib/record_cache/strategy/util.rb`._
152 |
153 | 1. Using `update_all` to modify attributes used in the [:index option](#index) will lead to stale results.
154 |
155 | 1. (Uncommon) If you have a model (A) with a `has_many :autosave => true` relation to another model (B) that defines a
156 | `:counter_cache` back to model A, the `_count` attribute will contain stale results. To solve this, add an
157 | after_save hook to model A and update the `_count` attribute there in case the `has_many` relation was loaded.
158 |
159 | 1. The combination of Mongrel (Rack) and the Dalli `:threadsafe => false` option will lead to the following errors in
160 | your log file: `undefined method `constantize’ for 0:Fixnum`. This is because Mongrel creates multiple threads.
161 | To overcome this, set thread_save to true, or consider using a different webserver like Unicorn.
162 |
163 | 1. Nested transactions: When using nested transactions, Rails will also call the after_commit hook of records that were
164 | updated within a nested transaction that was rolled back. This will cause the cache to contain updates that are not
165 | in the database.
166 | To overcome this, skip using nested transactions, or disable record cache and manually invalidate all records that were
167 | possibly updated within the nested transactions.
168 |
169 | 1. Flapping version store. Due to network hiccups the version store may not always be accessible to read/write the current
170 | version of a record. This may lead to stale results. The `on_write_failure` hook can be used to be informed when the
171 | communication to the version store fails and to take appropriate action, e.g. resetting the version store for that
172 | record some time later.
173 |
174 | Explain
175 | -------
176 |
177 | #### Retrieval
178 |
179 | Each query is parsed and sent to Record Cache before it is executed to check if the query is cacheable.
180 | A query is cacheable if:
181 |
182 | - it contains at least one `where(:id => ...)` or `where( => ...)` clause, and
183 |
184 | - it contains zero or more `where( => )` clauses on attributes in the same model, and
185 |
186 | - it has no `limit(...)` defined, or is limited to 1 record and has exactly one id in the `where(:id => ...)` clause, and
187 |
188 | - it has no `order(...)` clause, or it is sorted on single attributes using ASC and DESC only
189 |
190 | - it has no joins, calculations, group by, etc. clauses
191 |
192 | When the query is accepted by Record Cache, all requested records will be retrieved and cached as follows:
193 |
194 | ID queries:
195 |
196 | 1. The Version Store is called to retrieve the current version for each ID using a `multi_read` (keys `rc//`).
197 |
198 | 2. A new version will be generated (using the current timestamp) for each ID unknown to the Version Store.
199 |
200 | 3. The Record Store is called to retrieve the latest data for each ID using a `multi_read` (keys `rc//v`).
201 |
202 | 4. The data of the missing records is retrieved directly from the Data Store (single query) and are subsequently cached in the Record Store.
203 |
204 | 5. The data of all records is deserialized to Active Model records.
205 |
206 | 6. The other (simple) `where( => )` clauses are applied, if applicable.
207 |
208 | 7. The (simple) `order(...)` clause is applied, if applicable.
209 |
210 | Index queries:
211 |
212 | 1. The Version Store is called to retrieve the current version for the group (key `rc///`).
213 |
214 | 2. A new version will be generated (using the current timestamp) in case the current version is unknown to the Version Store.
215 |
216 | 3. The Record Store is called to retrieve the latest set of IDs in this group (key `rc///v`).
217 |
218 | 4. In case the IDs are missing, the IDs (only) will be retrieved from the Data Store (single query) and subsequently cached in the Record Store.
219 |
220 | 5. The IDs are passed as an ID query to the id-based-cache (see above).
221 |
222 |
223 | #### Invalidation
224 |
225 | The `after_commit, :on => :create/:update/:destroy` hooks are used to inform the Record Cache of changes to the cached records.
226 |
227 | ID cache:
228 |
229 | - `:create`: add a new version to the Version Store and cache the record in the Records Store
230 |
231 | - `:update`: similar to :create
232 |
233 | - `:destroy`: remove the record from the Version Store
234 |
235 | Index cache:
236 |
237 | - `:create`: increment Version Store for each index that contains the indexed attribute value of this record.
238 | In case the IDs in this group are cached and fresh, add the ID of the new record to the group and store
239 | the updated list of IDs in the Records Store.
240 |
241 | - `:update`: For each index that is included in the changed attribute, apply the :destoy logic to the old value
242 | and the :create logic to the new value.
243 |
244 | - `:destroy`: increment Version Store for each index that contains the indexed attribute value of this record.
245 | In case the IDs in this group are current cached and fresh, remove the ID of the record from the group and store
246 | the updated list of IDs in the Records Store.
247 |
248 | The `update_all` method of Active Record Relation is also overridden to make sure that mass-updates are processed correctly, e.g. used by the
249 | :counter_cache. As the details of the change are not known, all records that match the IDs mentioned in the update_all statement are invalidated by
250 | removing them from the Version Store.
251 |
252 | Finally for `has_many` relations, the `after_commit` hooks are not triggered on add and remove. Whether this is a bug or feature I do not know, but
253 | for Active Record the Has Many Association is patched to invalidate the Index Cache of the referenced (reflection) Record in case it has
254 | an [:index](#index) on the reverse `belongs_to` relation.
255 |
256 |
257 | Development
258 | -----------
259 |
260 | $ bundle
261 | $ appraisal
262 |
263 | # run the specs (requires ruby 1.9.3)
264 | $ appraisal rake
265 |
266 | # run the specs for a particular version (supported are rails-30, rails-31, rails-32, rails-40)
267 | $ appraisal rails-32 rake
268 |
269 | # run a single spec
270 | $ appraisal rails-40 rspec ./spec/lib/strategy/base_spec.rb:61
271 |
272 | Deploying the gem:
273 |
274 | # Don't forget to update the version in lib/record_cache/version.rb
275 | $ git tag -a v0.1.1 -m 'version 0.1.1'
276 | $ git push origin master --tags
277 | $ gem update --system
278 | $ gem build record-cache.gemspec
279 | $ gem push record-cache-0.1.1.gem
280 |
281 | Debugging the gem:
282 |
283 | Switch on DEBUG logging (`config.log_level = :debug` in development.rb) to get more information on cache hits and misses.
284 |
285 |
286 | Release Notes
287 | -------------
288 |
289 | #### Version 0.1.5 (next version)
290 |
291 | 1. On-write-failure hook on the version store
292 | 1.
293 |
294 | #### Version 0.1.4
295 |
296 | 1. Case insensitive filtering
297 | 1. to_sql no longer destroying the sql binds (John Morales)
298 | 1. Rails 4.0 support (Robin Roestenburg & Pitr https://github.com/orslumen/record-cache/pull/44)
299 | 1. Rails 4.1 support (Pitr https://github.com/orslumen/record-cache/pull/45)
300 | 1. Fix for +select('distinct ...')+ construct
301 |
302 |
303 | #### Version 0.1.3
304 |
305 | Fixed Bugs:
306 |
307 | 1. "\u0000" is also used by Arel as a parameter query binding marker.
308 | 1. https://github.com/orslumen/record-cache/issues/2: bypassing record_cache when selecting rows with lock
309 |
310 | Added:
311 |
312 | 1. Release Notes ;)
313 | 1. Ruby 1.9 fixes, has_one support, Remove Freeze for Dalli encoding (Bryan Mundie https://github.com/orslumen/record-cache/pull/3)
314 | 1. :unique_index option
315 | 1. :full_table option
316 | 1. [Appraisal](https://github.com/thoughtbot/appraisal) - working with different Rails versions
317 | 1. [Travis CI](https://travis-ci.org/orslumen/record-cache) - continuous integration service (Robin Roestenburg https://github.com/orslumen/record-cache/pull/33)
318 | 1. Rails 3.1 and 3.2 support
319 | 1. Replace request_cache in favor of ActiveRecord::QueryCache (Lawrence Pit https://github.com/orslumen/record-cache/pull/11)
320 | 1. Possibility to set a custom logger
321 | 1. Select queries within a transaction will automatically bypass the cache
322 | 1. No more increment calls to the Version Store (only set and delete)
323 | 1. Support for Dalli's +multi+ method to pipeline multiple cache writes (when storing multiple fresh records in the cache, or outdating multiple records after update_all)
324 | 1. Updated tests to RSpec 3
325 | 1. Fix deserialization of records with serialized attributes, see https://github.com/orslumen/record-cache/issues/19
326 | 1. Ruby 2 fix
327 |
328 |
329 | #### Version 0.1.2
330 |
331 | Refactoring: Moved Serialization, Sorting and Filtering to separate Util class.
332 |
333 | Now it is possible to re-use MySQL style sorting (with collation) in your own app, e.g. by calling `RecordCache::Strategy::Util.sort!(Apple.all, :name)`.
334 |
335 |
336 | #### Version 0.1.1
337 |
338 | Added support for Rails 3.1
339 |
340 |
341 | #### Version 0.1.0
342 |
343 | First version, with the following Strategies:
344 |
345 | 1. Request Cache
346 | 1. ID Cache
347 | 1. Index Cache
348 |
349 | ----
350 | Copyright (c) 2011-2015 Orslumen, released under the MIT license
351 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | require 'rubygems'
2 | require 'bundler'
3 | require 'bundler/setup'
4 | Bundler::GemHelper.install_tasks
5 |
6 | require 'rake'
7 | require 'rspec/core/rake_task'
8 |
9 | RSpec::Core::RakeTask.new(:spec)
10 |
11 | task :default => :spec
12 |
13 | task :notes do
14 | system "grep -n -r 'FIXME\\|TODO' lib spec"
15 | end
16 |
--------------------------------------------------------------------------------
/gemfiles/rails_30.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "rails", "3.0.20"
6 |
7 | gemspec :path => "../"
8 |
--------------------------------------------------------------------------------
/gemfiles/rails_30.gemfile.lock:
--------------------------------------------------------------------------------
1 | PATH
2 | remote: ../
3 | specs:
4 | record-cache (0.1.6)
5 | rails
6 |
7 | GEM
8 | remote: https://rubygems.org/
9 | specs:
10 | abstract (1.0.0)
11 | actionmailer (3.0.20)
12 | actionpack (= 3.0.20)
13 | mail (~> 2.2.19)
14 | actionpack (3.0.20)
15 | activemodel (= 3.0.20)
16 | activesupport (= 3.0.20)
17 | builder (~> 2.1.2)
18 | erubis (~> 2.6.6)
19 | i18n (~> 0.5.0)
20 | rack (~> 1.2.5)
21 | rack-mount (~> 0.6.14)
22 | rack-test (~> 0.5.7)
23 | tzinfo (~> 0.3.23)
24 | activemodel (3.0.20)
25 | activesupport (= 3.0.20)
26 | builder (~> 2.1.2)
27 | i18n (~> 0.5.0)
28 | activerecord (3.0.20)
29 | activemodel (= 3.0.20)
30 | activesupport (= 3.0.20)
31 | arel (~> 2.0.10)
32 | tzinfo (~> 0.3.23)
33 | activeresource (3.0.20)
34 | activemodel (= 3.0.20)
35 | activesupport (= 3.0.20)
36 | activesupport (3.0.20)
37 | appraisal (2.1.0)
38 | bundler
39 | rake
40 | thor (>= 0.14.0)
41 | arel (2.0.10)
42 | builder (2.1.2)
43 | coderay (1.1.0)
44 | database_cleaner (1.5.0)
45 | diff-lcs (1.2.5)
46 | docile (1.1.5)
47 | erubis (2.6.6)
48 | abstract (>= 1.0.0)
49 | i18n (0.5.4)
50 | json (1.8.3)
51 | mail (2.2.20)
52 | activesupport (>= 2.3.6)
53 | i18n (>= 0.4.0)
54 | mime-types (~> 1.16)
55 | treetop (~> 1.4.8)
56 | method_source (0.8.2)
57 | mime-types (1.25.1)
58 | mysql2 (0.4.3)
59 | polyglot (0.3.5)
60 | pry (0.10.2)
61 | coderay (~> 1.1.0)
62 | method_source (~> 0.8.1)
63 | slop (~> 3.4)
64 | rack (1.2.8)
65 | rack-mount (0.6.14)
66 | rack (>= 1.0.0)
67 | rack-test (0.5.7)
68 | rack (>= 1.0)
69 | rails (3.0.20)
70 | actionmailer (= 3.0.20)
71 | actionpack (= 3.0.20)
72 | activerecord (= 3.0.20)
73 | activeresource (= 3.0.20)
74 | activesupport (= 3.0.20)
75 | bundler (~> 1.0)
76 | railties (= 3.0.20)
77 | railties (3.0.20)
78 | actionpack (= 3.0.20)
79 | activesupport (= 3.0.20)
80 | rake (>= 0.8.7)
81 | rdoc (~> 3.4)
82 | thor (~> 0.14.4)
83 | rake (10.4.2)
84 | rdoc (3.12.2)
85 | json (~> 1.4)
86 | rspec (3.3.0)
87 | rspec-core (~> 3.3.0)
88 | rspec-expectations (~> 3.3.0)
89 | rspec-mocks (~> 3.3.0)
90 | rspec-core (3.3.2)
91 | rspec-support (~> 3.3.0)
92 | rspec-expectations (3.3.1)
93 | diff-lcs (>= 1.2.0, < 2.0)
94 | rspec-support (~> 3.3.0)
95 | rspec-mocks (3.3.2)
96 | diff-lcs (>= 1.2.0, < 2.0)
97 | rspec-support (~> 3.3.0)
98 | rspec-support (3.3.0)
99 | simplecov (0.10.0)
100 | docile (~> 1.1.0)
101 | json (~> 1.8)
102 | simplecov-html (~> 0.10.0)
103 | simplecov-html (0.10.0)
104 | slop (3.6.0)
105 | sqlite3 (1.3.10)
106 | test_after_commit (0.2.3)
107 | thor (0.14.6)
108 | timecop (0.8.0)
109 | treetop (1.4.15)
110 | polyglot
111 | polyglot (>= 0.3.1)
112 | tzinfo (0.3.45)
113 |
114 | PLATFORMS
115 | ruby
116 |
117 | DEPENDENCIES
118 | activerecord (< 4.2)
119 | appraisal
120 | bundler
121 | database_cleaner
122 | mysql2
123 | pry
124 | rails (= 3.0.20)
125 | rake
126 | record-cache!
127 | rspec (~> 3.0)
128 | simplecov
129 | sqlite3
130 | test_after_commit
131 | timecop
132 |
133 | BUNDLED WITH
134 | 1.12.5
135 |
--------------------------------------------------------------------------------
/gemfiles/rails_31.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "rails", "3.1.12"
6 |
7 | gemspec :path => "../"
8 |
--------------------------------------------------------------------------------
/gemfiles/rails_31.gemfile.lock:
--------------------------------------------------------------------------------
1 | PATH
2 | remote: ../
3 | specs:
4 | record-cache (0.1.6)
5 | rails
6 |
7 | GEM
8 | remote: https://rubygems.org/
9 | specs:
10 | actionmailer (3.1.12)
11 | actionpack (= 3.1.12)
12 | mail (~> 2.4.4)
13 | actionpack (3.1.12)
14 | activemodel (= 3.1.12)
15 | activesupport (= 3.1.12)
16 | builder (~> 3.0.0)
17 | erubis (~> 2.7.0)
18 | i18n (~> 0.6)
19 | rack (~> 1.3.6)
20 | rack-cache (~> 1.2)
21 | rack-mount (~> 0.8.2)
22 | rack-test (~> 0.6.1)
23 | sprockets (~> 2.0.4)
24 | activemodel (3.1.12)
25 | activesupport (= 3.1.12)
26 | builder (~> 3.0.0)
27 | i18n (~> 0.6)
28 | activerecord (3.1.12)
29 | activemodel (= 3.1.12)
30 | activesupport (= 3.1.12)
31 | arel (~> 2.2.3)
32 | tzinfo (~> 0.3.29)
33 | activeresource (3.1.12)
34 | activemodel (= 3.1.12)
35 | activesupport (= 3.1.12)
36 | activesupport (3.1.12)
37 | multi_json (~> 1.0)
38 | appraisal (2.1.0)
39 | bundler
40 | rake
41 | thor (>= 0.14.0)
42 | arel (2.2.3)
43 | builder (3.0.4)
44 | coderay (1.1.0)
45 | database_cleaner (1.5.0)
46 | diff-lcs (1.2.5)
47 | docile (1.1.5)
48 | erubis (2.7.0)
49 | hike (1.2.3)
50 | i18n (0.7.0)
51 | json (1.8.3)
52 | mail (2.4.4)
53 | i18n (>= 0.4.0)
54 | mime-types (~> 1.16)
55 | treetop (~> 1.4.8)
56 | method_source (0.8.2)
57 | mime-types (1.25.1)
58 | multi_json (1.11.2)
59 | mysql2 (0.4.3)
60 | polyglot (0.3.5)
61 | pry (0.10.2)
62 | coderay (~> 1.1.0)
63 | method_source (~> 0.8.1)
64 | slop (~> 3.4)
65 | rack (1.3.10)
66 | rack-cache (1.5.0)
67 | rack (>= 0.4)
68 | rack-mount (0.8.3)
69 | rack (>= 1.0.0)
70 | rack-ssl (1.3.4)
71 | rack
72 | rack-test (0.6.3)
73 | rack (>= 1.0)
74 | rails (3.1.12)
75 | actionmailer (= 3.1.12)
76 | actionpack (= 3.1.12)
77 | activerecord (= 3.1.12)
78 | activeresource (= 3.1.12)
79 | activesupport (= 3.1.12)
80 | bundler (~> 1.0)
81 | railties (= 3.1.12)
82 | railties (3.1.12)
83 | actionpack (= 3.1.12)
84 | activesupport (= 3.1.12)
85 | rack-ssl (~> 1.3.2)
86 | rake (>= 0.8.7)
87 | rdoc (~> 3.4)
88 | thor (~> 0.14.6)
89 | rake (10.4.2)
90 | rdoc (3.12.2)
91 | json (~> 1.4)
92 | rspec (3.3.0)
93 | rspec-core (~> 3.3.0)
94 | rspec-expectations (~> 3.3.0)
95 | rspec-mocks (~> 3.3.0)
96 | rspec-core (3.3.2)
97 | rspec-support (~> 3.3.0)
98 | rspec-expectations (3.3.1)
99 | diff-lcs (>= 1.2.0, < 2.0)
100 | rspec-support (~> 3.3.0)
101 | rspec-mocks (3.3.2)
102 | diff-lcs (>= 1.2.0, < 2.0)
103 | rspec-support (~> 3.3.0)
104 | rspec-support (3.3.0)
105 | simplecov (0.10.0)
106 | docile (~> 1.1.0)
107 | json (~> 1.8)
108 | simplecov-html (~> 0.10.0)
109 | simplecov-html (0.10.0)
110 | slop (3.6.0)
111 | sprockets (2.0.5)
112 | hike (~> 1.2)
113 | rack (~> 1.0)
114 | tilt (~> 1.1, != 1.3.0)
115 | sqlite3 (1.3.10)
116 | test_after_commit (0.2.3)
117 | thor (0.14.6)
118 | tilt (1.4.1)
119 | timecop (0.8.0)
120 | treetop (1.4.15)
121 | polyglot
122 | polyglot (>= 0.3.1)
123 | tzinfo (0.3.45)
124 |
125 | PLATFORMS
126 | ruby
127 |
128 | DEPENDENCIES
129 | activerecord (< 4.2)
130 | appraisal
131 | bundler
132 | database_cleaner
133 | mysql2
134 | pry
135 | rails (= 3.1.12)
136 | rake
137 | record-cache!
138 | rspec (~> 3.0)
139 | simplecov
140 | sqlite3
141 | test_after_commit
142 | timecop
143 |
144 | BUNDLED WITH
145 | 1.12.5
146 |
--------------------------------------------------------------------------------
/gemfiles/rails_32.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "rails", "3.2.21"
6 |
7 | gemspec :path => "../"
8 |
--------------------------------------------------------------------------------
/gemfiles/rails_32.gemfile.lock:
--------------------------------------------------------------------------------
1 | PATH
2 | remote: ../
3 | specs:
4 | record-cache (0.1.6)
5 | rails
6 |
7 | GEM
8 | remote: https://rubygems.org/
9 | specs:
10 | actionmailer (3.2.21)
11 | actionpack (= 3.2.21)
12 | mail (~> 2.5.4)
13 | actionpack (3.2.21)
14 | activemodel (= 3.2.21)
15 | activesupport (= 3.2.21)
16 | builder (~> 3.0.0)
17 | erubis (~> 2.7.0)
18 | journey (~> 1.0.4)
19 | rack (~> 1.4.5)
20 | rack-cache (~> 1.2)
21 | rack-test (~> 0.6.1)
22 | sprockets (~> 2.2.1)
23 | activemodel (3.2.21)
24 | activesupport (= 3.2.21)
25 | builder (~> 3.0.0)
26 | activerecord (3.2.21)
27 | activemodel (= 3.2.21)
28 | activesupport (= 3.2.21)
29 | arel (~> 3.0.2)
30 | tzinfo (~> 0.3.29)
31 | activeresource (3.2.21)
32 | activemodel (= 3.2.21)
33 | activesupport (= 3.2.21)
34 | activesupport (3.2.21)
35 | i18n (~> 0.6, >= 0.6.4)
36 | multi_json (~> 1.0)
37 | appraisal (2.1.0)
38 | bundler
39 | rake
40 | thor (>= 0.14.0)
41 | arel (3.0.3)
42 | builder (3.0.4)
43 | coderay (1.1.0)
44 | database_cleaner (1.5.0)
45 | diff-lcs (1.2.5)
46 | docile (1.1.5)
47 | erubis (2.7.0)
48 | hike (1.2.3)
49 | i18n (0.7.0)
50 | journey (1.0.4)
51 | json (1.8.3)
52 | mail (2.5.4)
53 | mime-types (~> 1.16)
54 | treetop (~> 1.4.8)
55 | method_source (0.8.2)
56 | mime-types (1.25.1)
57 | multi_json (1.11.2)
58 | mysql2 (0.4.3)
59 | polyglot (0.3.5)
60 | pry (0.10.2)
61 | coderay (~> 1.1.0)
62 | method_source (~> 0.8.1)
63 | slop (~> 3.4)
64 | rack (1.4.7)
65 | rack-cache (1.5.0)
66 | rack (>= 0.4)
67 | rack-ssl (1.3.4)
68 | rack
69 | rack-test (0.6.3)
70 | rack (>= 1.0)
71 | rails (3.2.21)
72 | actionmailer (= 3.2.21)
73 | actionpack (= 3.2.21)
74 | activerecord (= 3.2.21)
75 | activeresource (= 3.2.21)
76 | activesupport (= 3.2.21)
77 | bundler (~> 1.0)
78 | railties (= 3.2.21)
79 | railties (3.2.21)
80 | actionpack (= 3.2.21)
81 | activesupport (= 3.2.21)
82 | rack-ssl (~> 1.3.2)
83 | rake (>= 0.8.7)
84 | rdoc (~> 3.4)
85 | thor (>= 0.14.6, < 2.0)
86 | rake (10.4.2)
87 | rdoc (3.12.2)
88 | json (~> 1.4)
89 | rspec (3.3.0)
90 | rspec-core (~> 3.3.0)
91 | rspec-expectations (~> 3.3.0)
92 | rspec-mocks (~> 3.3.0)
93 | rspec-core (3.3.2)
94 | rspec-support (~> 3.3.0)
95 | rspec-expectations (3.3.1)
96 | diff-lcs (>= 1.2.0, < 2.0)
97 | rspec-support (~> 3.3.0)
98 | rspec-mocks (3.3.2)
99 | diff-lcs (>= 1.2.0, < 2.0)
100 | rspec-support (~> 3.3.0)
101 | rspec-support (3.3.0)
102 | simplecov (0.10.0)
103 | docile (~> 1.1.0)
104 | json (~> 1.8)
105 | simplecov-html (~> 0.10.0)
106 | simplecov-html (0.10.0)
107 | slop (3.6.0)
108 | sprockets (2.2.3)
109 | hike (~> 1.2)
110 | multi_json (~> 1.0)
111 | rack (~> 1.0)
112 | tilt (~> 1.1, != 1.3.0)
113 | sqlite3 (1.3.10)
114 | test_after_commit (0.4.1)
115 | activerecord (>= 3.2)
116 | thor (0.19.1)
117 | tilt (1.4.1)
118 | timecop (0.8.0)
119 | treetop (1.4.15)
120 | polyglot
121 | polyglot (>= 0.3.1)
122 | tzinfo (0.3.45)
123 |
124 | PLATFORMS
125 | ruby
126 |
127 | DEPENDENCIES
128 | activerecord (< 4.2)
129 | appraisal
130 | bundler
131 | database_cleaner
132 | mysql2
133 | pry
134 | rails (= 3.2.21)
135 | rake
136 | record-cache!
137 | rspec (~> 3.0)
138 | simplecov
139 | sqlite3
140 | test_after_commit
141 | timecop
142 |
143 | BUNDLED WITH
144 | 1.12.5
145 |
--------------------------------------------------------------------------------
/gemfiles/rails_40.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "rails", "4.0.13"
6 |
7 | gemspec :path => "../"
8 |
--------------------------------------------------------------------------------
/gemfiles/rails_40.gemfile.lock:
--------------------------------------------------------------------------------
1 | PATH
2 | remote: ../
3 | specs:
4 | record-cache (0.1.6)
5 | rails
6 |
7 | GEM
8 | remote: https://rubygems.org/
9 | specs:
10 | actionmailer (4.0.13)
11 | actionpack (= 4.0.13)
12 | mail (~> 2.5, >= 2.5.4)
13 | actionpack (4.0.13)
14 | activesupport (= 4.0.13)
15 | builder (~> 3.1.0)
16 | erubis (~> 2.7.0)
17 | rack (~> 1.5.2)
18 | rack-test (~> 0.6.2)
19 | activemodel (4.0.13)
20 | activesupport (= 4.0.13)
21 | builder (~> 3.1.0)
22 | activerecord (4.0.13)
23 | activemodel (= 4.0.13)
24 | activerecord-deprecated_finders (~> 1.0.2)
25 | activesupport (= 4.0.13)
26 | arel (~> 4.0.0)
27 | activerecord-deprecated_finders (1.0.4)
28 | activesupport (4.0.13)
29 | i18n (~> 0.6, >= 0.6.9)
30 | minitest (~> 4.2)
31 | multi_json (~> 1.3)
32 | thread_safe (~> 0.1)
33 | tzinfo (~> 0.3.37)
34 | appraisal (2.1.0)
35 | bundler
36 | rake
37 | thor (>= 0.14.0)
38 | arel (4.0.2)
39 | builder (3.1.4)
40 | coderay (1.1.0)
41 | database_cleaner (1.5.0)
42 | diff-lcs (1.2.5)
43 | docile (1.1.5)
44 | erubis (2.7.0)
45 | i18n (0.7.0)
46 | json (1.8.3)
47 | mail (2.6.3)
48 | mime-types (>= 1.16, < 3)
49 | method_source (0.8.2)
50 | mime-types (2.6.2)
51 | minitest (4.7.5)
52 | multi_json (1.11.2)
53 | mysql2 (0.4.3)
54 | pry (0.10.2)
55 | coderay (~> 1.1.0)
56 | method_source (~> 0.8.1)
57 | slop (~> 3.4)
58 | rack (1.5.5)
59 | rack-test (0.6.3)
60 | rack (>= 1.0)
61 | rails (4.0.13)
62 | actionmailer (= 4.0.13)
63 | actionpack (= 4.0.13)
64 | activerecord (= 4.0.13)
65 | activesupport (= 4.0.13)
66 | bundler (>= 1.3.0, < 2.0)
67 | railties (= 4.0.13)
68 | sprockets-rails (~> 2.0)
69 | railties (4.0.13)
70 | actionpack (= 4.0.13)
71 | activesupport (= 4.0.13)
72 | rake (>= 0.8.7)
73 | thor (>= 0.18.1, < 2.0)
74 | rake (10.4.2)
75 | rspec (3.3.0)
76 | rspec-core (~> 3.3.0)
77 | rspec-expectations (~> 3.3.0)
78 | rspec-mocks (~> 3.3.0)
79 | rspec-core (3.3.2)
80 | rspec-support (~> 3.3.0)
81 | rspec-expectations (3.3.1)
82 | diff-lcs (>= 1.2.0, < 2.0)
83 | rspec-support (~> 3.3.0)
84 | rspec-mocks (3.3.2)
85 | diff-lcs (>= 1.2.0, < 2.0)
86 | rspec-support (~> 3.3.0)
87 | rspec-support (3.3.0)
88 | simplecov (0.10.0)
89 | docile (~> 1.1.0)
90 | json (~> 1.8)
91 | simplecov-html (~> 0.10.0)
92 | simplecov-html (0.10.0)
93 | slop (3.6.0)
94 | sprockets (3.4.0)
95 | rack (> 1, < 3)
96 | sprockets-rails (2.3.3)
97 | actionpack (>= 3.0)
98 | activesupport (>= 3.0)
99 | sprockets (>= 2.8, < 4.0)
100 | sqlite3 (1.3.10)
101 | test_after_commit (0.4.1)
102 | activerecord (>= 3.2)
103 | thor (0.19.1)
104 | thread_safe (0.3.5)
105 | timecop (0.8.0)
106 | tzinfo (0.3.45)
107 |
108 | PLATFORMS
109 | ruby
110 |
111 | DEPENDENCIES
112 | activerecord (< 4.2)
113 | appraisal
114 | bundler
115 | database_cleaner
116 | mysql2
117 | pry
118 | rails (= 4.0.13)
119 | rake
120 | record-cache!
121 | rspec (~> 3.0)
122 | simplecov
123 | sqlite3
124 | test_after_commit
125 | timecop
126 |
127 | BUNDLED WITH
128 | 1.12.5
129 |
--------------------------------------------------------------------------------
/gemfiles/rails_41.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "rails", "4.1.13"
6 |
7 | gemspec :path => "../"
8 |
--------------------------------------------------------------------------------
/gemfiles/rails_41.gemfile.lock:
--------------------------------------------------------------------------------
1 | PATH
2 | remote: ../
3 | specs:
4 | record-cache (0.1.6)
5 | rails
6 |
7 | GEM
8 | remote: https://rubygems.org/
9 | specs:
10 | actionmailer (4.1.13)
11 | actionpack (= 4.1.13)
12 | actionview (= 4.1.13)
13 | mail (~> 2.5, >= 2.5.4)
14 | actionpack (4.1.13)
15 | actionview (= 4.1.13)
16 | activesupport (= 4.1.13)
17 | rack (~> 1.5.2)
18 | rack-test (~> 0.6.2)
19 | actionview (4.1.13)
20 | activesupport (= 4.1.13)
21 | builder (~> 3.1)
22 | erubis (~> 2.7.0)
23 | activemodel (4.1.13)
24 | activesupport (= 4.1.13)
25 | builder (~> 3.1)
26 | activerecord (4.1.13)
27 | activemodel (= 4.1.13)
28 | activesupport (= 4.1.13)
29 | arel (~> 5.0.0)
30 | activesupport (4.1.13)
31 | i18n (~> 0.6, >= 0.6.9)
32 | json (~> 1.7, >= 1.7.7)
33 | minitest (~> 5.1)
34 | thread_safe (~> 0.1)
35 | tzinfo (~> 1.1)
36 | appraisal (2.1.0)
37 | bundler
38 | rake
39 | thor (>= 0.14.0)
40 | arel (5.0.1.20140414130214)
41 | builder (3.2.2)
42 | coderay (1.1.0)
43 | database_cleaner (1.5.0)
44 | diff-lcs (1.2.5)
45 | docile (1.1.5)
46 | erubis (2.7.0)
47 | i18n (0.7.0)
48 | json (1.8.3)
49 | mail (2.6.3)
50 | mime-types (>= 1.16, < 3)
51 | method_source (0.8.2)
52 | mime-types (2.6.2)
53 | minitest (5.8.1)
54 | mysql2 (0.4.3)
55 | pry (0.10.2)
56 | coderay (~> 1.1.0)
57 | method_source (~> 0.8.1)
58 | slop (~> 3.4)
59 | rack (1.5.5)
60 | rack-test (0.6.3)
61 | rack (>= 1.0)
62 | rails (4.1.13)
63 | actionmailer (= 4.1.13)
64 | actionpack (= 4.1.13)
65 | actionview (= 4.1.13)
66 | activemodel (= 4.1.13)
67 | activerecord (= 4.1.13)
68 | activesupport (= 4.1.13)
69 | bundler (>= 1.3.0, < 2.0)
70 | railties (= 4.1.13)
71 | sprockets-rails (~> 2.0)
72 | railties (4.1.13)
73 | actionpack (= 4.1.13)
74 | activesupport (= 4.1.13)
75 | rake (>= 0.8.7)
76 | thor (>= 0.18.1, < 2.0)
77 | rake (10.4.2)
78 | rspec (3.3.0)
79 | rspec-core (~> 3.3.0)
80 | rspec-expectations (~> 3.3.0)
81 | rspec-mocks (~> 3.3.0)
82 | rspec-core (3.3.2)
83 | rspec-support (~> 3.3.0)
84 | rspec-expectations (3.3.1)
85 | diff-lcs (>= 1.2.0, < 2.0)
86 | rspec-support (~> 3.3.0)
87 | rspec-mocks (3.3.2)
88 | diff-lcs (>= 1.2.0, < 2.0)
89 | rspec-support (~> 3.3.0)
90 | rspec-support (3.3.0)
91 | simplecov (0.10.0)
92 | docile (~> 1.1.0)
93 | json (~> 1.8)
94 | simplecov-html (~> 0.10.0)
95 | simplecov-html (0.10.0)
96 | slop (3.6.0)
97 | sprockets (3.4.0)
98 | rack (> 1, < 3)
99 | sprockets-rails (2.3.3)
100 | actionpack (>= 3.0)
101 | activesupport (>= 3.0)
102 | sprockets (>= 2.8, < 4.0)
103 | sqlite3 (1.3.10)
104 | test_after_commit (0.4.1)
105 | activerecord (>= 3.2)
106 | thor (0.19.1)
107 | thread_safe (0.3.5)
108 | timecop (0.8.0)
109 | tzinfo (1.2.2)
110 | thread_safe (~> 0.1)
111 |
112 | PLATFORMS
113 | ruby
114 |
115 | DEPENDENCIES
116 | activerecord (< 4.2)
117 | appraisal
118 | bundler
119 | database_cleaner
120 | mysql2
121 | pry
122 | rails (= 4.1.13)
123 | rake
124 | record-cache!
125 | rspec (~> 3.0)
126 | simplecov
127 | sqlite3
128 | test_after_commit
129 | timecop
130 |
131 | BUNDLED WITH
132 | 1.12.5
133 |
--------------------------------------------------------------------------------
/init.rb:
--------------------------------------------------------------------------------
1 | require 'record_cache'
--------------------------------------------------------------------------------
/lib/record-cache.rb:
--------------------------------------------------------------------------------
1 | require 'record_cache'
--------------------------------------------------------------------------------
/lib/record_cache.rb:
--------------------------------------------------------------------------------
1 | # Record Cache files
2 | require "record_cache/version"
3 | ["query", "version_store", "multi_read",
4 | "strategy/util", "strategy/base", "strategy/unique_index_cache", "strategy/full_table_cache", "strategy/index_cache",
5 | "statistics", "dispatcher", "base"].each do |file|
6 | require File.dirname(__FILE__) + "/record_cache/#{file}.rb"
7 | end
8 |
9 | # Load Data Stores (currently only support for Active Record)
10 | require File.dirname(__FILE__) + "/record_cache/datastore/active_record.rb"
11 |
--------------------------------------------------------------------------------
/lib/record_cache/base.rb:
--------------------------------------------------------------------------------
1 | module RecordCache
2 | # Normal mode
3 | ENABLED = 1
4 | # Do not fetch queries through the cache (but still update the cache after commit)
5 | NO_FETCH = 2
6 | # Completely disable the cache (may lead to stale results in case caching for other workers is not DISABLED)
7 | DISABLED = 3
8 |
9 | module Base
10 | class << self
11 | def included(klass)
12 | klass.class_eval do
13 | extend ClassMethods
14 | include InstanceMethods
15 | end
16 | end
17 |
18 | # The logger instance (Rails.logger if present)
19 | def logger
20 | @logger ||= (rails_logger || ::ActiveRecord::Base.logger)
21 | end
22 |
23 | # Provide a different logger for Record Cache related information
24 | def logger=(logger)
25 | @logger = logger
26 | end
27 |
28 | def rails_logger
29 | defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
30 | end
31 |
32 | # Set the ActiveSupport::Cache::Store instance that contains the current record(group) versions.
33 | # Note that it must point to a single Store shared by all webservers (defaults to Rails.cache)
34 | def version_store=(store)
35 | @version_store = RecordCache::VersionStore.new(RecordCache::MultiRead.test(store))
36 | end
37 |
38 | # The ActiveSupport::Cache::Store instance that contains the current record(group) versions.
39 | # Note that it must point to a single Store shared by all webservers (defaults to Rails.cache)
40 | def version_store
41 | self.version_store = Rails.cache unless @version_store
42 | @version_store
43 | end
44 |
45 | # Register a cache store by id for future reference with the :store option for +cache_records+
46 | # e.g. RecordCache::Base.register_store(:server, ActiveSupport::Cache.lookup_store(:memory_store))
47 | def register_store(id, store)
48 | stores[id] = RecordCache::MultiRead.test(store)
49 | end
50 |
51 | # The hash of registered record stores (store_id => store)
52 | def stores
53 | @stores ||= {}
54 | end
55 |
56 | # To disable the record cache for all models:
57 | # RecordCache::Base.disabled!
58 | # Enable again with:
59 | # RecordCache::Base.enable
60 | def disable!
61 | @status = RecordCache::DISABLED
62 | end
63 |
64 | # Enable record cache
65 | def enable
66 | @status = RecordCache::ENABLED
67 | end
68 |
69 | # Executes the block with caching enabled.
70 | # Useful in testing scenarios.
71 | #
72 | # RecordCache::Base.enabled do
73 | # @foo = Article.find(1)
74 | # @foo.update_attributes(:time_spent => 45)
75 | # @foo = Article.find(1)
76 | # @foo.time_spent.should be_nil
77 | # TimeSpent.last.amount.should == 45
78 | # end
79 | #
80 | def enabled(&block)
81 | previous_status = @status
82 | begin
83 | @status = RecordCache::ENABLED
84 | yield
85 | ensure
86 | @status = previous_status
87 | end
88 | end
89 |
90 | # Retrieve the current status
91 | def status
92 | @status ||= RecordCache::ENABLED
93 | end
94 |
95 | # execute block of code without using the records cache to fetch records
96 | # note that updates are still written to the cache, as otherwise other
97 | # workers may receive stale results.
98 | # To fully disable caching use +disable!+
99 | def without_record_cache(&block)
100 | old_status = status
101 | begin
102 | @status = RecordCache::NO_FETCH
103 | yield
104 | ensure
105 | @status = old_status
106 | end
107 | end
108 | end
109 |
110 | module ClassMethods
111 | # Cache the instances of this model
112 | # generic options:
113 | # :store => the cache store for the instances, e.g. :memory_store, :dalli_store* (default: Rails.cache)
114 | # or one of the store ids defined using +RecordCache::Base.register_store+
115 | # :key => provide a unique shorter key to limit the cache key length (default: model.name)
116 | #
117 | # cache strategy specific options:
118 | # :index => one or more attributes (Symbols) for which the ids are cached for the value of the attribute
119 | #
120 | # Hints:
121 | # - Dalli is a high performance pure Ruby client for accessing memcached servers, see https://github.com/mperham/dalli
122 | # - use :store => :memory_store in case all records can easily fit in server memory
123 | # - use :index => :account_id in case the records are (almost) always queried as a full set per account
124 | # - use :index => :person_id for aggregated has_many associations
125 | def cache_records(options = {})
126 | unless @rc_dispatcher
127 | @rc_dispatcher = RecordCache::Dispatcher.new(self)
128 | # Callback for Data Store specific initialization
129 | record_cache_init
130 |
131 | class << self
132 | alias_method_chain :inherited, :record_cache
133 | end
134 | end
135 | # parse the requested strategies from the given options
136 | @rc_dispatcher.parse(options)
137 | end
138 |
139 | # Returns true if record cache is defined and active for this class
140 | def record_cache?
141 | record_cache && record_cache.instance_variable_get('@base') == self && RecordCache::Base.status == RecordCache::ENABLED
142 | end
143 |
144 | # Returns the RecordCache (class) instance
145 | def record_cache
146 | @rc_dispatcher
147 | end
148 |
149 | def inherited_with_record_cache(subclass)
150 | class << subclass
151 | def record_cache
152 | self.superclass.record_cache
153 | end
154 | end
155 | inherited_without_record_cache(subclass)
156 | end
157 | end
158 |
159 | module InstanceMethods
160 | def record_cache_create
161 | self.class.record_cache.record_change(self, :create) unless RecordCache::Base.status == RecordCache::DISABLED
162 | true
163 | end
164 |
165 | def record_cache_update
166 | self.class.record_cache.record_change(self, :update) unless RecordCache::Base.status == RecordCache::DISABLED
167 | true
168 | end
169 |
170 | def record_cache_destroy
171 | self.class.record_cache.record_change(self, :destroy) unless RecordCache::Base.status == RecordCache::DISABLED
172 | true
173 | end
174 | end
175 |
176 | end
177 | end
178 |
--------------------------------------------------------------------------------
/lib/record_cache/datastore/active_record.rb:
--------------------------------------------------------------------------------
1 | require 'active_record'
2 |
3 | # basic Record Cache functionality
4 | ActiveRecord::Base.send(:include, RecordCache::Base)
5 |
6 | # To be able to fetch records from the cache and invalidate records in the cache
7 | # some internal Active Record methods need to be aliased.
8 | # The downside of using internal methods, is that they may change in different releases,
9 | # hence the following code:
10 | AR_VERSION = "#{ActiveRecord::VERSION::MAJOR}#{ActiveRecord::VERSION::MINOR}"
11 | filename = "#{File.dirname(__FILE__)}/active_record_#{AR_VERSION}.rb"
12 | abort("No support for Active Record version #{AR_VERSION}") unless File.exists?(filename)
13 | require filename
14 |
--------------------------------------------------------------------------------
/lib/record_cache/datastore/active_record_30.rb:
--------------------------------------------------------------------------------
1 | module RecordCache
2 | module ActiveRecord
3 |
4 | module Base
5 | class << self
6 | def included(klass)
7 | klass.extend ClassMethods
8 | klass.class_eval do
9 | class << self
10 | alias_method_chain :find_by_sql, :record_cache
11 | end
12 | end
13 | end
14 | end
15 |
16 | module ClassMethods
17 | # the tests are always run within a transaction, so the threshold is one higher
18 | RC_TRANSACTIONS_THRESHOLD = ENV['RAILS_ENV'] == 'test' ? 1 : 0
19 |
20 | # add cache invalidation hooks on initialization
21 | def record_cache_init
22 | after_commit :record_cache_create, :on => :create, :prepend => true
23 | after_commit :record_cache_update, :on => :update, :prepend => true
24 | after_commit :record_cache_destroy, :on => :destroy, :prepend => true
25 | end
26 |
27 | # Retrieve the records, possibly from cache
28 | def find_by_sql_with_record_cache(sql)
29 | # shortcut, no caching please
30 | return find_by_sql_without_record_cache(sql) unless record_cache?
31 |
32 | arel = sql.instance_variable_get(:@arel)
33 | sanitized_sql = sanitize_sql(sql)
34 |
35 | records = if connection.query_cache_enabled
36 | connection.query_cache["rc/#{sanitized_sql}"] ||= try_record_cache(sanitized_sql, arel)
37 | elsif connection.open_transactions > RC_TRANSACTIONS_THRESHOLD
38 | connection.select_all(sanitized_sql, "#{name} Load")
39 | else
40 | try_record_cache(sanitized_sql, arel)
41 | end
42 | records.collect! { |record| instantiate(record) } if records[0].is_a?(Hash)
43 | records
44 | end
45 |
46 | def try_record_cache(sql, arel)
47 | query = arel && arel.respond_to?(:ast) ? RecordCache::Arel::QueryVisitor.new.accept(arel.ast) : nil
48 | record_cache.fetch(query) do
49 | connection.select_all(sql, "#{name} Load")
50 | end
51 | end
52 |
53 | end
54 |
55 | end
56 | end
57 |
58 | module Arel
59 |
60 | # The method .find_by_sql is used to actually
61 | # retrieve the data from the DB.
62 | # Unfortunately the ActiveRelation record is not accessible from
63 | # there, so it is piggy-back'd in the SQL string.
64 | module TreeManager
65 | def self.included(klass)
66 | klass.extend ClassMethods
67 | klass.send(:include, InstanceMethods)
68 | klass.class_eval do
69 | alias_method_chain :to_sql, :record_cache
70 | end
71 | end
72 |
73 | module ClassMethods
74 | end
75 |
76 | module InstanceMethods
77 | def to_sql_with_record_cache
78 | sql = to_sql_without_record_cache
79 | sql.instance_variable_set(:@arel, self)
80 | sql
81 | end
82 | end
83 | end
84 |
85 | # Visitor for the ActiveRelation to extract a simple cache query
86 | # Only accepts single select queries with equality where statements
87 | # Rejects queries with grouping / having / offset / etc.
88 | class QueryVisitor < ::Arel::Visitors::Visitor
89 | DESC = "DESC".freeze
90 | COMMA = ",".freeze
91 |
92 | def initialize
93 | super()
94 | @cacheable = true
95 | @query = ::RecordCache::Query.new
96 | end
97 |
98 | def accept ast
99 | super
100 | @cacheable && !ast.lock ? @query : nil
101 | end
102 |
103 | private
104 |
105 | def not_cacheable o
106 | @cacheable = false
107 | end
108 |
109 | def skip o
110 | end
111 |
112 | alias :visit_Arel_Nodes_TableAlias :not_cacheable
113 |
114 | alias :visit_Arel_Nodes_Lock :not_cacheable
115 |
116 | alias :visit_Arel_Nodes_Sum :not_cacheable
117 | alias :visit_Arel_Nodes_Max :not_cacheable
118 | alias :visit_Arel_Nodes_Min :not_cacheable
119 | alias :visit_Arel_Nodes_Avg :not_cacheable
120 | alias :visit_Arel_Nodes_Count :not_cacheable
121 |
122 | alias :visit_Arel_Nodes_StringJoin :not_cacheable
123 | alias :visit_Arel_Nodes_InnerJoin :not_cacheable
124 | alias :visit_Arel_Nodes_OuterJoin :not_cacheable
125 |
126 | alias :visit_Arel_Nodes_DeleteStatement :not_cacheable
127 | alias :visit_Arel_Nodes_InsertStatement :not_cacheable
128 | alias :visit_Arel_Nodes_UpdateStatement :not_cacheable
129 |
130 | alias :visit_Arel_Nodes_Except :not_cacheable
131 | alias :visit_Arel_Nodes_Exists :not_cacheable
132 | alias :visit_Arel_Nodes_Intersect :not_cacheable
133 | alias :visit_Arel_Nodes_Union :not_cacheable
134 | alias :visit_Arel_Nodes_UnionAll :not_cacheable
135 |
136 | alias :visit_Arel_Nodes_As :skip
137 |
138 | alias :unary :not_cacheable
139 | alias :visit_Arel_Nodes_Group :unary
140 | alias :visit_Arel_Nodes_Having :unary
141 | alias :visit_Arel_Nodes_Not :unary
142 | alias :visit_Arel_Nodes_On :unary
143 | alias :visit_Arel_Nodes_UnqualifiedColumn :unary
144 |
145 | def visit_Arel_Nodes_Offset o
146 | @cacheable = false unless o.expr == 0
147 | end
148 |
149 | def visit_Arel_Nodes_Values o
150 | visit o.expressions if @cacheable
151 | end
152 |
153 | def visit_Arel_Nodes_Limit o
154 | @query.limit = o.expr
155 | end
156 | alias :visit_Arel_Nodes_Top :visit_Arel_Nodes_Limit
157 |
158 | GROUPING_EQUALS_REGEXP = /^\W?(\w*)\W?\.\W?(\w*)\W?\s*=\s*(\d+)$/ # `calendars`.account_id = 5
159 | GROUPING_IN_REGEXP = /^^\W?(\w*)\W?\.\W?(\w*)\W?\s*IN\s*\(([\d\s,]+)\)$/ # `service_instances`.`id` IN (118,80,120,82)
160 | def visit_Arel_Nodes_Grouping o
161 | return unless @cacheable
162 | if @table_name && o.expr =~ GROUPING_EQUALS_REGEXP && $1 == @table_name
163 | @cacheable = @query.where($2, $3.to_i)
164 | elsif @table_name && o.expr =~ GROUPING_IN_REGEXP && $1 == @table_name
165 | @cacheable = @query.where($2, $3.split(',').map(&:to_i))
166 | else
167 | @cacheable = false
168 | end
169 | end
170 |
171 | def visit_Arel_Nodes_SelectCore o
172 | @cacheable = false unless o.groups.empty?
173 | visit o.froms if @cacheable
174 | visit o.wheres if @cacheable
175 | @cacheable = o.projections.none?{ |projection| projection.to_s =~ /distinct/i } unless o.projections.empty? if @cacheable
176 | end
177 |
178 | def visit_Arel_Nodes_SelectStatement o
179 | @cacheable = false if o.cores.size > 1
180 | if @cacheable
181 | visit o.offset
182 | o.orders.map { |x| handle_order_by(visit x) } if @cacheable && o.orders.size > 0
183 | visit o.limit
184 | visit o.cores
185 | end
186 | end
187 |
188 | ORDER_BY_REGEXP = /^\s*([\w\.]*)\s*(|ASC|asc|DESC|desc)\s*$/ # people.id DESC
189 | def handle_order_by(order)
190 | order.to_s.split(COMMA).each do |o|
191 | # simple sort order (+people.id+ can be replaced by +id+, as joins are not allowed anyways)
192 | if o.match(ORDER_BY_REGEXP)
193 | asc = $2.upcase == DESC ? false : true
194 | @query.order_by($1.split('.').last, asc)
195 | else
196 | @cacheable = false
197 | end
198 | end
199 | end
200 |
201 | def visit_Arel_Table o
202 | @table_name = o.name
203 | end
204 |
205 | def visit_Arel_Nodes_Ordering o
206 | [visit(o.expr), o.descending]
207 | end
208 |
209 | def visit_Arel_Attributes_Attribute o
210 | o.name.to_sym
211 | end
212 | alias :visit_Arel_Attributes_Integer :visit_Arel_Attributes_Attribute
213 | alias :visit_Arel_Attributes_Float :visit_Arel_Attributes_Attribute
214 | alias :visit_Arel_Attributes_String :visit_Arel_Attributes_Attribute
215 | alias :visit_Arel_Attributes_Time :visit_Arel_Attributes_Attribute
216 | alias :visit_Arel_Attributes_Boolean :visit_Arel_Attributes_Attribute
217 | alias :visit_Arel_Attributes_Decimal :visit_Arel_Attributes_Attribute
218 |
219 | def visit_Arel_Nodes_Equality o
220 | key, value = visit(o.left), visit(o.right)
221 | # p " =====> equality found: #{key.inspect}@#{key.class.name} => #{value.inspect}@#{value.class.name}"
222 | @query.where(key, value)
223 | end
224 | alias :visit_Arel_Nodes_In :visit_Arel_Nodes_Equality
225 |
226 | def visit_Arel_Nodes_And o
227 | visit(o.left)
228 | visit(o.right)
229 | end
230 |
231 | alias :visit_Arel_Nodes_Or :not_cacheable
232 | alias :visit_Arel_Nodes_NotEqual :not_cacheable
233 | alias :visit_Arel_Nodes_GreaterThan :not_cacheable
234 | alias :visit_Arel_Nodes_GreaterThanOrEqual :not_cacheable
235 | alias :visit_Arel_Nodes_Assignment :not_cacheable
236 | alias :visit_Arel_Nodes_LessThan :not_cacheable
237 | alias :visit_Arel_Nodes_LessThanOrEqual :not_cacheable
238 | alias :visit_Arel_Nodes_Between :not_cacheable
239 | alias :visit_Arel_Nodes_NotIn :not_cacheable
240 | alias :visit_Arel_Nodes_DoesNotMatch :not_cacheable
241 | alias :visit_Arel_Nodes_Matches :not_cacheable
242 |
243 | def visit_Fixnum o
244 | o.to_i
245 | end
246 | alias :visit_Bignum :visit_Fixnum
247 |
248 | def visit_Symbol o
249 | o.to_sym
250 | end
251 |
252 | def visit_Object o
253 | o
254 | end
255 | alias :visit_Arel_Nodes_SqlLiteral :visit_Object
256 | alias :visit_Arel_SqlLiteral :visit_Object # This is deprecated
257 | alias :visit_String :visit_Object
258 | alias :visit_NilClass :visit_Object
259 | alias :visit_TrueClass :visit_Object
260 | alias :visit_FalseClass :visit_Object
261 | alias :visit_Arel_SqlLiteral :visit_Object
262 | alias :visit_BigDecimal :visit_Object
263 | alias :visit_Float :visit_Object
264 | alias :visit_Time :visit_Object
265 | alias :visit_Date :visit_Object
266 | alias :visit_DateTime :visit_Object
267 | alias :visit_Hash :visit_Object
268 |
269 | def visit_Array o
270 | o.map{ |x| visit x }
271 | end
272 | end
273 | end
274 |
275 | end
276 |
277 | module RecordCache
278 |
279 | # Patch ActiveRecord::Relation to make sure update_all will invalidate all referenced records
280 | module ActiveRecord
281 | module UpdateAll
282 | class << self
283 | def included(klass)
284 | klass.extend ClassMethods
285 | klass.send(:include, InstanceMethods)
286 | klass.class_eval do
287 | alias_method_chain :update_all, :record_cache
288 | end
289 | end
290 | end
291 |
292 | module ClassMethods
293 | end
294 |
295 | module InstanceMethods
296 | def __find_in_clause(sub_select)
297 | return nil unless sub_select.arel.constraints.count == 1
298 | constraint = sub_select.arel.constraints.first
299 | return constraint if constraint.is_a?(::Arel::Nodes::In) # directly an IN clause
300 | return nil unless constraint.respond_to?(:children) && constraint.children.count == 1
301 | constraint = constraint.children.first
302 | return constraint if constraint.is_a?(::Arel::Nodes::In) # AND with IN clause
303 | nil
304 | end
305 |
306 | def update_all_with_record_cache(updates, conditions = nil, options = {})
307 | result = update_all_without_record_cache(updates, conditions, options)
308 |
309 | if record_cache?
310 | # when this condition is met, the arel.update method will be called on the current scope, see ActiveRecord::Relation#update_all
311 | unless conditions || options.present? || @limit_value.present? != @order_values.present?
312 | # get all attributes that contain a unique index for this model
313 | unique_index_attributes = RecordCache::Strategy::UniqueIndexCache.attributes(self)
314 | # go straight to SQL result (without instantiating records) for optimal performance
315 | RecordCache::Base.version_store.multi do
316 | sub_select = select(unique_index_attributes.map(&:to_s).join(','))
317 | in_clause = __find_in_clause(sub_select)
318 | if unique_index_attributes.size == 1 && in_clause &&
319 | in_clause.left.try(:name).to_s == unique_index_attributes.first.to_s
320 | # common case where the unique index is the (only) constraint on the query: SELECT id FROM people WHERE id in (...)
321 | attribute = unique_index_attributes.first
322 | in_clause.right.each do |value|
323 | record_cache.invalidate(attribute, value)
324 | end
325 | else
326 | connection.execute(sub_select.to_sql).each do |row|
327 | # invalidate the unique index for all attributes
328 | unique_index_attributes.each_with_index do |attribute, index|
329 | record_cache.invalidate(attribute, (row.is_a?(Hash) ? row[attribute.to_s] : row[index]))
330 | end
331 | end
332 | end
333 | end
334 | end
335 | end
336 |
337 | result
338 | end
339 | end
340 | end
341 | end
342 |
343 | # Patch ActiveRecord::Associations::HasManyAssociation to make sure the index_cache is updated when records are
344 | # deleted from the collection
345 | module ActiveRecord
346 | module HasMany
347 | class << self
348 | def included(klass)
349 | klass.extend ClassMethods
350 | klass.send(:include, InstanceMethods)
351 | klass.class_eval do
352 | alias_method_chain :delete_records, :record_cache
353 | end
354 | end
355 | end
356 |
357 | module ClassMethods
358 | end
359 |
360 | module InstanceMethods
361 | def delete_records_with_record_cache(records)
362 | # invalidate :id cache for all records
363 | records.each{ |record| record.class.record_cache.invalidate(record.id) if record.class.record_cache? unless record.new_record? }
364 | # invalidate the referenced class for the attribute/value pair on the index cache
365 | @reflection.klass.record_cache.invalidate(@reflection.primary_key_name.to_sym, @owner.id) if @reflection.klass.record_cache?
366 | delete_records_without_record_cache(records)
367 | end
368 | end
369 | end
370 | end
371 |
372 | end
373 |
374 | ActiveRecord::Base.send(:include, RecordCache::ActiveRecord::Base)
375 | Arel::TreeManager.send(:include, RecordCache::Arel::TreeManager)
376 | ActiveRecord::Relation.send(:include, RecordCache::ActiveRecord::UpdateAll)
377 | ActiveRecord::Associations::HasManyAssociation.send(:include, RecordCache::ActiveRecord::HasMany)
378 |
--------------------------------------------------------------------------------
/lib/record_cache/dispatcher.rb:
--------------------------------------------------------------------------------
1 | module RecordCache
2 |
3 | # Every model that calls cache_records will receive an instance of this class
4 | # accessible through +.record_cache+
5 | #
6 | # The dispatcher is responsible for dispatching queries, record_changes and invalidation calls
7 | # to the appropriate cache strategies.
8 | class Dispatcher
9 |
10 | # Retrieve all strategies ordered by fastest strategy first.
11 | #
12 | # Roll your own cache strategies by extending from +RecordCache::Strategy::Base+,
13 | # and registering it here +RecordCache::Dispatcher.strategy_classes << MyStrategy+
14 | def self.strategy_classes
15 | @strategy_classes ||= [RecordCache::Strategy::UniqueIndexCache, RecordCache::Strategy::FullTableCache, RecordCache::Strategy::IndexCache]
16 | end
17 |
18 | def initialize(base)
19 | @base = base
20 | @strategy_by_attribute = {}
21 | end
22 |
23 | # Parse the options provided to the cache_records method and create the appropriate cache strategies.
24 | def parse(options)
25 | # find the record store, possibly based on the :store option
26 | store = record_store(options.delete(:store))
27 | # dispatch the parse call to all known strategies
28 | Dispatcher.strategy_classes.map{ |klass| klass.parse(@base, store, options) }.flatten.compact.each do |strategy|
29 | raise "Multiple record cache definitions found for '#{strategy.attribute}' on #{@base.name}" if @strategy_by_attribute[strategy.attribute]
30 | # and keep track of all strategies
31 | @strategy_by_attribute[strategy.attribute] = strategy
32 | end
33 | # make sure the strategies are ordered again on next call to +ordered_strategies+
34 | @ordered_strategies = nil
35 | end
36 |
37 | # Retrieve the caching strategy for the given attribute
38 | def [](attribute)
39 | @strategy_by_attribute[attribute]
40 | end
41 |
42 | # retrieve the record(s) based on the given query (check with cacheable?(query) first)
43 | def fetch(query, &block)
44 | strategy = query && ordered_strategies.detect { |strategy| strategy.cacheable?(query) }
45 | strategy ? strategy.fetch(query) : yield
46 | end
47 |
48 | # Update the version store and the record store (used by callbacks)
49 | # @param record the updated record (possibly with
50 | # @param action one of :create, :update or :destroy
51 | def record_change(record, action)
52 | # skip unless something has actually changed
53 | return if action == :update && record.previous_changes.empty?
54 | # dispatch the record change to all known strategies
55 | @strategy_by_attribute.values.each { |strategy| strategy.record_change(record, action) }
56 | end
57 |
58 | # Explicitly invalidate one or more records
59 | # @param: strategy: the id of the strategy to invalidate (defaults to +:id+)
60 | # @param: value: the value to send to the invalidate method of the chosen strategy
61 | def invalidate(strategy, value = nil)
62 | (value = strategy; strategy = :id) unless strategy.is_a?(Symbol)
63 | # call the invalidate method of the chosen strategy
64 | @strategy_by_attribute[strategy].invalidate(value) if @strategy_by_attribute[strategy]
65 | end
66 |
67 | private
68 |
69 | # Find the cache store for the records (using the :store option)
70 | def record_store(store)
71 | store = RecordCache::Base.stores[store] || ActiveSupport::Cache.lookup_store(store) if store.is_a?(Symbol)
72 | store ||= Rails.cache if defined?(::Rails)
73 | store ||= ActiveSupport::Cache.lookup_store(:memory_store)
74 | RecordCache::MultiRead.test(store)
75 | end
76 |
77 | # Retrieve all strategies ordered by the fastest strategy first (currently :id, :unique, :index)
78 | def ordered_strategies
79 | @ordered_strategies ||= begin
80 | last_index = Dispatcher.strategy_classes.size
81 | # sort the strategies based on the +strategy_classes+ index
82 | ordered = @strategy_by_attribute.values.sort do |x,y|
83 | (Dispatcher.strategy_classes.index(x.class) || last_index) <=> (Dispatcher.strategy_classes.index(y.class) || last_index)
84 | end
85 | ordered
86 | end
87 | end
88 |
89 | end
90 | end
91 |
--------------------------------------------------------------------------------
/lib/record_cache/multi_read.rb:
--------------------------------------------------------------------------------
1 | # This class will delegate read_multi to sequential read calls in case read_multi is not supported.
2 | #
3 | # If a particular Store Class does support read_multi, but is somehow slower because of a bug,
4 | # you can disable read_multi by calling:
5 | # RecordCache::MultiRead.disable(ActiveSupport::Cache::DalliStore)
6 | #
7 | # Important: Because of a bug in Dalli, read_multi is quite slow on some machines.
8 | # @see https://github.com/mperham/dalli/issues/106
9 | require "set"
10 |
11 | module RecordCache
12 | module MultiRead
13 | @tested = Set.new
14 | @disabled_klass_names = Set.new
15 |
16 | class << self
17 |
18 | # Disable multi_read for a particular Store, e.g.
19 | # RecordCache::MultiRead.disable(ActiveSupport::Cache::DalliStore)
20 | def disable(klass)
21 | @disabled_klass_names << klass.name
22 | end
23 |
24 | # Test the store if it supports read_multi calls
25 | # If not, delegate multi_read calls to normal read calls
26 | def test(store)
27 | return store if @tested.include?(store)
28 | @tested << store
29 | override_read_multi(store) unless read_multi_supported?(store)
30 | store
31 | end
32 |
33 | private
34 |
35 | def read_multi_supported?(store)
36 | return false if @disabled_klass_names.include?(store.class.name)
37 | begin
38 | store.read_multi('a', 'b')
39 | true
40 | rescue Exception => ignore
41 | false
42 | end
43 | end
44 |
45 | # delegate read_multi to normal read calls
46 | def override_read_multi(store)
47 | def store.read_multi(*keys)
48 | keys.inject({}){ |h,key| h[key] = self.read(key); h}
49 | end
50 | end
51 | end
52 | end
53 | end
54 |
55 | # Exposes Dalli's +multi+ functionality in ActiveSupport::Cache implementation of Dalli
56 | module ActiveSupport
57 | module Cache
58 | class DalliStore
59 | def multi(&block)
60 | dalli.multi(&block)
61 | end
62 | end
63 | end
64 | end if defined?(::ActiveSupport::Cache::DalliStore)
65 |
--------------------------------------------------------------------------------
/lib/record_cache/query.rb:
--------------------------------------------------------------------------------
1 | module RecordCache
2 |
3 | # Container for the Query parameters
4 | class Query
5 | attr_reader :wheres, :sort_orders, :limit
6 |
7 | def initialize(equality = nil)
8 | @wheres = equality || {}
9 | @sort_orders = []
10 | @limit = nil
11 | @where_values = {}
12 | end
13 |
14 | # Set equality of an attribute (usually found in where clause)
15 | def where(attribute, values)
16 | @wheres[attribute.to_sym] = values if attribute
17 | end
18 |
19 | # Retrieve the values for the given attribute from the where statements
20 | # Returns nil if no the attribute is not present
21 | # @param attribute: the attribute name
22 | # @param type: the type to be retrieved, :integer or :string (defaults to :integer)
23 | def where_values(attribute, type = :integer)
24 | return @where_values[attribute] if @where_values.key?(attribute)
25 | @where_values[attribute] ||= array_of_values(@wheres[attribute], type)
26 | end
27 |
28 | # Retrieve the single value for the given attribute from the where statements
29 | # Returns nil if the attribute is not present, or if it contains multiple values
30 | # @param attribute: the attribute name
31 | # @param type: the type to be retrieved, :integer or :string (defaults to :integer)
32 | def where_value(attribute, type = :integer)
33 | values = where_values(attribute, type)
34 | return nil unless values && values.size == 1
35 | values.first
36 | end
37 |
38 | # Add a sort order to the query
39 | def order_by(attribute, ascending = true)
40 | @sort_orders << [attribute.to_s, ascending]
41 | end
42 |
43 | def sorted?
44 | @sort_orders.size > 0
45 | end
46 |
47 | def limit=(limit)
48 | @limit = limit.to_i
49 | end
50 |
51 | # DEPRECATED: retrieve a unique key for this Query (used in RequestCache)
52 | def cache_key
53 | @cache_key ||= generate_key
54 | end
55 |
56 | # DEPRECATED
57 | def to_s
58 | s = "SELECT "
59 | s << @wheres.map{|k,v| "#{k} = #{v.inspect}"}.join(" AND ")
60 | if sorted?
61 | order_by_clause = @sort_orders.map{|attr,asc| "#{attr} #{asc ? 'ASC' : 'DESC'}"}.join(', ')
62 | s << " ORDER_BY #{order_by_clause}"
63 | end
64 | s << " LIMIT #{@limit}" if @limit
65 | s
66 | end
67 |
68 | private
69 |
70 | def generate_key
71 | key = ""
72 | key << @limit.to_s if @limit
73 | key << @sort_orders.map{|attr,asc| "#{asc ? '+' : '-'}#{attr}"}.join if sorted?
74 | if @wheres.any?
75 | key << "?"
76 | key << @wheres.map{|k,v| "#{k}=#{v.inspect}"}.join("&")
77 | end
78 | key
79 | end
80 |
81 | def array_of_values(values, type)
82 | return nil unless values
83 | values = values.is_a?(Array) ? values.dup : [values]
84 | values.compact!
85 | if type == :integer
86 | values = values.map{|value| value.to_i} unless values.first.is_a?(Fixnum)
87 | return nil unless values.all?{ |value| value && value > 0 } # all values must be positive integers
88 | elsif type == :string
89 | values = values.map{|value| value.to_s} unless values.first.is_a?(String)
90 | end
91 | values
92 | end
93 |
94 | end
95 | end
96 |
--------------------------------------------------------------------------------
/lib/record_cache/statistics.rb:
--------------------------------------------------------------------------------
1 | module RecordCache
2 |
3 | # Collect cache hit/miss statistics for each cache strategy
4 | module Statistics
5 |
6 | class << self
7 |
8 | # returns +true+ if statistics need to be collected
9 | def active?
10 | !!@active
11 | end
12 |
13 | # start statistics collection
14 | def start
15 | @active = true
16 | end
17 |
18 | # stop statistics collection
19 | def stop
20 | @active = false
21 | end
22 |
23 | # toggle statistics collection
24 | def toggle
25 | @active = !@active
26 | end
27 |
28 | # reset all statistics
29 | def reset!(base = nil)
30 | stats = find(base).values
31 | stats = stats.map(&:values).flatten unless base # flatten hash of hashes in case base was nil
32 | stats.each{ |s| s.reset! }
33 | end
34 |
35 | # Retrieve the statistics for the given base and attribute
36 | # Returns a hash { => => { => options[:ttl] } : {}
16 | end
17 |
18 | # Retrieve the +attribute+ for this strategy (unique per model).
19 | # May be a non-existing attribute in case a cache is not based on a single attribute.
20 | def attribute
21 | @attribute
22 | end
23 |
24 | # Fetch all records and sort and filter locally
25 | def fetch(query)
26 | records = fetch_records(query)
27 | Util.filter!(records, query.wheres) if query.wheres.size > 0
28 | Util.sort!(records, query.sort_orders) if query.sorted?
29 | records = records[0..query.limit-1] if query.limit
30 | records
31 | end
32 |
33 | # Can the cache retrieve the records based on this query?
34 | def cacheable?(query)
35 | raise NotImplementedError
36 | end
37 |
38 | # Handle create/update/destroy (use record.previous_changes to find the old values in case of an update)
39 | def record_change(record, action)
40 | raise NotImplementedError
41 | end
42 |
43 | # Handle invalidation call
44 | def invalidate(id)
45 | version_store.renew(cache_key(id), version_opts)
46 | end
47 |
48 | protected
49 |
50 | def fetch_records(query)
51 | raise NotImplementedError
52 | end
53 |
54 | # ------------------------- Utility methods ----------------------------
55 |
56 | # retrieve the version store (unique store for the whole application)
57 | def version_store
58 | RecordCache::Base.version_store
59 | end
60 |
61 | # should be used when calling version_store.renew(..., version_opts)
62 | def version_opts
63 | @version_opts
64 | end
65 |
66 | # retrieve the record store (store for records for this cache strategy)
67 | def record_store
68 | @record_store
69 | end
70 |
71 | # find the statistics for this cache strategy
72 | def statistics
73 | @statistics ||= RecordCache::Statistics.find(@base, @attribute)
74 | end
75 |
76 | # retrieve the cache key for the given id, e.g. rc/person/14
77 | def cache_key(id)
78 | "#{@cache_key_prefix}#{id}"
79 | end
80 |
81 | # retrieve the versioned record key, e.g. rc/person/14v1
82 | def versioned_key(cache_key, version)
83 | "#{cache_key}v#{version.to_s}"
84 | end
85 |
86 | private
87 |
88 | # add default implementation for multi support to perform multiple cache calls in a pipelined fashion
89 | def with_multi_support(cache_store)
90 | unless cache_store.respond_to?(:multi)
91 | cache_store.send(:define_singleton_method, :multi) do |&block|
92 | block.call
93 | end
94 | end
95 | cache_store
96 | end
97 |
98 | end
99 | end
100 | end
101 |
--------------------------------------------------------------------------------
/lib/record_cache/strategy/full_table_cache.rb:
--------------------------------------------------------------------------------
1 | module RecordCache
2 | module Strategy
3 | class FullTableCache < Base
4 | FULL_TABLE = 'full-table'
5 |
6 | # parse the options and return (an array of) instances of this strategy
7 | def self.parse(base, record_store, options)
8 | return nil unless options[:full_table]
9 | return nil unless base.table_exists?
10 |
11 | FullTableCache.new(base, :full_table, record_store, options)
12 | end
13 |
14 | # Can the cache retrieve the records based on this query?
15 | def cacheable?(query)
16 | true
17 | end
18 |
19 | # Clear the cache on any record change
20 | def record_change(record, action)
21 | version_store.delete(cache_key(FULL_TABLE))
22 | end
23 |
24 | protected
25 |
26 | # retrieve the record(s) with the given id(s) as an array
27 | def fetch_records(query)
28 | key = cache_key(FULL_TABLE)
29 | # retrieve the current version of the records
30 | current_version = version_store.current(key)
31 | # get the records from the cache if there is a current version
32 | records = current_version ? from_cache(key, current_version) : nil
33 | # logging (only in debug mode!) and statistics
34 | log_full_table_cache_hit(key, records) if RecordCache::Base.logger.debug?
35 | statistics.add(1, records ? 1 : 0) if statistics.active?
36 | # no records found?
37 | unless records
38 | # renew the version in case the version was not known
39 | current_version ||= version_store.renew_for_read(key, version_opts)
40 | # retrieve all records from the DB
41 | records = from_db(key, current_version)
42 | end
43 | # return the array
44 | records
45 | end
46 |
47 | def cache_key(id)
48 | super(FULL_TABLE)
49 | end
50 |
51 | private
52 |
53 | # ---------------------------- Querying ------------------------------------
54 |
55 | # retrieve the records from the cache with the given keys
56 | def from_cache(key, version)
57 | records = record_store.read(versioned_key(key, version))
58 | records.map{ |record| Util.deserialize(record) } if records
59 | end
60 |
61 | # retrieve the records with the given ids from the database
62 | def from_db(key, version)
63 | RecordCache::Base.without_record_cache do
64 | # retrieve the records from the database
65 | records = @base.all.to_a
66 | # write all records to the cache
67 | record_store.write(versioned_key(key, version), records.map{ |record| Util.serialize(record) })
68 | records
69 | end
70 | end
71 |
72 | # ------------------------- Utility methods ----------------------------
73 |
74 | # log cache hit/miss to debug log
75 | def log_full_table_cache_hit(key, records)
76 | RecordCache::Base.logger.debug{ "FullTableCache #{records ? 'hit' : 'miss'} for model #{@base.name}" }
77 | end
78 |
79 | end
80 | end
81 | end
82 |
--------------------------------------------------------------------------------
/lib/record_cache/strategy/index_cache.rb:
--------------------------------------------------------------------------------
1 | module RecordCache
2 | module Strategy
3 | class IndexCache < Base
4 |
5 | # parse the options and return (an array of) instances of this strategy
6 | def self.parse(base, record_store, options)
7 | return nil unless options[:index]
8 | return nil unless base.table_exists?
9 |
10 | raise "Index cache '#{options[:index].inspect}' on #{base.name} is redundant as index cache queries are handled by the full table cache." if options[:full_table]
11 | raise ":index => #{options[:index].inspect} option cannot be used unless 'id' is present on #{base.name}" unless base.columns_hash['id']
12 | [options[:index]].flatten.compact.map do |attribute|
13 | type = base.columns_hash[attribute.to_s].try(:type)
14 | raise "No column found for index '#{attribute}' on #{base.name}." unless type
15 | raise "Incorrect type (expected integer, found #{type}) for index '#{attribute}' on #{base.name}." unless type == :integer
16 | IndexCache.new(base, attribute, record_store, options)
17 | end
18 | end
19 |
20 | def initialize(base, attribute, record_store, options)
21 | super
22 | @cache_key_prefix << "#{attribute}="
23 | end
24 |
25 | # Can the cache retrieve the records based on this query?
26 | def cacheable?(query)
27 | # allow limit of 1 for has_one
28 | query.where_value(@attribute) && (query.limit.nil? || (query.limit == 1 && !query.sorted?))
29 | end
30 |
31 | # Handle create/update/destroy (use record.previous_changes to find the old values in case of an update)
32 | def record_change(record, action)
33 | if action == :destroy
34 | remove_from_index(record.send(@attribute), record.id)
35 | elsif action == :create
36 | add_to_index(record.send(@attribute), record.id)
37 | else
38 | index_change = record.previous_changes[@attribute.to_s] || record.previous_changes[@attribute]
39 | return unless index_change
40 | remove_from_index(index_change[0], record.id)
41 | add_to_index(index_change[1], record.id)
42 | end
43 | end
44 |
45 | protected
46 |
47 | # retrieve the record(s) based on the given query
48 | def fetch_records(query)
49 | value = query.where_value(@attribute)
50 | # make sure CacheCase.filter! does not see this where clause anymore
51 | query.wheres.delete(@attribute)
52 | # retrieve the cache key for this index and value
53 | key = cache_key(value)
54 | # retrieve the current version of the ids list
55 | current_version = version_store.current(key)
56 | # create the versioned key, renew the version in case it was missing in the version store
57 | versioned_key = versioned_key(key, current_version || version_store.renew_for_read(key, version_opts))
58 | # retrieve the ids from the local cache based on the current version from the version store
59 | ids = current_version ? fetch_ids_from_cache(versioned_key) : nil
60 | # logging (only in debug mode!) and statistics
61 | log_cache_hit(versioned_key, ids) if RecordCache::Base.logger.debug?
62 | statistics.add(1, ids ? 1 : 0) if statistics.active?
63 | # retrieve the ids from the DB if the result was not fresh
64 | ids = fetch_ids_from_db(versioned_key, value) unless ids
65 | # use the IdCache to retrieve the records based on the ids
66 | @base.record_cache[:id].send(:fetch_records, ::RecordCache::Query.new({:id => ids}))
67 | end
68 |
69 | private
70 |
71 | # ---------------------------- Querying ------------------------------------
72 |
73 | # Retrieve the ids from the local cache
74 | def fetch_ids_from_cache(versioned_key)
75 | record_store.read(versioned_key)
76 | end
77 |
78 | # retrieve the ids from the database and update the local cache
79 | def fetch_ids_from_db(versioned_key, value)
80 | RecordCache::Base.without_record_cache do
81 | # go straight to SQL result for optimal performance
82 | sql = @base.select('id').where(@attribute => value).to_sql
83 | ids = []; @base.connection.execute(sql).each{ |row| ids << (row.is_a?(Hash) ? row['id'] : row.first).to_i }
84 | record_store.write(versioned_key, ids)
85 | ids
86 | end
87 | end
88 |
89 | # ---------------------------- Local Record Changes ---------------------------------
90 |
91 | # add one record(id) to the index with the given value
92 | def add_to_index(value, id)
93 | renew_version(value.to_i) { |ids| ids << id } if value
94 | end
95 |
96 | # remove one record(id) from the index with the given value
97 | def remove_from_index(value, id)
98 | renew_version(value.to_i) { |ids| ids.delete(id) } if value
99 | end
100 |
101 | # renew the version store and update the local store
102 | def renew_version(value, &block)
103 | # retrieve local version and increment version store
104 | key = cache_key(value)
105 | old_version = version_store.current(key)
106 | new_version = version_store.renew(key, true, version_opts)
107 | # try to update the ids list based on the last version
108 | ids = fetch_ids_from_cache(versioned_key(key, old_version))
109 | if ids
110 | ids = Array.new(ids)
111 | yield ids
112 | record_store.write(versioned_key(key, new_version), ids)
113 | end
114 | end
115 |
116 | # ------------------------- Utility methods ----------------------------
117 |
118 | # log cache hit/miss to debug log
119 | def log_cache_hit(key, ids)
120 | RecordCache::Base.logger.debug{ "IndexCache #{ids ? 'hit' : 'miss'} for #{key}: found #{ids ? ids.size : 'no'} ids" }
121 | end
122 | end
123 | end
124 | end
125 |
--------------------------------------------------------------------------------
/lib/record_cache/strategy/unique_index_cache.rb:
--------------------------------------------------------------------------------
1 | module RecordCache
2 | module Strategy
3 | class UniqueIndexCache < Base
4 |
5 | # All attributes with a unique index for the given model
6 | def self.attributes(base)
7 | (@attributes ||= {})[base.name] ||= []
8 | end
9 |
10 | # parse the options and return (an array of) instances of this strategy
11 | def self.parse(base, record_store, options)
12 | return nil unless base.table_exists?
13 |
14 | attributes = [options[:unique_index]].flatten.compact
15 | # add unique index for :id by default
16 | attributes << :id if base.columns_hash['id'] unless base.record_cache[:id]
17 | attributes.uniq! # in development mode, do not keep adding 'id' to the list of unique index attributes
18 | return nil if attributes.empty?
19 | attributes.map do |attribute|
20 | type = base.columns_hash[attribute.to_s].try(:type)
21 | raise "No column found for unique index '#{index}' on #{base.name}." unless type
22 | raise "Incorrect type (expected string or integer, found #{type}) for unique index '#{attribute}' on #{base.name}." unless type == :string || type == :integer
23 | UniqueIndexCache.new(base, attribute, record_store, options, type)
24 | end
25 | end
26 |
27 | def initialize(base, attribute, record_store, options, type)
28 | super(base, attribute, record_store, options)
29 | # remember the attributes with a unique index
30 | UniqueIndexCache.attributes(base) << attribute
31 | # for unique indexes that are not on the :id column, use key: rc//:
32 | @cache_key_prefix << "#{attribute}:" unless attribute == :id
33 | @type = type
34 | end
35 |
36 | # Can the cache retrieve the records based on this query?
37 | def cacheable?(query)
38 | values = query.where_values(@attribute, @type)
39 | values && (query.limit.nil? || (query.limit == 1 && values.size == 1))
40 | end
41 |
42 | # Update the version store and the record store
43 | def record_change(record, action)
44 | key = cache_key(record.send(@attribute))
45 | if action == :destroy
46 | version_store.delete(key)
47 | else
48 | # update the version store and add the record to the cache
49 | new_version = version_store.renew(key, version_opts)
50 | record_store.write(versioned_key(key, new_version), Util.serialize(record))
51 | end
52 | end
53 |
54 | protected
55 |
56 | # retrieve the record(s) with the given id(s) as an array
57 | def fetch_records(query)
58 | ids = query.where_values(@attribute, @type)
59 | query.wheres.delete(@attribute) # make sure CacheCase.filter! does not see this where anymore
60 | id_to_key_map = ids.inject({}){|h,id| h[id] = cache_key(id); h }
61 | # retrieve the current version of the records
62 | current_versions = version_store.current_multi(id_to_key_map)
63 | # get the keys for the records for which a current version was found
64 | id_to_version_key_map = Hash[id_to_key_map.map{ |id, key| current_versions[id] ? [id, versioned_key(key, current_versions[id])] : nil }.compact]
65 | # retrieve the records from the cache
66 | records = id_to_version_key_map.size > 0 ? from_cache(id_to_version_key_map) : []
67 | # query the records with missing ids
68 | id_to_key_map.except!(*records.map(&@attribute))
69 | # logging (only in debug mode!) and statistics
70 | log_id_cache_hit(ids, id_to_key_map.keys) if RecordCache::Base.logger.debug?
71 | statistics.add(ids.size, records.size) if statistics.active?
72 | # retrieve records from DB in case there are some missing ids
73 | records += from_db(id_to_key_map, id_to_version_key_map) if id_to_key_map.size > 0
74 | # return the array
75 | records
76 | end
77 |
78 | private
79 |
80 | # ---------------------------- Querying ------------------------------------
81 |
82 | # retrieve the records from the cache with the given keys
83 | def from_cache(id_to_versioned_key_map)
84 | records = record_store.read_multi(*(id_to_versioned_key_map.values)).values.compact
85 | records.map do |record|
86 | record = Util.deserialize(record)
87 | record.becomes(self.instance_variable_get('@base')) unless record.class == self.instance_variable_get('@base')
88 | record
89 | end
90 | end
91 |
92 | # retrieve the records with the given ids from the database
93 | def from_db(id_to_key_map, id_to_version_key_map)
94 | # skip record cache itself
95 | RecordCache::Base.without_record_cache do
96 | # set version store in multi-mode
97 | RecordCache::Base.version_store.multi do
98 | # set record store in multi-mode
99 | record_store.multi do
100 | # retrieve the records from the database
101 | records = @base.where(@attribute => id_to_key_map.keys).to_a
102 | records.each do |record|
103 | versioned_key = id_to_version_key_map[record.send(@attribute)]
104 | unless versioned_key
105 | # renew the key in the version store in case it was missing
106 | key = id_to_key_map[record.send(@attribute)]
107 | versioned_key = versioned_key(key, version_store.renew_for_read(key, version_opts))
108 | end
109 | # store the record based on the versioned key
110 | record_store.write(versioned_key, Util.serialize(record))
111 | end
112 | records
113 | end
114 | end
115 | end
116 | end
117 |
118 | # ------------------------- Utility methods ----------------------------
119 |
120 | # log cache hit/miss to debug log
121 | def log_id_cache_hit(ids, missing_ids)
122 | hit = missing_ids.empty? ? "hit" : ids.size == missing_ids.size ? "miss" : "partial hit"
123 | missing = missing_ids.empty? || ids.size == missing_ids.size ? "" : ": missing #{missing_ids.inspect}"
124 | msg = "UniqueIndexCache on '#{@base.name}.#{@attribute}' #{hit} for ids #{ids.size == 1 ? ids.first.inspect : ids.inspect}#{missing}"
125 | RecordCache::Base.logger.debug{ msg }
126 | end
127 |
128 | end
129 | end
130 | end
131 |
--------------------------------------------------------------------------------
/lib/record_cache/strategy/util.rb:
--------------------------------------------------------------------------------
1 | # Utility methods for the Cache Strategies
2 | module RecordCache
3 | module Strategy
4 | module Util
5 | CLASS_KEY = :c
6 | ATTRIBUTES_KEY = :a
7 |
8 | class << self
9 |
10 | # serialize one record before adding it to the cache
11 | # creates a shallow clone with a version and without associations
12 | def serialize(record)
13 | {CLASS_KEY => record.class.name,
14 | ATTRIBUTES_KEY => record.instance_variable_get(:@attributes).dup}
15 | end
16 |
17 | # deserialize a cached record
18 | def deserialize(serialized)
19 | record = serialized[CLASS_KEY].constantize.allocate
20 | attributes = serialized[ATTRIBUTES_KEY].clone
21 | record.class.serialized_attributes.keys.each do |attribute|
22 | if attributes[attribute].respond_to?(:unserialize)
23 | if attributes[attribute].method(:unserialize).arity > 0
24 | attributes[attribute] = attributes[attribute].unserialize(attributes[attribute].value)
25 | else
26 | attributes[attribute] = attributes[attribute].unserialize
27 | end
28 | end
29 | end
30 | record.init_with('attributes' => attributes)
31 | record
32 | end
33 |
34 | # Filter the cached records in memory
35 | # only simple x = y or x IN (a,b,c) can be handled
36 | # string comparison is case insensitive
37 | # Example:
38 | # RecordCache::Strategy::Util.filter!(Apple.all, :price => [0.49, 0.59, 0.69], :name => "Green Apple")
39 | def filter!(records, wheres)
40 | wheres.each_pair do |attr, value|
41 | attr = attr.to_sym
42 | if value.is_a?(Array)
43 | where_values = Set.new(value.first.respond_to?(:downcase) ? value.map(&:downcase) : value)
44 | records.to_a.select! do |record|
45 | attribute_value = record.send(attr)
46 | attribute_value = attribute_value.downcase if attribute_value.respond_to?(:downcase)
47 | where_values.include?(attribute_value)
48 | end
49 | else
50 | where_value = value.respond_to?(:downcase) ? value.downcase : value
51 | records.to_a.select! do |record|
52 | attribute_value = record.send(attr)
53 | attribute_value = attribute_value.downcase if attribute_value.respond_to?(:downcase)
54 | attribute_value == where_value
55 | end
56 | end
57 | end
58 | end
59 |
60 | # Sort the cached records in memory, similar to MySql sorting rules including collatiom
61 | # Simply provide the Symbols of the attributes to sort in Ascending order, or use
62 | # [, false] for Descending order.
63 | # Example:
64 | # RecordCache::Strategy::Util.sort!(Apple.all, :name)
65 | # RecordCache::Strategy::Util.sort!(Apple.all, [:name, false])
66 | # RecordCache::Strategy::Util.sort!(Apple.all, [:price, false], :name)
67 | # RecordCache::Strategy::Util.sort!(Apple.all, [:price, false], [:name, true])
68 | # RecordCache::Strategy::Util.sort!(Apple.all, [[:price, false], [:name, true]])
69 | def sort!(records, *sort_orders)
70 | return records if records.empty? || sort_orders.empty?
71 | if sort_orders.first.is_a?(Array) && sort_orders.first.first.is_a?(Array)
72 | sort_orders = sort_orders.first
73 | else
74 | sort_orders = sort_orders.map{ |order| order.is_a?(Array) ? order : [order, true] } unless sort_orders.all?{ |order| order.is_a?(Array) }
75 | end
76 | records.sort!(&sort_proc(records.first.class, sort_orders))
77 | Collator.clear
78 | records
79 | end
80 |
81 | private
82 |
83 | # Retrieve the Proc based on the order by attributes
84 | # Note: Case insensitive sorting with collation is used for Strings
85 | def sort_proc(base, sort_orders)
86 | # [['(COLLATER.collate(x.name) || NIL_COMES_FIRST)', 'COLLATER.collate(y.name)'], ['(y.updated_at || NIL_COMES_FIRST)', 'x.updated_at']]
87 | sort = sort_orders.map do |attr, asc|
88 | attr = attr.to_s
89 | lr = ["x.", "y."]
90 | lr.reverse! unless asc
91 | lr.each{ |s| s << attr }
92 | lr.each{ |s| s.replace("Collator.collate(#{s})") } if base.columns_hash[attr].type == :string
93 | lr[0].replace("(#{lr[0]} || NIL_COMES_FIRST)")
94 | lr
95 | end
96 | # ['[(COLLATER.collate(x.name) || NIL_COMES_FIRST), (y.updated_at || NIL_COMES_FIRST)]', '[COLLATER.collate(y.name), x.updated_at]']
97 | sort = sort.transpose.map{|s| s.size == 1 ? s.first : "[#{s.join(',')}]"}
98 | # Proc.new{ |x,y| { ([(COLLATER.collate(x.name) || NIL_COMES_FIRST), (y.updated_at || NIL_COMES_FIRST)] <=> [COLLATER.collate(y.name), x.updated_at]) || 1 }
99 | eval("Proc.new{ |x,y| (#{sort[0]} <=> #{sort[1]}) || 1 }")
100 | end
101 |
102 | # If +x.nil?+ this class will return -1 for +x <=> y+
103 | NIL_COMES_FIRST = ((class NilComesFirst; def <=>(y); -1; end; end); NilComesFirst.new)
104 |
105 | # StringCollator uses the Rails transliterate method for collation
106 | module Collator
107 | @collated = []
108 |
109 | def self.clear
110 | @collated.each { |string| string.send(:remove_instance_variable, :@rc_collated) }
111 | @collated.clear
112 | end
113 |
114 | def self.collate(string)
115 | collated = string.instance_variable_get(:@rc_collated)
116 | return collated if collated
117 | normalized = ActiveSupport::Multibyte::Unicode.normalize(ActiveSupport::Multibyte::Unicode.tidy_bytes(string || ''), :c).mb_chars
118 | collated = I18n.transliterate(normalized).downcase.mb_chars
119 | # transliterate will replace ignored/unknown chars with ? the following line replaces ? with the original character
120 | collated.chars.each_with_index{ |c, i| collated[i] = normalized[i] if c == '?' } if collated.index('?')
121 | # puts "collation: #{string} => #{collated.to_s}"
122 | string.instance_variable_set(:@rc_collated, collated)
123 | @collated << string
124 | collated
125 | end
126 | end
127 | end
128 |
129 | end
130 | end
131 | end
132 |
--------------------------------------------------------------------------------
/lib/record_cache/test/resettable_version_store.rb:
--------------------------------------------------------------------------------
1 | # Make sure the version store can be reset to it's starting point after each test
2 | # Usage:
3 | # require 'record_cache/test/resettable_version_store'
4 | # after(:each) { RecordCache::Base.version_store.reset! }
5 | module RecordCache
6 | module Test
7 |
8 | module ResettableVersionStore
9 |
10 | def self.included(base)
11 | base.extend ClassMethods
12 | base.send(:include, InstanceMethods)
13 | base.instance_eval do
14 | alias_method_chain :renew, :reset
15 | end
16 | end
17 |
18 | module ClassMethods
19 | end
20 |
21 | module InstanceMethods
22 |
23 | def renew_with_reset(key, write = true, opts = {})
24 | updated_version_keys << key
25 | renew_without_reset(key, write, opts)
26 | end
27 |
28 | def reset!
29 | updated_version_keys.each { |key| delete(key) }
30 | updated_version_keys.clear
31 | end
32 |
33 | def updated_version_keys
34 | @updated_version_keys ||= []
35 | end
36 | end
37 | end
38 |
39 | end
40 | end
41 |
42 | RecordCache::VersionStore.send(:include, RecordCache::Test::ResettableVersionStore)
43 |
--------------------------------------------------------------------------------
/lib/record_cache/version.rb:
--------------------------------------------------------------------------------
1 | module RecordCache # :nodoc:
2 | module Version # :nodoc:
3 | STRING = '0.1.6'
4 | end
5 | end
--------------------------------------------------------------------------------
/lib/record_cache/version_store.rb:
--------------------------------------------------------------------------------
1 | module RecordCache
2 | class VersionStore
3 | attr_accessor :store
4 |
5 | def initialize(store)
6 | [:write, :read, :read_multi, :delete].each do |method|
7 | raise "Store #{store.class.name} must respond to #{method}" unless store.respond_to?(method)
8 | end
9 | @store = store
10 | end
11 |
12 | def on_write_failure(&blk)
13 | @on_write_failure = blk
14 | end
15 |
16 | # Retrieve the current versions for the given key
17 | # @return nil in case the key is not known in the version store
18 | def current(key)
19 | @store.read(key)
20 | end
21 |
22 | # Retrieve the current versions for the given keys
23 | # @param id_key_map is a map with {id => cache_key}
24 | # @return a map with {id => current_version}
25 | # version nil for all keys unknown to the version store
26 | def current_multi(id_key_map)
27 | current_versions = @store.read_multi(*(id_key_map.values))
28 | Hash[id_key_map.map{ |id, key| [id, current_versions[key]] }]
29 | end
30 |
31 | def renew_for_read(key, options = {})
32 | renew(key, false, options)
33 | end
34 |
35 | # Call this method to reset the key to a new (unique) version
36 | def renew(key, write = true, options = {})
37 | new_version = (Time.current.to_f * 10000).to_i
38 | seconds = options[:ttl] ? options[:ttl] + (rand(options[:ttl] / 2) * [1, -1].sample) : nil
39 | written = @store.write(key, new_version, {:expires_in => seconds})
40 | @on_write_failure.call(key) if !written && write && @on_write_failure
41 | RecordCache::Base.logger.debug{ "Version Store: renew #{key}: nil => #{new_version}" }
42 | new_version
43 | end
44 |
45 | # perform several actions on the version store in one go
46 | # Dalli: Turn on quiet aka noreply support. All relevant operations within this block will be effectively pipelined using 'quiet' operations where possible.
47 | # Currently supports the set, add, replace and delete operations for Dalli cache.
48 | def multi(&block)
49 | if @store.respond_to?(:multi)
50 | @store.multi(&block)
51 | else
52 | yield
53 | end
54 | end
55 |
56 | # @deprecated: use renew instead
57 | def increment(key)
58 | RecordCache::Base.logger.debug{ "increment is deprecated, use renew instead. Called from: #{caller[0]}" }
59 | renew(key, true)
60 | end
61 |
62 | # Delete key from the version store (records cached in the Record Store belonging to this key will become unreachable)
63 | def delete(key)
64 | deleted = @store.delete(key)
65 | @on_write_failure.call(key) if !deleted && @on_write_failure
66 | RecordCache::Base.logger.debug{ "Version Store: deleted #{key}" }
67 | deleted
68 | end
69 |
70 | end
71 | end
72 |
--------------------------------------------------------------------------------
/record-cache.gemspec:
--------------------------------------------------------------------------------
1 | # -*- encoding: utf-8 -*-
2 | $LOAD_PATH.unshift File.expand_path('../lib', __FILE__)
3 | require 'record_cache/version'
4 |
5 | Gem::Specification.new do |s|
6 | s.name = 'record-cache'
7 | s.version = RecordCache::Version::STRING
8 | s.authors = ['Orslumen']
9 | s.email = 'orslumen@gmail.com'
10 | s.homepage = 'https://github.com/orslumen/record-cache'
11 | s.summary = "Record Cache v#{RecordCache::Version::STRING} transparantly stores Records in a Cache Store and retrieve those Records from the store when queried using Active Model."
12 | s.description = 'Record Cache for Rails 3'
13 | s.license = 'MIT'
14 |
15 | s.files = `git ls-files -- lib/*`.split("\n")
16 | s.test_files = `git ls-files -- spec/*`.split("\n")
17 | s.require_path = 'lib'
18 |
19 | s.add_runtime_dependency 'rails'
20 |
21 | s.add_development_dependency 'bundler'
22 | s.add_development_dependency 'activerecord', '< 4.2'
23 | s.add_development_dependency 'sqlite3'
24 | s.add_development_dependency 'pry'
25 | s.add_development_dependency 'mysql2'
26 | s.add_development_dependency 'rake'
27 | s.add_development_dependency 'simplecov'
28 | s.add_development_dependency 'rspec', '~> 3.0'
29 | s.add_development_dependency 'database_cleaner'
30 | s.add_development_dependency 'appraisal'
31 | s.add_development_dependency 'test_after_commit'
32 | s.add_development_dependency 'timecop'
33 |
34 | end
35 |
--------------------------------------------------------------------------------
/spec/db/create-record-cache-db_and_user.sql:
--------------------------------------------------------------------------------
1 | DROP DATABASE IF EXISTS record_cache;
2 | CREATE DATABASE record_cache;
3 | CREATE USER 'record_cache'@'localhost' IDENTIFIED BY 'test';
4 | GRANT ALL ON record_cache.* to 'record_cache'@'localhost';
5 | FLUSH PRIVILEGES;
6 |
--------------------------------------------------------------------------------
/spec/db/database.yml:
--------------------------------------------------------------------------------
1 | sqlite3:
2 | adapter: sqlite3
3 | database: ":memory:"
4 | encoding: utf8
5 | charset: utf8
6 | timeout: 5000
7 |
8 |
9 | mysql:
10 | adapter: mysql2
11 | database: "record_cache"
12 | username: record_cache
13 | password: test
14 |
--------------------------------------------------------------------------------
/spec/db/schema.rb:
--------------------------------------------------------------------------------
1 | ActiveRecord::Schema.define version: 0 do
2 |
3 | create_table :people, force: true do |t|
4 | t.string :name
5 | t.date :birthday
6 | t.float :height
7 | end
8 |
9 | create_table :stores, force: true do |t|
10 | t.string :name
11 | t.integer :owner_id
12 | end
13 |
14 | create_table :people_stores, id: false, force: true do |t|
15 | t.integer :person_id
16 | t.string :store_id
17 | end
18 |
19 | create_table :apples, force: true do |t|
20 | t.string :name
21 | t.integer :store_id
22 | t.integer :person_id
23 | end
24 |
25 | create_table :bananas, force: true do |t|
26 | t.string :name
27 | t.integer :store_id
28 | t.integer :person_id
29 | end
30 |
31 | create_table :pears, force: true do |t|
32 | t.string :name
33 | t.integer :store_id
34 | t.integer :person_id
35 | end
36 |
37 | create_table :addresses, force: true do |t|
38 | t.string :name
39 | t.integer :store_id
40 | t.string :location
41 | end
42 |
43 | create_table :languages, force: true do |t|
44 | t.string :name
45 | t.string :locale
46 | end
47 |
48 | end
49 |
--------------------------------------------------------------------------------
/spec/db/seeds.rb:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 | ActiveRecord::Schema.define :version => 1 do
3 |
4 | # Make sure that at the beginning of the tests, NOTHING is known to Record Cache
5 | RecordCache::Base.disable!
6 |
7 | @adam = Person.create!(:name => "Adam", :birthday => Date.civil(1975,03,20), :height => 1.83)
8 | @blue = Person.create!(:name => "Blue", :birthday => Date.civil(1953,11,11), :height => 1.75)
9 | @cris = Person.create!(:name => "Cris", :birthday => Date.civil(1975,03,20), :height => 1.75)
10 |
11 | @adam_apples = Store.create!(:name => "Adams Apple Store", :owner => @adam)
12 | @blue_fruits = Store.create!(:name => "Blue Fruits", :owner => @blue)
13 | @cris_bananas = Store.create!(:name => "Chris Bananas", :owner => @cris)
14 |
15 | @adam_apples_address = Address.create!(:name => "101 1st street", :store => @adam_apples)
16 | @blue_fruits_address = Address.create!(:name => "102 1st street", :store => @blue_fruits)
17 | @cris_bananas_address = Address.create!(:name => "103 1st street", :store => @cris_bananas, :location => {latitue: 27.175015, longitude: 78.042155, dms_lat: %(27° 10' 30.0540" N), dms_long: %(78° 2' 31.7580" E)})
18 |
19 | @fry = Person.create!(:name => "Fry", :birthday => Date.civil(1985,01,20), :height => 1.69)
20 | @chase = Person.create!(:name => "Chase", :birthday => Date.civil(1970,07,03), :height => 1.91)
21 | @penny = Person.create!(:name => "Penny", :birthday => Date.civil(1958,04,16), :height => 1.61)
22 |
23 | Apple.create!(:name => "Adams Apple 1", :store => @adam_apples)
24 | Apple.create!(:name => "Adams Apple 2", :store => @adam_apples)
25 | Apple.create!(:name => "Adams Apple 3", :store => @adam_apples, :person => @fry)
26 | Apple.create!(:name => "Adams Apple 4", :store => @adam_apples, :person => @fry)
27 | Apple.create!(:name => "Adams Apple 5", :store => @adam_apples, :person => @chase)
28 | Apple.create!(:name => "Blue Apple 1", :store => @blue_fruits, :person => @fry)
29 | Apple.create!(:name => "Blue Apple 2", :store => @blue_fruits, :person => @fry)
30 | Apple.create!(:name => "Blue Apple 3", :store => @blue_fruits, :person => @chase)
31 | Apple.create!(:name => "Blue Apple 4", :store => @blue_fruits, :person => @chase)
32 |
33 | Banana.create!(:name => "Blue Banana 1", :store => @blue_fruits, :person => @fry)
34 | Banana.create!(:name => "Blue Banana 2", :store => @blue_fruits, :person => @chase)
35 | Banana.create!(:name => "Blue Banana 3", :store => @blue_fruits, :person => @chase)
36 | Banana.create!(:name => "Cris Banana 1", :store => @cris_bananas, :person => @fry)
37 | Banana.create!(:name => "Cris Banana 2", :store => @cris_bananas, :person => @chase)
38 |
39 | Pear.create!(:name => "Blue Pear 1", :store => @blue_fruits)
40 | Pear.create!(:name => "Blue Pear 2", :store => @blue_fruits, :person => @fry)
41 | Pear.create!(:name => "Blue Pear 3", :store => @blue_fruits, :person => @chase)
42 | Pear.create!(:name => "Blue Pear 4", :store => @blue_fruits, :person => @chase)
43 |
44 | Language.create!(:name => "English (US)", :locale => "en-US")
45 | Language.create!(:name => "English (GB)", :locale => "en-GB")
46 | Language.create!(:name => "Nederlands (NL)", :locale => "du-NL")
47 | Language.create!(:name => "Magyar", :locale => "hu")
48 |
49 | RecordCache::Base.enable
50 | end
51 |
--------------------------------------------------------------------------------
/spec/initializers/backward_compatibility.rb:
--------------------------------------------------------------------------------
1 | if ActiveRecord::VERSION::MAJOR < 4
2 |
3 | module ActiveRecord
4 | class Base
5 | class << self
6 | # generic find_by introduced in Rails 4
7 | def find_by(*args)
8 | where(*args).first
9 | rescue RangeError
10 | nil
11 | end unless method_defined? :find_by
12 | end
13 | end
14 | end
15 |
16 | module ActiveSupport
17 | module Dependencies
18 | module Loadable
19 | # load without arguments in Rails 4 is similar to +to_a+ in Rails 3
20 | def load_with_default(*args)
21 | if self.respond_to?(:to_a)
22 | self.to_a
23 | else
24 | self.load_without_default(*args)
25 | end
26 | end
27 | alias_method_chain :load, :default
28 | end
29 | end
30 | end
31 |
32 | end
--------------------------------------------------------------------------------
/spec/initializers/record_cache.rb:
--------------------------------------------------------------------------------
1 | # --- Version Store
2 | # All Workers that use the Record Cache should point to the same Version Store
3 | # E.g. a MemCached cluster or a Redis Store (defaults to Rails.cache)
4 | RecordCache::Base.version_store = ActiveSupport::Cache.lookup_store(:memory_store)
5 |
6 | # --- Record Stores
7 | # Register Cache Stores for the Records themselves
8 | # Note: A different Cache Store could be used per Model, but in most configurations the following 2 stores will suffice:
9 |
10 | # The :local store is used to keep records in Worker memory
11 | RecordCache::Base.register_store(:local, ActiveSupport::Cache.lookup_store(:memory_store))
12 |
13 | # The :shared store is used to share Records between multiple Workers
14 | RecordCache::Base.register_store(:shared, ActiveSupport::Cache.lookup_store(:memory_store))
15 |
--------------------------------------------------------------------------------
/spec/lib/active_record/visitor_spec.rb:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 | require 'spec_helper'
3 |
4 | RSpec.describe 'ActiveRecord Visitor' do
5 |
6 | def find_visit_methods(visitor_class)
7 | (visitor_class.instance_methods + visitor_class.private_instance_methods).select{ |method| method.to_s =~ /^visit_Arel_/ }.sort.uniq
8 | end
9 |
10 | it 'should implement all visitor methods' do
11 | all_visit_methods = find_visit_methods(Arel::Visitors::ToSql)
12 | rc_visit_methods = find_visit_methods(RecordCache::Arel::QueryVisitor)
13 | expect(all_visit_methods - rc_visit_methods).to be_empty
14 | end
15 |
16 | it 'should not implement old visitor methods' do
17 | all_visit_methods = find_visit_methods(Arel::Visitors::ToSql)
18 | rc_visit_methods = find_visit_methods(RecordCache::Arel::QueryVisitor)
19 | expect(rc_visit_methods - all_visit_methods).to be_empty
20 | end
21 |
22 | end
23 |
--------------------------------------------------------------------------------
/spec/lib/base_spec.rb:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 | require 'spec_helper'
3 |
4 | RSpec.describe RecordCache::Base do
5 |
6 | it "should run a block in enabled mode" do
7 | RecordCache::Base.disable!
8 | RecordCache::Base.enabled do
9 | expect(RecordCache::Base.status).to eq(RecordCache::ENABLED)
10 | end
11 | expect(RecordCache::Base.status).to eq(RecordCache::DISABLED)
12 | end
13 |
14 | it "should be possible to provide a different logger" do
15 | custom_logger = Logger.new(STDOUT)
16 | RecordCache::Base.logger = custom_logger
17 | expect(RecordCache::Base.logger).to eq(custom_logger)
18 | RecordCache::Base.logger = nil
19 | expect(RecordCache::Base.logger).to eq(::ActiveRecord::Base.logger)
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/spec/lib/dispatcher_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | RSpec.describe RecordCache::Dispatcher do
4 | before(:each) do
5 | @apple_dispatcher = Apple.record_cache
6 | end
7 |
8 | it "should return the (ordered) strategy classes" do
9 | expect(RecordCache::Dispatcher.strategy_classes).to eq([RecordCache::Strategy::UniqueIndexCache, RecordCache::Strategy::FullTableCache, RecordCache::Strategy::IndexCache])
10 | end
11 |
12 | it "should be able to register a new strategy" do
13 | RecordCache::Dispatcher.strategy_classes << Integer
14 | expect(RecordCache::Dispatcher.strategy_classes).to include(Integer)
15 | RecordCache::Dispatcher.strategy_classes.delete(Integer)
16 | end
17 |
18 | context "parse" do
19 | it "should raise an error when the same index is added twice" do
20 | expect{ Apple.cache_records(:index => :store_id) }.to raise_error("Multiple record cache definitions found for 'store_id' on Apple")
21 | end
22 | end
23 |
24 | it "should return the Cache for the requested strategy" do
25 | expect(@apple_dispatcher[:id].class).to eq(RecordCache::Strategy::UniqueIndexCache)
26 | expect(@apple_dispatcher[:store_id].class).to eq(RecordCache::Strategy::IndexCache)
27 | end
28 |
29 | it "should return nil for unknown requested strategies" do
30 | expect(@apple_dispatcher[:unknown]).to be_nil
31 | end
32 |
33 | context "record_change" do
34 | it "should dispatch record_change to all strategies" do
35 | apple = Apple.first
36 | [:id, :store_id, :person_id].each do |strategy|
37 | expect(@apple_dispatcher[strategy]).to receive(:record_change).with(apple, :create)
38 | end
39 | @apple_dispatcher.record_change(apple, :create)
40 | end
41 |
42 | it "should not dispatch record_change for updates without changes" do
43 | apple = Apple.first
44 | [:id, :store_id, :person_id].each do |strategy|
45 | expect(@apple_dispatcher[strategy]).to_not receive(:record_change)
46 | end
47 | @apple_dispatcher.record_change(apple, :update)
48 | end
49 | end
50 |
51 | context "invalidate" do
52 | it "should default to the :id strategy" do
53 | expect(@apple_dispatcher[:id]).to receive(:invalidate).with(15)
54 | @apple_dispatcher.invalidate(15)
55 | end
56 |
57 | it "should delegate to given strategy" do
58 | expect(@apple_dispatcher[:id]).to receive(:invalidate).with(15)
59 | expect(@apple_dispatcher[:store_id]).to receive(:invalidate).with(31)
60 | @apple_dispatcher.invalidate(:id, 15)
61 | @apple_dispatcher.invalidate(:store_id, 31)
62 | end
63 | end
64 | end
65 |
--------------------------------------------------------------------------------
/spec/lib/multi_read_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | RSpec.describe RecordCache::MultiRead do
4 |
5 | it "should not delegate to single reads when multi_read is supported" do
6 | class MultiReadSupported
7 | def read(key) "single" end
8 | def read_multi(*keys) "multi" end
9 | end
10 | store = RecordCache::MultiRead.test(MultiReadSupported.new)
11 | expect(store.read_multi("key1", "key2")).to eq("multi")
12 | end
13 |
14 | it "should delegate to single reads when multi_read is explicitly disabled" do
15 | class ExplicitlyDisabled
16 | def read(key) "single" end
17 | def read_multi(*keys) "multi" end
18 | end
19 | RecordCache::MultiRead.disable(ExplicitlyDisabled)
20 | store = RecordCache::MultiRead.test(ExplicitlyDisabled.new)
21 | expect(store.read_multi("key1", "key2")).to eq({"key1" => "single", "key2" => "single"})
22 | end
23 |
24 | it "should delegate to single reads when multi_read throws an error" do
25 | class MultiReadNotImplemented
26 | def read(key) "single" end
27 | def read_multi(*keys) raise NotImplementedError.new("multiread not implemented") end
28 | end
29 | store = RecordCache::MultiRead.test(MultiReadNotImplemented.new)
30 | expect(store.read_multi("key1", "key2")).to eq({"key1" => "single", "key2" => "single"})
31 | end
32 |
33 | it "should delegate to single reads when multi_read is undefined" do
34 | class MultiReadNotDefined
35 | def read(key) "single" end
36 | end
37 | store = RecordCache::MultiRead.test(MultiReadNotDefined.new)
38 | expect(store.read_multi("key1", "key2")).to eq({"key1" => "single", "key2" => "single"})
39 | end
40 |
41 | it "should have tested the Version Store" do
42 | expect(RecordCache::MultiRead.instance_variable_get(:@tested)).to include(RecordCache::Base.version_store.instance_variable_get(:@store))
43 | end
44 |
45 | it "should have tested all Record Stores" do
46 | tested_stores = RecordCache::MultiRead.instance_variable_get(:@tested)
47 | RecordCache::Base.stores.values.each do |record_store|
48 | expect(tested_stores).to include(record_store)
49 | end
50 | end
51 | end
52 |
--------------------------------------------------------------------------------
/spec/lib/query_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | RSpec.describe RecordCache::Query do
4 | before(:each) do
5 | @query = RecordCache::Query.new
6 | end
7 |
8 | context "wheres" do
9 | it "should be an empty hash by default" do
10 | expect(@query.wheres).to eq({})
11 | end
12 |
13 | it "should fill wheres on instantiation" do
14 | @query = RecordCache::Query.new({:id => 1})
15 | expect(@query.wheres).to eq({:id => 1})
16 | end
17 |
18 | it "should keep track of where clauses" do
19 | @query.where(:name, "My name")
20 | @query.where(:id, [1, 2, 3])
21 | @query.where(:height, 1.75)
22 | expect(@query.wheres).to eq({:name => "My name", :id => [1, 2, 3], :height => 1.75})
23 | end
24 |
25 | context "where_values" do
26 | it "should return nil if the attribute is not defined" do
27 | @query.where(:idx, 15)
28 | expect(@query.where_values(:id)).to be_nil
29 | end
30 |
31 | it "should return nil if one the value is nil" do
32 | @query.where(:id, nil)
33 | expect(@query.where_values(:id)).to be_nil
34 | end
35 |
36 | it "should return nil if one of the values is < 1" do
37 | @query.where(:id, [2, 0, 8])
38 | expect(@query.where_values(:id)).to be_nil
39 | end
40 |
41 | it "should return remove nil from the values" do
42 | @query.where(:id, ["1", nil, "3"])
43 | expect(@query.where_values(:id)).to eq([1,3])
44 | end
45 |
46 | it "should retrieve an array of integers when a single integer is provided" do
47 | @query.where(:id, 15)
48 | expect(@query.where_values(:id)).to eq([15])
49 | end
50 |
51 | it "should retrieve an array of integers when a multiple integers are provided" do
52 | @query.where(:id, [2, 4, 8])
53 | expect(@query.where_values(:id)).to eq([2, 4, 8])
54 | end
55 |
56 | it "should retrieve an array of integers when a single string is provided" do
57 | @query.where(:id, "15")
58 | expect(@query.where_values(:id)).to eq([15])
59 | end
60 |
61 | it "should retrieve an array of integers when a multiple strings are provided" do
62 | @query.where(:id, ["2", "4", "8"])
63 | expect(@query.where_values(:id)).to eq([2, 4, 8])
64 | end
65 |
66 | it "should cache the array of values" do
67 | @query.where(:id, ["2", "4", "8"])
68 | ids1 = @query.where_values(:id)
69 | ids2 = @query.where_values(:id)
70 | expect(ids1.object_id).to eq(ids2.object_id)
71 | end
72 | end
73 |
74 | context "where_value" do
75 | it "should return nil when multiple integers are provided" do
76 | @query.where(:id, [2, 4, 8])
77 | expect(@query.where_value(:id)).to be_nil
78 | end
79 |
80 | it "should return the id when a single integer is provided" do
81 | @query.where(:id, 4)
82 | expect(@query.where_value(:id)).to eq(4)
83 | end
84 |
85 | it "should return the id when a single string is provided" do
86 | @query.where(:id, ["4"])
87 | expect(@query.where_value(:id)).to eq(4)
88 | end
89 | end
90 | end
91 |
92 | context "sort" do
93 | it "should be an empty array by default" do
94 | expect(@query.sort_orders).to be_empty
95 | end
96 |
97 | it "should keep track of sort orders" do
98 | @query.order_by("name", true)
99 | @query.order_by("id", false)
100 | expect(@query.sort_orders).to eq([ ["name", true], ["id", false] ])
101 | end
102 |
103 | it "should convert attribute to string" do
104 | @query.order_by(:name, true)
105 | expect(@query.sort_orders).to eq([ ["name", true] ])
106 | end
107 |
108 | it "should define sorted?" do
109 | expect(@query.sorted?).to eq(false)
110 | @query.order_by("name", true)
111 | expect(@query.sorted?).to eq(true)
112 | end
113 | end
114 |
115 | context "limit" do
116 | it "should be +nil+ by default" do
117 | expect(@query.limit).to be_nil
118 | end
119 |
120 | it "should keep track of limit" do
121 | @query.limit = 4
122 | expect(@query.limit).to eq(4)
123 | end
124 |
125 | it "should convert limit to integer" do
126 | @query.limit = "4"
127 | expect(@query.limit).to eq(4)
128 | end
129 | end
130 |
131 | context "utility" do
132 | before(:each) do
133 | @query.where(:name, "My name & co")
134 | @query.where(:id, [1, 2, 3])
135 | @query.order_by("name", true)
136 | @query.limit = "4"
137 | end
138 |
139 | it "should generate a unique key for (request) caching purposes" do
140 | expect(@query.cache_key).to eq('4+name?name="My name & co"&id=[1, 2, 3]')
141 | end
142 |
143 | it "should generate a pretty formatted query" do
144 | expect(@query.to_s).to eq('SELECT name = "My name & co" AND id = [1, 2, 3] ORDER_BY name ASC LIMIT 4')
145 | end
146 | end
147 |
148 | end
149 |
--------------------------------------------------------------------------------
/spec/lib/statistics_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | RSpec.describe RecordCache::Statistics do
4 | before(:each) do
5 | # remove active setting from other tests
6 | RecordCache::Statistics.send(:remove_instance_variable, :@active) if RecordCache::Statistics.instance_variable_get(:@active)
7 | end
8 |
9 | context "active" do
10 | it "should default to false" do
11 | expect(RecordCache::Statistics.active?).to be_falsey
12 | end
13 |
14 | it "should be activated by start" do
15 | RecordCache::Statistics.start
16 | expect(RecordCache::Statistics.active?).to be_truthy
17 | end
18 |
19 | it "should be deactivated by stop" do
20 | RecordCache::Statistics.start
21 | expect(RecordCache::Statistics.active?).to be_truthy
22 | RecordCache::Statistics.stop
23 | expect(RecordCache::Statistics.active?).to be_falsey
24 | end
25 |
26 | it "should be toggleable" do
27 | RecordCache::Statistics.toggle
28 | expect(RecordCache::Statistics.active?).to be_truthy
29 | RecordCache::Statistics.toggle
30 | expect(RecordCache::Statistics.active?).to be_falsey
31 | end
32 | end
33 |
34 | context "find" do
35 | it "should return {} for unknown base classes" do
36 | class UnknownBase; end
37 | expect(RecordCache::Statistics.find(UnknownBase)).to eq({})
38 | end
39 |
40 | it "should create a new counter for unknown strategies" do
41 | class UnknownBase; end
42 | counter = RecordCache::Statistics.find(UnknownBase, :strategy)
43 | expect(counter.calls).to eq(0)
44 | end
45 |
46 | it "should retrieve all strategies if only the base is provided" do
47 | class KnownBase; end
48 | counter1 = RecordCache::Statistics.find(KnownBase, :strategy1)
49 | counter2 = RecordCache::Statistics.find(KnownBase, :strategy2)
50 | counters = RecordCache::Statistics.find(KnownBase)
51 | expect(counters.size).to eq(2)
52 | expect(counters[:strategy1]).to eq(counter1)
53 | expect(counters[:strategy2]).to eq(counter2)
54 | end
55 |
56 | it "should retrieve the counter for an existing strategy" do
57 | class KnownBase; end
58 | counter1 = RecordCache::Statistics.find(KnownBase, :strategy1)
59 | expect(RecordCache::Statistics.find(KnownBase, :strategy1)).to eq(counter1)
60 | end
61 | end
62 |
63 | context "reset!" do
64 | before(:each) do
65 | class BaseA; end
66 | @counter_a1 = RecordCache::Statistics.find(BaseA, :strategy1)
67 | @counter_a2 = RecordCache::Statistics.find(BaseA, :strategy2)
68 | class BaseB; end
69 | @counter_b1 = RecordCache::Statistics.find(BaseB, :strategy1)
70 | end
71 |
72 | it "should reset all counters for a specific base" do
73 | expect(@counter_a1).to receive(:reset!)
74 | expect(@counter_a2).to receive(:reset!)
75 | expect(@counter_b1).to_not receive(:reset!)
76 | RecordCache::Statistics.reset!(BaseA)
77 | end
78 |
79 | it "should reset all counters" do
80 | expect(@counter_a1).to receive(:reset!)
81 | expect(@counter_a2).to receive(:reset!)
82 | expect(@counter_b1).to receive(:reset!)
83 | RecordCache::Statistics.reset!
84 | end
85 | end
86 |
87 | context "counter" do
88 | before(:each) do
89 | @counter = RecordCache::Statistics::Counter.new
90 | end
91 |
92 | it "should be empty by default" do
93 | expect([@counter.calls, @counter.hits, @counter.misses]).to eq([0, 0, 0])
94 | end
95 |
96 | it "should delegate active? to RecordCache::Statistics" do
97 | expect(RecordCache::Statistics).to receive(:active?)
98 | @counter.active?
99 | end
100 |
101 | it "should add hits and misses" do
102 | @counter.add(4, 3)
103 | expect([@counter.calls, @counter.hits, @counter.misses]).to eq([1, 3, 1])
104 | end
105 |
106 | it "should sum added hits and misses" do
107 | @counter.add(4, 3)
108 | @counter.add(1, 1)
109 | @counter.add(3, 2)
110 | expect([@counter.calls, @counter.hits, @counter.misses]).to eq([3, 6, 2])
111 | end
112 |
113 | it "should reset! hits and misses" do
114 | @counter.add(4, 3)
115 | @counter.add(1, 1)
116 | @counter.reset!
117 | expect([@counter.calls, @counter.hits, @counter.misses]).to eq([0, 0, 0])
118 | end
119 |
120 | it "should provide 0.0 percentage for empty counter" do
121 | expect(@counter.percentage).to eq(0.0)
122 | end
123 |
124 | it "should provide percentage" do
125 | @counter.add(4, 3)
126 | expect(@counter.percentage).to eq(75.0)
127 | @counter.add(1, 1)
128 | expect(@counter.percentage).to eq(80.0)
129 | @counter.add(5, 2)
130 | expect(@counter.percentage).to eq(60.0)
131 | end
132 |
133 | it "should pretty print on inspect" do
134 | @counter.add(4, 3)
135 | @counter.add(1, 1)
136 | @counter.add(5, 2)
137 | expect(@counter.inspect).to eq("60.0% (6/10)")
138 | end
139 | end
140 | end
141 |
--------------------------------------------------------------------------------
/spec/lib/strategy/base_spec.rb:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 | require 'spec_helper'
3 |
4 | RSpec.describe RecordCache::Strategy::Base do
5 |
6 | it "should force implementation of self.parse method" do
7 | module RecordCache
8 | module Strategy
9 | class MissingParseCache < Base
10 | end
11 | end
12 | end
13 | expect{ RecordCache::Strategy::MissingParseCache.parse(1,2,3) }.to raise_error(NotImplementedError)
14 | end
15 |
16 | it "should provide easy access to the Version Store" do
17 | expect(Apple.record_cache[:id].send(:version_store)).to eq(RecordCache::Base.version_store)
18 | end
19 |
20 | it "should provide easy access to the Record Store" do
21 | expect(Apple.record_cache[:id].send(:record_store)).to eq(RecordCache::Base.stores[:shared])
22 | expect(Banana.record_cache[:id].send(:record_store)).to eq(RecordCache::Base.stores[:local])
23 | end
24 |
25 | it "should provide easy access to the statistics" do
26 | expect(Apple.record_cache[:person_id].send(:statistics)).to eq(RecordCache::Statistics.find(Apple, :person_id))
27 | expect(Banana.record_cache[:id].send(:statistics)).to eq(RecordCache::Statistics.find(Banana, :id))
28 | end
29 |
30 | it "should retrieve the cache key based on the :key option" do
31 | expect(Apple.record_cache[:id].send(:cache_key, 1)).to eq("rc/apl/1")
32 | end
33 |
34 | it "should retrieve the cache key based on the model name" do
35 | expect(Banana.record_cache[:id].send(:cache_key, 1)).to eq("rc/Banana/1")
36 | end
37 |
38 | it "should define the versioned key" do
39 | expect(Banana.record_cache[:id].send(:versioned_key, "rc/Banana/1", 2312423)).to eq("rc/Banana/1v2312423")
40 | end
41 |
42 | it "should provide the version_opts" do
43 | expect(Apple.record_cache[:id].send(:version_opts)).to eq({:ttl => 300})
44 | expect(Banana.record_cache[:id].send(:version_opts)).to eq({})
45 | end
46 |
47 | context "filter" do
48 | it "should apply filter on :id cache hits" do
49 | expect{ @apples = Apple.where(:id => [1,2]).where(:name => "Adams Apple 1").load }.to use_cache(Apple).on(:id)
50 | expect(@apples).to eq([Apple.find_by(name: "Adams Apple 1")])
51 | end
52 |
53 | it "should apply filter on index cache hits" do
54 | expect{ @apples = Apple.where(:store_id => 1).where(:name => "Adams Apple 1").load }.to use_cache(Apple).on(:store_id)
55 | expect(@apples).to eq([Apple.find_by(name: "Adams Apple 1")])
56 | end
57 |
58 | it "should return empty array when filter does not match any record" do
59 | expect{ @apples = Apple.where(:store_id => 1).where(:name => "Adams Apple Pie").load }.to use_cache(Apple).on(:store_id)
60 | expect(@apples).to be_empty
61 | end
62 |
63 | it "should filter on text" do
64 | expect{ @apples = Apple.where(:id => [1,2]).where(:name => "Adams Apple 1").load }.to use_cache(Apple).on(:id)
65 | expect(@apples).to eq([Apple.find_by(name: "Adams Apple 1")])
66 | end
67 |
68 | it "should filter on integers" do
69 | expect{ @apples = Apple.where(:id => [1,2,8,9]).where(:store_id => 2).load }.to use_cache(Apple).on(:id)
70 | expect(@apples.map(&:id).sort).to eq([8,9])
71 | end
72 |
73 | it "should filter on dates" do
74 | expect{ @people = Person.where(:id => [1,2,3]).where(:birthday => Date.civil(1953,11,11)).load }.to use_cache(Person).on(:id)
75 | expect(@people.size).to eq(1)
76 | expect(@people.first.name).to eq("Blue")
77 | end
78 |
79 | it "should filter on floats" do
80 | expect{ @people = Person.where(:id => [1,2,3]).where(:height => 1.75).load }.to use_cache(Person).on(:id)
81 | expect(@people.size).to eq(2)
82 | expect(@people.map(&:name).sort).to eq(["Blue", "Cris"])
83 | end
84 |
85 | it "should filter on arrays" do
86 | expect{ @apples = Apple.where(:id => [1,2,8,9]).where(:store_id => [2, 4]).load }.to use_cache(Apple).on(:id)
87 | expect(@apples.map(&:id).sort).to eq([8,9])
88 | end
89 |
90 | it "should filter on multiple fields" do
91 | # make sure two apples exist with the same name
92 | @apple = Apple.find(8)
93 | @apple.name = Apple.find(9).name
94 | @apple.save!
95 |
96 | expect{ @apples = Apple.where(:id => [1,2,3,8,9,10]).where(:store_id => 2).where(:name => @apple.name).load }.to use_cache(Apple).on(:id)
97 | expect(@apples.size).to eq(2)
98 | expect(@apples.map(&:name)).to eq([@apple.name, @apple.name])
99 | expect(@apples.map(&:id).sort).to eq([8,9])
100 | end
101 |
102 | end
103 |
104 | context "sort" do
105 | it "should apply sort on :id cache hits" do
106 | expect{ @people = Person.where(:id => [1,2,3]).order("name DESC").load }.to use_cache(Person).on(:id)
107 | expect(@people.map(&:name)).to eq(["Cris", "Blue", "Adam"])
108 | end
109 |
110 | it "should apply sort on index cache hits" do
111 | expect{ @apples = Apple.where(:store_id => 1).order("person_id ASC").load }.to use_cache(Apple).on(:store_id)
112 | expect(@apples.map(&:person_id)).to eq([nil, nil, 4, 4, 5])
113 | end
114 |
115 | it "should default to ASC" do
116 | expect{ @apples = Apple.where(:store_id => 1).order("person_id").load }.to use_cache(Apple).on(:store_id)
117 | expect(@apples.map(&:person_id)).to eq([nil, nil, 4, 4, 5])
118 | end
119 |
120 | it "should apply sort nil first for ASC" do
121 | expect{ @apples = Apple.where(:store_id => 1).order("person_id ASC").load }.to use_cache(Apple).on(:store_id)
122 | expect(@apples.map(&:person_id)).to eq([nil, nil, 4, 4, 5])
123 | end
124 |
125 | it "should apply sort nil last for DESC" do
126 | expect{ @apples = Apple.where(:store_id => 1).order("person_id DESC").load }.to use_cache(Apple).on(:store_id)
127 | expect(@apples.map(&:person_id)).to eq([5, 4, 4, nil, nil])
128 | end
129 |
130 | it "should sort ascending on text" do
131 | expect{ @people = Person.where(:id => [1,2,3,4]).order("name ASC").load }.to use_cache(Person).on(:id)
132 | expect(@people.map(&:name)).to eq(["Adam", "Blue", "Cris", "Fry"])
133 | end
134 |
135 | it "should sort descending on text" do
136 | expect{ @people = Person.where(:id => [1,2,3,4]).order("name DESC").load }.to use_cache(Person).on(:id)
137 | expect(@people.map(&:name)).to eq(["Fry", "Cris", "Blue", "Adam"])
138 | end
139 |
140 | it "should sort ascending on integers" do
141 | expect{ @people = Person.where(:id => [1,2,3,4]).order("id ASC").load }.to use_cache(Person).on(:id)
142 | expect(@people.map(&:id)).to eq([1,2,3,4])
143 | end
144 |
145 | it "should sort descending on integers" do
146 | expect{ @people = Person.where(:id => [1,2,3,4]).order("id DESC").load }.to use_cache(Person).on(:id)
147 | expect(@people.map(&:id)).to eq([4,3,2,1])
148 | end
149 |
150 | it "should sort ascending on dates" do
151 | expect{ @people = Person.where(:id => [1,2,3,4]).order("birthday ASC").load }.to use_cache(Person).on(:id)
152 | expect(@people.map(&:birthday)).to eq([Date.civil(1953,11,11), Date.civil(1975,03,20), Date.civil(1975,03,20), Date.civil(1985,01,20)])
153 | end
154 |
155 | it "should sort descending on dates" do
156 | expect{ @people = Person.where(:id => [1,2,3,4]).order("birthday DESC").load }.to use_cache(Person).on(:id)
157 | expect(@people.map(&:birthday)).to eq([Date.civil(1985,01,20), Date.civil(1975,03,20), Date.civil(1975,03,20), Date.civil(1953,11,11)])
158 | end
159 |
160 | it "should sort ascending on float" do
161 | expect{ @people = Person.where(:id => [1,2,3,4]).order("height ASC").load }.to use_cache(Person).on(:id)
162 | expect(@people.map(&:height)).to eq([1.69, 1.75, 1.75, 1.83])
163 | end
164 |
165 | it "should sort descending on float" do
166 | expect{ @people = Person.where(:id => [1,2,3,4]).order("height DESC").load }.to use_cache(Person).on(:id)
167 | expect(@people.map(&:height)).to eq([1.83, 1.75, 1.75, 1.69])
168 | end
169 |
170 | it "should sort on multiple fields (ASC + ASC)" do
171 | expect{ @people = Person.where(:id => [2,3,4,5]).order("height ASC, id ASC").load }.to use_cache(Person).on(:id)
172 | expect(@people.map(&:height)).to eq([1.69, 1.75, 1.75, 1.91])
173 | expect(@people.map(&:id)).to eq([4, 2, 3, 5])
174 | end
175 |
176 | it "should sort on multiple fields (ASC + DESC)" do
177 | expect{ @people = Person.where(:id => [2,3,4,5]).order("height ASC, id DESC").load }.to use_cache(Person).on(:id)
178 | expect(@people.map(&:height)).to eq([1.69, 1.75, 1.75, 1.91])
179 | expect(@people.map(&:id)).to eq([4, 3, 2, 5])
180 | end
181 |
182 | it "should sort on multiple fields (DESC + ASC)" do
183 | expect{ @people = Person.where(:id => [2,3,4,5]).order("height DESC, id ASC").load }.to use_cache(Person).on(:id)
184 | expect(@people.map(&:height)).to eq([1.91, 1.75, 1.75, 1.69])
185 | expect(@people.map(&:id)).to eq([5, 2, 3, 4])
186 | end
187 |
188 | it "should sort on multiple fields (DESC + DESC)" do
189 | expect{ @people = Person.where(:id => [2,3,4,5]).order("height DESC, id DESC").load }.to use_cache(Person).on(:id)
190 | expect(@people.map(&:height)).to eq([1.91, 1.75, 1.75, 1.69])
191 | expect(@people.map(&:id)).to eq([5, 3, 2, 4])
192 | end
193 |
194 | it "should use mysql style collation" do
195 | ids = []
196 | ids << Person.create!(:name => "ċedriĉ 3").id # latin other special
197 | ids << Person.create!(:name => "a cedric").id # first in ascending order
198 | ids << Person.create!(:name => "čedriĉ 4").id # latin another special
199 | ids << Person.create!(:name => "ćedriĉ Last").id # latin special lowercase
200 | ids << Person.create!(:name => "sedric 1").id # second to last latin in ascending order
201 | ids << Person.create!(:name => "Cedric 2").id # ascii uppercase
202 | ids << Person.create!(:name => "čedriĉ คฉ Almost last cedric").id # latin special, with non-latin
203 | ids << Person.create!(:name => "Sedric 2").id # last latin in ascending order
204 | ids << Person.create!(:name => "1 cedric").id # numbers before characters
205 | ids << Person.create!(:name => "cedric 1").id # ascii lowercase
206 | ids << Person.create!(:name => "คฉ Really last").id # non-latin characters last in ascending order
207 | ids << Person.create!(:name => "čedriĉ ꜩ Last").id # latin special, with latin non-collateable
208 |
209 | names_asc = ["1 cedric", "a cedric", "cedric 1", "Cedric 2", "ċedriĉ 3", "čedriĉ 4", "ćedriĉ Last", "čedriĉ คฉ Almost last cedric", "čedriĉ ꜩ Last", "sedric 1", "Sedric 2", "คฉ Really last"]
210 | expect{ @people = Person.where(:id => ids).order("name ASC").load }.to hit_cache(Person).on(:id).times(ids.size)
211 | expect(@people.map(&:name)).to eq(names_asc)
212 |
213 | expect{ @people = Person.where(:id => ids).order("name DESC").load }.to hit_cache(Person).on(:id).times(ids.size)
214 | expect(@people.map(&:name)).to eq(names_asc.reverse)
215 | end
216 | end
217 |
218 | it "should combine filter and sort" do
219 | expect{ @people = Person.where(:id => [1,2,3]).where(:height => 1.75).order("name DESC").load }.to use_cache(Person).on(:id)
220 | expect(@people.size).to eq(2)
221 | expect(@people.map(&:name)).to eq(["Cris", "Blue"])
222 |
223 | expect{ @people = Person.where(:id => [1,2,3]).where(:height => 1.75).order("name").load }.to hit_cache(Person).on(:id).times(3)
224 | expect(@people.map(&:name)).to eq(["Blue", "Cris"])
225 | end
226 |
227 | context "NotImplementedError" do
228 | before(:each) do
229 | @invalid_strategy = RecordCache::Strategy::Base.new(Object, nil, nil, {:key => "key"})
230 | end
231 |
232 | it "should require record_change to be implemented" do
233 | expect{ @invalid_strategy.record_change(Object.new, 1) }.to raise_error(NotImplementedError)
234 | end
235 |
236 | it "should require cacheable? to be implemented" do
237 | expect{ @invalid_strategy.cacheable?(RecordCache::Query.new) }.to raise_error(NotImplementedError)
238 | end
239 |
240 | it "should fetch_records to be implemented" do
241 | expect{ @invalid_strategy.fetch(RecordCache::Query.new) }.to raise_error(NotImplementedError)
242 | end
243 | end
244 | end
245 |
--------------------------------------------------------------------------------
/spec/lib/strategy/full_table_cache_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | RSpec.describe RecordCache::Strategy::FullTableCache do
4 |
5 | it "should retrieve a Language from the cache" do
6 | expect { Language.where(:locale => 'en-US').load }.to miss_cache(Language).on(:full_table).times(1)
7 | expect { Language.where(:locale => 'en-US').load }.to hit_cache(Language).on(:full_table).times(1)
8 | end
9 |
10 | it "should retrieve all Languages from cache" do
11 | expect { Language.all.load }.to miss_cache(Language).on(:full_table).times(1)
12 | expect { Language.all.load }.to hit_cache(Language).on(:full_table).times(1)
13 | expect(Language.all.map(&:locale).sort).to eq(%w(du-NL en-GB en-US hu))
14 | end
15 |
16 | context "logging" do
17 | it "should write hit to the debug log" do
18 | Language.all.load
19 | expect { Language.all.load }.to log(:debug, "FullTableCache hit for model Language")
20 | end
21 |
22 | it "should write miss to the debug log" do
23 | expect{ Language.all.load }.to log(:debug, "FullTableCache miss for model Language")
24 | end
25 | end
26 |
27 | context "cacheable?" do
28 | it "should always return true" do
29 | expect(Language.record_cache[:full_table].cacheable?("any query")).to be_truthy
30 | end
31 | end
32 |
33 | context "record_change" do
34 | before(:each) do
35 | @Languages = Language.all.load
36 | end
37 |
38 | it "should invalidate the cache when a record is added" do
39 | expect{ Language.where(:locale => 'en-US').load }.to hit_cache(Language).on(:full_table).times(1)
40 | Language.create!(:name => 'Deutsch', :locale => 'de')
41 | expect{ Language.where(:locale => 'en-US').load }.to miss_cache(Language).on(:full_table).times(1)
42 | end
43 |
44 | it "should invalidate the cache when any record is deleted" do
45 | expect{ Language.where(:locale => 'en-US').load }.to hit_cache(Language).on(:full_table).times(1)
46 | Language.where(:locale => 'hu').first.destroy
47 | expect{ Language.where(:locale => 'en-US').load }.to miss_cache(Language).on(:full_table).times(1)
48 | end
49 |
50 | it "should invalidate the cache when any record is modified" do
51 | expect{ Language.where(:locale => 'en-US').load }.to hit_cache(Language).on(:full_table).times(1)
52 | hungarian = Language.where(:locale => 'hu').first
53 | hungarian.name = 'Magyar (Magyarorszag)'
54 | hungarian.save!
55 | expect{ Language.where(:locale => 'en-US').load }.to miss_cache(Language).on(:full_table).times(1)
56 | end
57 | end
58 |
59 | context "invalidate" do
60 |
61 | it "should invalidate the full cache" do
62 | Language.record_cache[:full_table].invalidate(-10) # any id
63 | expect{ Language.where(:locale => 'en-US').load }.to miss_cache(Language).on(:full_table).times(1)
64 | end
65 |
66 | end
67 |
68 | end
69 |
--------------------------------------------------------------------------------
/spec/lib/strategy/index_cache_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 | require 'timecop'
3 |
4 | RSpec.describe RecordCache::Strategy::IndexCache do
5 |
6 | context "initialize" do
7 | it "should only accept index cache on DB columns" do
8 | expect{ Apple.send(:cache_records, :index => :unknown_column) }.to raise_error("No column found for index 'unknown_column' on Apple.")
9 | end
10 |
11 | it "should only accept index cache on integer columns" do
12 | expect{ Apple.send(:cache_records, :index => :name) }.to raise_error("Incorrect type (expected integer, found string) for index 'name' on Apple.")
13 | end
14 | end
15 |
16 | it "should use the id cache to retrieve the actual records" do
17 | expect{ @apples = Apple.where(:store_id => 1).load }.to miss_cache(Apple).on(:store_id).times(1)
18 | expect{ Apple.where(:store_id => 1).load }.to hit_cache(Apple).on(:store_id).times(1)
19 | expect{ Apple.where(:store_id => 1).load }.to hit_cache(Apple).on(:id).times(@apples.size)
20 | end
21 |
22 | it "should find cached records through relationship" do
23 | store = Store.first
24 | expect{ @apple = store.apples.find(3) }.to use_cache(Apple).on(:id).times(1)
25 | expect(@apple.name).to eq("Adams Apple 3")
26 | end
27 |
28 | context "logging" do
29 | before(:each) do
30 | Apple.where(:store_id => 1).load
31 | end
32 |
33 | it "should write hit to the debug log" do
34 | expect{ Apple.where(:store_id => 1).load }.to log(:debug, /IndexCache hit for rc\/apl\/store_id=1v\d+: found 5 ids/)
35 | end
36 |
37 | it "should write miss to the debug log" do
38 | expect{ Apple.where(:store_id => 2).load }.to log(:debug, /IndexCache miss for rc\/apl\/store_id=2v\d+: found no ids/)
39 | end
40 | end
41 |
42 | context "cacheable?" do
43 | before(:each) do
44 | @store1_apples = Apple.where(:store_id => 1).load
45 | @store2_apples = Apple.where(:store_id => 2).load
46 | end
47 |
48 | it "should hit the cache for a single index id" do
49 | expect{ Apple.where(:store_id => 1).load }.to hit_cache(Apple).on(:store_id).times(1)
50 | end
51 |
52 | it "should hit the cache for a single index id with other where clauses" do
53 | expect{ Apple.where(:store_id => 1).where(:name => "applegate").load }.to hit_cache(Apple).on(:store_id).times(1)
54 | end
55 |
56 | it "should hit the cache for a single index id with (simple) sort clauses" do
57 | expect{ Apple.where(:store_id => 1).order("name ASC").load }.to hit_cache(Apple).on(:store_id).times(1)
58 | end
59 |
60 | #Allow limit == 1 by filtering records after cache hit. Needed for has_one
61 | it "should not hit the cache for a single index id with limit > 0" do
62 | expect{ Apple.where(:store_id => 1).limit(2).load }.to_not hit_cache(Apple).on(:store_id)
63 | end
64 |
65 | it "should not hit the cache when an :id where clause is defined" do
66 | # this query should make use of the :id cache, which is faster
67 | expect{ Apple.where(:store_id => 1).where(:id => 1).load }.to_not hit_cache(Apple).on(:store_id)
68 | end
69 | end
70 |
71 | context "record_change" do
72 | before(:each) do
73 | @store1_apples = Apple.where(:store_id => 1).order('id ASC').to_a
74 | @store2_apples = Apple.where(:store_id => 2).order('id ASC').to_a
75 | end
76 |
77 | [false, true].each do |fresh|
78 | it "should #{fresh ? 'update' : 'invalidate'} the index when a record in the index is destroyed and the current index is #{fresh ? '' : 'not '}fresh" do
79 | # make sure the index is no longer fresh
80 | Apple.record_cache.invalidate(:store_id, 1) unless fresh
81 | # destroy an apple
82 | @destroyed = @store1_apples.last
83 | @destroyed.destroy
84 | # check the cache hit/miss on the index that contained that apple
85 | if fresh
86 | expect{ @apples = Apple.where(:store_id => 1).order('id ASC').load }.to hit_cache(Apple).on(:store_id).times(1)
87 | else
88 | expect{ @apples = Apple.where(:store_id => 1).order('id ASC').load }.to miss_cache(Apple).on(:store_id).times(1)
89 | end
90 | expect(@apples.size).to eq(@store1_apples.size - 1)
91 | expect(@apples.map(&:id)).to eq(@store1_apples.map(&:id) - [@destroyed.id])
92 | # and the index should be cached again
93 | expect{ Apple.where(:store_id => 1).load }.to hit_cache(Apple).on(:store_id).times(1)
94 | end
95 |
96 | it "should #{fresh ? 'update' : 'invalidate'} the index when a record in the index is created and the current index is #{fresh ? '' : 'not '}fresh" do
97 | # make sure the index is no longer fresh
98 | Apple.record_cache.invalidate(:store_id, 1) unless fresh
99 | # create an apple
100 | @new_apple_in_store1 = Apple.create!(:name => "Fresh Apple", :store_id => 1)
101 | # check the cache hit/miss on the index that contains that apple
102 | if fresh
103 | expect{ @apples = Apple.where(:store_id => 1).order('id ASC').load }.to hit_cache(Apple).on(:store_id).times(1)
104 | else
105 | expect{ @apples = Apple.where(:store_id => 1).order('id ASC').load }.to miss_cache(Apple).on(:store_id).times(1)
106 | end
107 | expect(@apples.size).to eq(@store1_apples.size + 1)
108 | expect(@apples.map(&:id)).to eq(@store1_apples.map(&:id) + [@new_apple_in_store1.id])
109 | # and the index should be cached again
110 | expect{ Apple.where(:store_id => 1).load }.to hit_cache(Apple).on(:store_id).times(1)
111 | end
112 |
113 | it "should #{fresh ? 'update' : 'invalidate'} two indexes when the indexed value is updated and the current index is #{fresh ? '' : 'not '}fresh" do
114 | # make sure both indexes are no longer fresh
115 | Apple.record_cache.invalidate(:store_id, 1) unless fresh
116 | Apple.record_cache.invalidate(:store_id, 2) unless fresh
117 | # move one apple from store 1 to store 2
118 | @apple_moved_from_store1_to_store2 = @store1_apples.last
119 | @apple_moved_from_store1_to_store2.store_id = 2
120 | @apple_moved_from_store1_to_store2.save!
121 | # check the cache hit/miss on the indexes that contained/contains that apple
122 | if fresh
123 | expect{ @apples1 = Apple.where(:store_id => 1).order('id ASC').load }.to hit_cache(Apple).on(:store_id).times(1)
124 | expect{ @apples2 = Apple.where(:store_id => 2).order('id ASC').load }.to hit_cache(Apple).on(:store_id).times(1)
125 | else
126 | expect{ @apples1 = Apple.where(:store_id => 1).order('id ASC').load }.to miss_cache(Apple).on(:store_id).times(1)
127 | expect{ @apples2 = Apple.where(:store_id => 2).order('id ASC').load }.to miss_cache(Apple).on(:store_id).times(1)
128 | end
129 | expect(@apples1.size).to eq(@store1_apples.size - 1)
130 | expect(@apples2.size).to eq(@store2_apples.size + 1)
131 | expect(@apples1.map(&:id)).to eq(@store1_apples.map(&:id) - [@apple_moved_from_store1_to_store2.id])
132 | expect(@apples2.map(&:id)).to eq([@apple_moved_from_store1_to_store2.id] + @store2_apples.map(&:id))
133 | # and the index should be cached again
134 | expect{ Apple.where(:store_id => 1).load }.to hit_cache(Apple).on(:store_id).times(1)
135 | expect{ Apple.where(:store_id => 2).load }.to hit_cache(Apple).on(:store_id).times(1)
136 | end
137 |
138 | it "should #{fresh ? 'update' : 'invalidate'} multiple indexes when several values on different indexed attributes are updated at once and one of the indexes is #{fresh ? '' : 'not '}fresh" do
139 | # find the apples for person 1 and 5 (Chase)
140 | @person4_apples = Apple.where(:person_id => 4).to_a # Fry's Apples
141 | @person5_apples = Apple.where(:person_id => 5).to_a # Chases' Apples
142 | # make sure person indexes are no longer fresh
143 | Apple.record_cache.invalidate(:person_id, 4) unless fresh
144 | Apple.record_cache.invalidate(:person_id, 5) unless fresh
145 | # move one apple from store 1 to store 2
146 | @apple_moved_from_s1to2_p5to4 = @store1_apples.last # the last apple belongs to person Chase (id 5)
147 | @apple_moved_from_s1to2_p5to4.store_id = 2
148 | @apple_moved_from_s1to2_p5to4.person_id = 4
149 | @apple_moved_from_s1to2_p5to4.save!
150 | # check the cache hit/miss on the indexes that contained/contains that apple
151 | expect{ @apples_s1 = Apple.where(:store_id => 1).order('id ASC').load }.to hit_cache(Apple).on(:store_id).times(1)
152 | expect{ @apples_s2 = Apple.where(:store_id => 2).order('id ASC').load }.to hit_cache(Apple).on(:store_id).times(1)
153 | if fresh
154 | expect{ @apples_p1 = Apple.where(:person_id => 4).order('id ASC').load }.to hit_cache(Apple).on(:person_id).times(1)
155 | expect{ @apples_p2 = Apple.where(:person_id => 5).order('id ASC').load }.to hit_cache(Apple).on(:person_id).times(1)
156 | else
157 | expect{ @apples_p1 = Apple.where(:person_id => 4).order('id ASC').load }.to miss_cache(Apple).on(:person_id).times(1)
158 | expect{ @apples_p2 = Apple.where(:person_id => 5).order('id ASC').load }.to miss_cache(Apple).on(:person_id).times(1)
159 | end
160 | expect(@apples_s1.size).to eq(@store1_apples.size - 1)
161 | expect(@apples_s2.size).to eq(@store2_apples.size + 1)
162 | expect(@apples_p1.size).to eq(@person4_apples.size + 1)
163 | expect(@apples_p2.size).to eq(@person5_apples.size - 1)
164 | expect(@apples_s1.map(&:id)).to eq(@store1_apples.map(&:id) - [@apple_moved_from_s1to2_p5to4.id])
165 | expect(@apples_s2.map(&:id)).to eq([@apple_moved_from_s1to2_p5to4.id] + @store2_apples.map(&:id))
166 | expect(@apples_p1.map(&:id)).to eq(([@apple_moved_from_s1to2_p5to4.id] + @person4_apples.map(&:id)).sort)
167 | expect(@apples_p2.map(&:id)).to eq( (@person5_apples.map(&:id) - [@apple_moved_from_s1to2_p5to4.id]).sort)
168 | # and the index should be cached again
169 | expect{ Apple.where(:store_id => 1).load }.to hit_cache(Apple).on(:store_id).times(1)
170 | expect{ Apple.where(:store_id => 2).load }.to hit_cache(Apple).on(:store_id).times(1)
171 | expect{ Apple.where(:person_id => 4).load }.to hit_cache(Apple).on(:person_id).times(1)
172 | expect{ Apple.where(:person_id => 5).load }.to hit_cache(Apple).on(:person_id).times(1)
173 | end
174 | end
175 |
176 | it "should leave the index alone when a record outside the index is destroyed" do
177 | # destroy an apple of store 2
178 | @store2_apples.first.destroy
179 | # index of apples of store 1 are not affected
180 | expect{ @apples = Apple.where(:store_id => 1).order('id ASC').load }.to hit_cache(Apple).on(:store_id).times(1)
181 | end
182 |
183 | it "should leave the index alone when a record outside the index is created" do
184 | # create an apple for store 2
185 | Apple.create!(:name => "Fresh Apple", :store_id => 2)
186 | # index of apples of store 1 are not affected
187 | expect{ @apples = Apple.where(:store_id => 1).order('id ASC').load }.to hit_cache(Apple).on(:store_id).times(1)
188 | end
189 | end
190 |
191 | context "invalidate" do
192 | before(:each) do
193 | @store1_apples = Apple.where(:store_id => 1).to_a
194 | @store2_apples = Apple.where(:store_id => 2).to_a
195 | @address_1 = Address.where(:store_id => 1).to_a
196 | @address_2 = Address.where(:store_id => 2).to_a
197 | end
198 |
199 | it "should invalidate single index" do
200 | Apple.record_cache[:store_id].invalidate(1)
201 | expect{ Apple.where(:store_id => 1).load }.to miss_cache(Apple).on(:store_id).times(1)
202 | end
203 |
204 | it "should invalidate indexes when using update_all" do
205 | pending "is there a performant way to invalidate index caches within update_all? only the new value is available, so we should query the old values..."
206 | # update 2 apples for index values store 1 and store 2
207 | Apple.where(:id => [@store1_apples.first.id, @store2_apples.first.id]).update_all(:store_id => 3)
208 | expect{ @apples_1 = Apple.where(:store_id => 1).load }.to miss_cache(Apple).on(:store_id).times(1)
209 | expect{ @apples_2 = Apple.where(:store_id => 2).load }.to miss_cache(Apple).on(:store_id).times(1)
210 | expect(@apples_1.map(&:id).sort).to eq(@store1_apples[1..-1].sort)
211 | expect(@apples_2.map(&:id).sort).to eq(@store2_apples[1..-1].sort)
212 | end
213 |
214 | it "should invalidate reflection indexes when a has_many relation is updated" do
215 | # assign different apples to store 2
216 | expect{ Apple.where(:store_id => 1).first }.to hit_cache(Apple).on(:store_id).times(1)
217 | store2_apple_ids = @store2_apples.map(&:id).sort
218 | store1 = Store.find(1)
219 | store1.apple_ids = store2_apple_ids
220 | store1.save!
221 | # apples in Store 1 should be all (only) the apples that were in Store 2 (cache invalidated)
222 | expect{ @apples_1 = Apple.where(:store_id => 1).load }.to miss_cache(Apple).on(:store_id).times(1)
223 | expect(@apples_1.map(&:id).sort).to eq(store2_apple_ids)
224 | # there are no apples in Store 2 anymore (incremental cache update, as each apples in store 2 was saved separately)
225 | expect{ @apples_2 = Apple.where(:store_id => 2).load }.to hit_cache(Apple).on(:store_id).times(1)
226 | expect(@apples_2).to eq([])
227 | end
228 |
229 | it "should invalidate reflection indexes when a has_one relation is updated" do
230 | # assign different address to store 2
231 | expect{ Address.where(:store_id => 1).limit(1).first }.to hit_cache(Address).on(:store_id).times(1)
232 | store2 = Store.find(2)
233 | store2_address = store2.address
234 | Address.where(:store_id => 1).first.id == 1
235 | store1 = Store.find(1)
236 | store1.address = store2_address
237 | store1.save!
238 | Address.where(:store_id => 1).first.id == 2
239 | # address for Store 1 should be the address that was for Store 2 (cache invalidated)
240 | expect{ @address_1 = Address.where(:store_id => 1).first }.to hit_cache(Address).on(:store_id).times(1)
241 | expect(@address_1.id).to eq(store2_address.id)
242 | # there are no address in Store 2 anymore (incremental cache update, as address for store 2 was saved separately)
243 | expect{ @address_2 = Address.where(:store_id => 2).first }.to hit_cache(Address).on(:store_id).times(1)
244 | expect(@address_2).to be_nil
245 | end
246 |
247 | # see https://github.com/orslumen/record-cache/issues/19
248 | it "should work with serialized object" do
249 | address = Address.find(3) # not from cache
250 | address = Address.find(3) # from cache
251 | expect(address.location[:latitue]).to eq(27.175015)
252 | expect(address.location[:dms_lat]).to eq(%(27\u00B0 10' 30.0540" N))
253 | address.name = 'updated name'
254 | address.save!
255 | end
256 |
257 | it "should honor version store TTL" do
258 | Apple.record_cache[:store_id].invalidate(1)
259 | expect(RecordCache::Base.version_store.store.read('rc/apl/1')).not_to be_nil
260 | Timecop.travel(1.hour.from_now) do
261 | expect(RecordCache::Base.version_store.store.read('rc/apl/1')).to be_nil
262 | end
263 | end
264 | end
265 |
266 | context 'subclassing' do
267 | it "should delegate cache updates to the base class" do
268 | class RedDelicious < Apple; end
269 | apple = Apple.find(1)
270 | delicious = RedDelicious.find(1)
271 | store_id = apple.store_id
272 | delicious.store_id = 100
273 | delicious.save
274 | apple = Apple.find(1)
275 | expect(apple.store_id).to_not eq(store_id)
276 | apple.store_id = store_id
277 | apple.save
278 | end
279 | end
280 |
281 | end
282 |
--------------------------------------------------------------------------------
/spec/lib/strategy/query_cache_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | # This describes the behaviour as expected when ActiveRecord::QueryCache is
4 | # enabled. ActiveRecord::QueryCache is enabled by default in rails via a
5 | # middleware. During the scope of a request that cache is used.
6 | #
7 | # In console mode (or within e.g. a cron job) QueryCache isn't enabled.
8 | # You can still take advantage of this cache by executing
9 | #
10 | # ActiveRecord::Base.cache do
11 | # # your queries
12 | # end
13 | #
14 | # Be aware that though that during the execution of the block if updates
15 | # happen to records by another process, while you have already got
16 | # references to that records in QueryCache, that you won't see the changes
17 | # made by the other process.
18 | RSpec.describe "QueryCache" do
19 |
20 | it "should retrieve a record from the QueryCache" do
21 | ActiveRecord::Base.cache do
22 | expect{ Store.find(1) }.to miss_cache(Store).on(:id).times(1)
23 | second_lookup = expect{ Store.find(1) }
24 | second_lookup.to miss_cache(Store).times(0)
25 | second_lookup.to hit_cache(Store).on(:id).times(0)
26 | end
27 | end
28 |
29 | it "should maintain object identity when the same query is used" do
30 | ActiveRecord::Base.cache do
31 | @store_1 = Store.find(1)
32 | @store_2 = Store.find(1)
33 | expect(@store_1).to eq(@store_2)
34 | expect(@store_1.object_id).to eq(@store_2.object_id)
35 | end
36 | end
37 |
38 | context "record_change" do
39 | it "should clear the query cache completely when a record is created" do
40 | ActiveRecord::Base.cache do
41 | init_query_cache
42 | expect{ Store.find(2) }.to hit_cache(Store).times(0)
43 | expect{ Apple.find(1) }.to hit_cache(Apple).times(0)
44 | Store.create!(:name => "New Apple Store")
45 | expect{ Store.find(2) }.to hit_cache(Store).times(1)
46 | expect{ Apple.find(1) }.to hit_cache(Apple).times(1)
47 | end
48 | end
49 |
50 | it "should clear the query cache completely when a record is updated" do
51 | ActiveRecord::Base.cache do
52 | init_query_cache
53 | expect{ Store.find(2) }.to hit_cache(Store).times(0)
54 | expect{ Apple.find(1) }.to hit_cache(Apple).times(0)
55 | @store1.name = "Store E"
56 | @store1.save!
57 | expect{ Store.find(2) }.to hit_cache(Store).times(1)
58 | expect{ Apple.find(1) }.to hit_cache(Apple).times(1)
59 | end
60 | end
61 |
62 | it "should clear the query cache completely when a record is destroyed" do
63 | ActiveRecord::Base.cache do
64 | init_query_cache
65 | expect{ Store.find(2) }.to hit_cache(Store).times(0)
66 | expect{ Apple.find(1) }.to hit_cache(Apple).times(0)
67 | @store1.destroy
68 | expect{ Store.find(2) }.to hit_cache(Store).times(1)
69 | expect{ Apple.find(1) }.to hit_cache(Apple).times(1)
70 | end
71 | end
72 | end
73 |
74 | private
75 |
76 | # Cache a few objects in QueryCache to test with
77 | def init_query_cache
78 | @store1 = Store.find(1)
79 | @store2 = Store.find(2)
80 | @apple1 = Apple.find(1)
81 | end
82 |
83 | end
84 |
--------------------------------------------------------------------------------
/spec/lib/strategy/unique_index_on_id_cache_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | RSpec.describe RecordCache::Strategy::UniqueIndexCache do
4 |
5 | it "should retrieve an Apple from the cache" do
6 | expect{ Apple.find(1) }.to miss_cache(Apple).on(:id).times(1)
7 | expect{ Apple.find(1) }.to hit_cache(Apple).on(:id).times(1)
8 | end
9 |
10 | it "should accept find_by_sql queries (can not use the cache though)" do
11 | apple2 = Apple.find(2) # prefill cache
12 | apples = []
13 | expect{ apples = Apple.find_by_sql("select * from apples where id = 2") }.to_not use_cache(Apple).on(:id)
14 | expect(apples).to eq([apple2])
15 | end
16 |
17 | it "should accept parameterized find_by_sql queries (can not use the cache though)" do
18 | apple1 = Apple.find(1) # prefill cache
19 | apples = []
20 | expect{ apples = Apple.find_by_sql(["select * from apples where id = ?", 1]) }.to_not use_cache(Apple).on(:id)
21 | expect(apples).to eq([apple1])
22 | end
23 |
24 | it "should retrieve cloned records" do
25 | @apple_1a = Apple.find(1)
26 | @apple_1b = Apple.find(1)
27 | expect(@apple_1a).to eq(@apple_1b)
28 | expect(@apple_1a.object_id).to_not eq(@apple_1b.object_id)
29 | end
30 |
31 | context "logging" do
32 | before(:each) do
33 | Apple.find(1)
34 | end
35 |
36 | it "should write full hits to the debug log" do
37 | expect{ Apple.find(1) }.to log(:debug, %(UniqueIndexCache on 'Apple.id' hit for ids 1))
38 | end
39 |
40 | it "should write full miss to the debug log" do
41 | expect{ Apple.find(2) }.to log(:debug, %(UniqueIndexCache on 'Apple.id' miss for ids 2))
42 | end
43 |
44 | it "should write partial hits to the debug log" do
45 | expect{ Apple.where(:id => [1,2]).load }.to log(:debug, %(UniqueIndexCache on 'Apple.id' partial hit for ids [1, 2]: missing [2]))
46 | end
47 | end
48 |
49 | context "cacheable?" do
50 | before(:each) do
51 | # fill cache
52 | @apple1 = Apple.find(1)
53 | @apple2 = Apple.find(2)
54 | end
55 |
56 | # @see https://github.com/orslumen/record-cache/issues/2
57 | it "should not use the cache when a lock is used" do
58 | pending("Any_lock is sqlite specific and I'm not aware of a mysql alternative") unless ActiveRecord::Base.connection.adapter_name == "SQLite"
59 |
60 | expect{ Apple.lock("any_lock").where(:id => 1).load }.to_not hit_cache(Apple)
61 | end
62 |
63 | it "should use the cache when a single id is requested" do
64 | expect{ Apple.where(:id => 1).load }.to hit_cache(Apple).on(:id).times(1)
65 | end
66 |
67 | it "should use the cache when a multiple ids are requested" do
68 | expect{ Apple.where(:id => [1, 2]).load }.to hit_cache(Apple).on(:id).times(2)
69 | end
70 |
71 | it "should use the cache when a single id is requested and the limit is 1" do
72 | expect{ Apple.where(:id => 1).limit(1).load }.to hit_cache(Apple).on(:id).times(1)
73 | end
74 |
75 | it "should not use the cache when a single id is requested and the limit is > 1" do
76 | expect{ Apple.where(:id => 1).limit(2).load }.to_not use_cache(Apple).on(:id)
77 | end
78 |
79 | it "should not use the cache when multiple ids are requested and the limit is 1" do
80 | expect{ Apple.where(:id => [1, 2]).limit(1).load }.to_not use_cache(Apple).on(:id)
81 | end
82 |
83 | it "should use the cache when a single id is requested together with other where clauses" do
84 | expect{ Apple.where(:id => 1).where(:name => "Adams Apple x").load }.to hit_cache(Apple).on(:id).times(1)
85 | end
86 |
87 | it "should use the cache when a multiple ids are requested together with other where clauses" do
88 | expect{ Apple.where(:id => [1,2]).where(:name => "Adams Apple x").load }.to hit_cache(Apple).on(:id).times(2)
89 | end
90 |
91 | it "should use the cache when a single id is requested together with (simple) sort clauses" do
92 | expect{ Apple.where(:id => 1).order("name ASC").load }.to hit_cache(Apple).on(:id).times(1)
93 | end
94 |
95 | it "should use the cache when a multiple ids are requested together with (simple) sort clauses" do
96 | expect{ Apple.where(:id => [1,2]).order("name ASC").load }.to hit_cache(Apple).on(:id).times(2)
97 | end
98 |
99 | it "should not use the cache when a join clause is used" do
100 | expect{ Apple.where(:id => [1,2]).joins(:store).load }.to_not use_cache(Apple).on(:id)
101 | end
102 |
103 | it "should not use the cache when distinct is used in a select" do
104 | expect{ Apple.select('distinct person_id').where(:id => [1, 2]).load }.not_to hit_cache(Apple).on(:id)
105 | end
106 |
107 | it "should not use the cache when distinct is used in a select" do
108 | expect{ Apple.select('distinct person_id').where(:id => [1, 2]).load }.not_to hit_cache(Apple).on(:id)
109 | end
110 | end
111 |
112 | context "record_change" do
113 | before(:each) do
114 | # fill cache
115 | @apple1 = Apple.find(1)
116 | @apple2 = Apple.find(2)
117 | end
118 |
119 | it "should invalidate destroyed records" do
120 | expect{ Apple.where(:id => 1).load }.to hit_cache(Apple).on(:id).times(1)
121 | @apple1.destroy
122 | expect{ @apples = Apple.where(:id => 1).load }.to miss_cache(Apple).on(:id).times(1)
123 | expect(@apples).to be_empty
124 | # try again, to make sure the "missing record" is not cached
125 | expect{ Apple.where(:id => 1).load }.to miss_cache(Apple).on(:id).times(1)
126 | end
127 |
128 | it "should add updated records directly to the cache" do
129 | @apple1.name = "Applejuice"
130 | @apple1.save!
131 | expect{ @apple = Apple.find(1) }.to hit_cache(Apple).on(:id).times(1)
132 | expect(@apple.name).to eq("Applejuice")
133 | end
134 |
135 | it "should add created records directly to the cache" do
136 | @new_apple = Apple.create!(:name => "Fresh Apple", :store_id => 1)
137 | expect{ @apple = Apple.find(@new_apple.id) }.to hit_cache(Apple).on(:id).times(1)
138 | expect(@apple.name).to eq("Fresh Apple")
139 | end
140 |
141 | it "should add updated records to the cache, also when multiple ids are queried" do
142 | @apple1.name = "Applejuice"
143 | @apple1.save!
144 | expect{ @apples = Apple.where(:id => [1, 2]).order('id ASC').load }.to hit_cache(Apple).on(:id).times(2)
145 | expect(@apples.map(&:name)).to eq(["Applejuice", "Adams Apple 2"])
146 | end
147 |
148 | end
149 |
150 | context "invalidate" do
151 | before(:each) do
152 | @apple1 = Apple.find(1)
153 | @apple2 = Apple.find(2)
154 | end
155 |
156 | it "should invalidate single records" do
157 | Apple.record_cache[:id].invalidate(1)
158 | expect{ Apple.find(1) }.to miss_cache(Apple).on(:id).times(1)
159 | end
160 |
161 | it "should only miss the cache for the invalidated record when multiple ids are queried" do
162 | # miss on 1
163 | Apple.record_cache[:id].invalidate(1)
164 | expect{ Apple.where(:id => [1, 2]).load }.to miss_cache(Apple).on(:id).times(1)
165 | # hit on 2
166 | Apple.record_cache[:id].invalidate(1)
167 | expect{ Apple.where(:id => [1, 2]).load }.to hit_cache(Apple).on(:id).times(1)
168 | # nothing invalidated, both hit
169 | expect{ Apple.where(:id => [1, 2]).load }.to hit_cache(Apple).on(:id).times(2)
170 | end
171 |
172 | it "should invalidate records when using update_all" do
173 | Apple.where(:id => [3,4,5]).load # fill id cache on all Adam Store apples
174 | expect{ @apples = Apple.where(:id => [1, 2, 3, 4, 5]).order('id ASC').load }.to hit_cache(Apple).on(:id).times(5)
175 | expect(@apples.map(&:name)).to eq(["Adams Apple 1", "Adams Apple 2", "Adams Apple 3", "Adams Apple 4", "Adams Apple 5"])
176 | # update 3 of the 5 apples in the Adam Store
177 | Apple.where(:id => [1,2,3]).update_all(:name => "Uniform Apple")
178 | expect{ @apples = Apple.where(:id => [1, 2, 3, 4, 5]).order('id ASC').load }.to hit_cache(Apple).on(:id).times(2)
179 | expect(@apples.map(&:name)).to eq(["Uniform Apple", "Uniform Apple", "Uniform Apple", "Adams Apple 4", "Adams Apple 5"])
180 | end
181 |
182 | it "should invalidate reflection indexes when a has_many relation is updated" do
183 | # assign different apples to store 2
184 | expect{ Apple.where(:store_id => 1).load }.to hit_cache(Apple).on(:id).times(2)
185 | store2_apple_ids = Apple.where(:store_id => 2).map(&:id)
186 | store1 = Store.find(1)
187 | store1.apple_ids = store2_apple_ids
188 | store1.save!
189 | # the apples that used to belong to store 2 are now in store 1 (incremental update)
190 | expect{ @apple1 = Apple.find(store2_apple_ids.first) }.to hit_cache(Apple).on(:id).times(1)
191 | expect(@apple1.store_id).to eq(1)
192 | # the apples that used to belong to store 1 are now homeless (cache invalidated)
193 | expect{ @homeless_apple = Apple.find(1) }.to miss_cache(Apple).on(:id).times(1)
194 | expect(@homeless_apple.store_id).to be_nil
195 | end
196 |
197 | it "should reload from the DB after invalidation" do
198 | @apple = Apple.last
199 | Apple.record_cache.invalidate(@apple.id)
200 | expect{ Apple.find(@apple.id) }.to miss_cache(Apple).on(:id).times(1)
201 | end
202 |
203 | end
204 |
205 | context "transactions" do
206 |
207 | it "should update the cache once the transaction is committed" do
208 | apple1 = Apple.find(1)
209 | ActiveRecord::Base.transaction do
210 | apple1.name = "Committed Apple"
211 | apple1.save!
212 |
213 | # do not use the cache within a transaction
214 | expect{ apple1 = Apple.find(1) }.to_not use_cache(Apple).on(:id)
215 | expect(apple1.name).to eq("Committed Apple")
216 | end
217 |
218 | # use the cache again once the transaction is over
219 | expect{ apple1 = Apple.find(1) }.to use_cache(Apple).on(:id)
220 | expect(apple1.name).to eq("Committed Apple")
221 | end
222 |
223 | it "should not update the cache when the transaction is rolled back" do
224 | apple1 = Apple.find(1)
225 | ActiveRecord::Base.transaction do
226 | apple1.name = "Rollback Apple"
227 | apple1.save!
228 |
229 | # test to make sure appl1 is not retrieved 1:1 from the cache
230 | apple1.name = "Not saved apple"
231 |
232 | # do not use the cache within a transaction
233 | expect{ apple1 = Apple.find(1) }.to_not use_cache(Apple).on(:id)
234 | expect(apple1.name).to eq("Rollback Apple")
235 |
236 | raise ActiveRecord::Rollback, "oops"
237 | end
238 |
239 | # use the cache again once the transaction is over
240 | expect{ apple1 = Apple.find(1) }.to use_cache(Apple).on(:id)
241 | expect(apple1.name).to eq("Adams Apple 1")
242 | end
243 |
244 | end
245 |
246 | context "nested transactions" do
247 |
248 | it "should update the cache in case both transactions are committed" do
249 | apple1, apple2 = nil
250 |
251 | ActiveRecord::Base.transaction do
252 | apple1 = Apple.find(1)
253 | apple1.name = "Committed Apple 1"
254 | apple1.save!
255 |
256 | ActiveRecord::Base.transaction(requires_new: true) do
257 | apple2 = Apple.find(2)
258 | apple2.name = "Committed Apple 2"
259 | apple2.save!
260 | end
261 | end
262 |
263 | expect{ apple1 = Apple.find(1) }.to use_cache(Apple).on(:id)
264 | expect(apple1.name).to eq("Committed Apple 1")
265 |
266 | expect{ apple2 = Apple.find(2) }.to use_cache(Apple).on(:id)
267 | expect(apple2.name).to eq("Committed Apple 2")
268 | end
269 |
270 | [:implicitly, :explicitly].each do |inner_rollback_explicit_or_implicit|
271 | it "should not update the cache in case both transactions are #{inner_rollback_explicit_or_implicit} rolled back" do
272 | pending "nested transaction support" if ActiveRecord::VERSION::MAJOR == 3 && ActiveRecord::VERSION::MINOR == 0
273 | apple1, apple2 = nil
274 |
275 | ActiveRecord::Base.transaction do
276 | apple1 = Apple.find(1)
277 | apple1.name = "Rollback Apple 1"
278 | apple1.save!
279 | apple1.name = "Saved Apple 1"
280 |
281 | ActiveRecord::Base.transaction(requires_new: true) do
282 | apple2 = Apple.find(2)
283 | apple2.name = "Rollback Apple 2"
284 | apple2.save!
285 | apple1.name = "Saved Apple 2"
286 |
287 | raise ActiveRecord::Rollback, "oops" if inner_rollback_explicit_or_implicit == :explicitly
288 | end
289 |
290 | raise ActiveRecord::Rollback, "oops"
291 | end
292 |
293 | expect{ apple1 = Apple.find(1) }.to use_cache(Apple).on(:id)
294 | expect(apple1.name).to eq("Adams Apple 1")
295 |
296 | expect{ apple2 = Apple.find(2) }.to use_cache(Apple).on(:id)
297 | expect(apple2.name).to eq("Adams Apple 2")
298 | end
299 | end
300 |
301 | # does not work for Rails 3.0
302 | unless ActiveRecord::VERSION::MAJOR == 3 && ActiveRecord::VERSION::MINOR == 0
303 | it "should not update the cache for the rolled back inner transaction" do
304 | apple1, apple2 = nil
305 |
306 | ActiveRecord::Base.transaction do
307 | apple1 = Apple.find(1)
308 | apple1.name = "Committed Apple 1"
309 | apple1.save!
310 |
311 | ActiveRecord::Base.transaction(requires_new: true) do
312 | apple2 = Apple.find(2)
313 | apple2.name = "Rollback Apple 2"
314 | apple2.save!
315 |
316 | raise ActiveRecord::Rollback, "oops"
317 | end
318 | end
319 |
320 | expect{ apple1 = Apple.find(1) }.to use_cache(Apple).on(:id)
321 | expect(apple1.name).to eq("Committed Apple 1")
322 |
323 | expect{ apple2 = Apple.find(2) }.to use_cache(Apple).on(:id)
324 | expect(apple2.name).to eq("Adams Apple 2")
325 | end
326 | end
327 |
328 | end
329 | end
330 |
--------------------------------------------------------------------------------
/spec/lib/strategy/unique_index_on_string_cache_spec.rb:
--------------------------------------------------------------------------------
1 | require "spec_helper"
2 |
3 | RSpec.describe RecordCache::Strategy::UniqueIndexCache do
4 |
5 | it "should retrieve an Person from the cache" do
6 | expect{ Person.find_by(name: "Fry") }.to miss_cache(Person).on(:name).times(1)
7 | expect{ Person.find_by(name: "Fry") }.to hit_cache(Person).on(:name).times(1)
8 | end
9 |
10 | it "should retrieve cloned records" do
11 | @fry_a = Person.find_by(name: "Fry")
12 | @fry_b = Person.find_by(name: "Fry")
13 | expect(@fry_a).to eq(@fry_b)
14 | expect(@fry_a.object_id).to_not eq(@fry_b.object_id)
15 | end
16 |
17 | context "logging" do
18 | before(:each) do
19 | Person.find_by(name: "Fry")
20 | end
21 |
22 | it "should write full hits to the debug log" do
23 | expect{ Person.find_by(name: "Fry") }.to log(:debug, %(UniqueIndexCache on 'Person.name' hit for ids "Fry"))
24 | end
25 |
26 | it "should write full miss to the debug log" do
27 | expect{ Person.find_by(name: "Chase") }.to log(:debug, %(UniqueIndexCache on 'Person.name' miss for ids "Chase"))
28 | end
29 |
30 | it "should write partial hits to the debug log" do
31 | expect{ Person.where(:name => ["Fry", "Chase"]).load }.to log(:debug, %(UniqueIndexCache on 'Person.name' partial hit for ids ["Fry", "Chase"]: missing ["Chase"]))
32 | end
33 | end
34 |
35 | context "cacheable?" do
36 | before(:each) do
37 | # fill cache
38 | @fry = Person.find_by(name: "Fry")
39 | @chase = Person.find_by(name: "Chase")
40 | end
41 |
42 | # @see https://github.com/orslumen/record-cache/issues/2
43 | it "should not use the cache when a lock is used" do
44 | pending("Any_lock is sqlite specific and I'm not aware of a mysql alternative") unless ActiveRecord::Base.connection.adapter_name == "SQLite"
45 |
46 | expect{ Person.lock("any_lock").where(:name => "Fry").load }.to_not hit_cache(Person)
47 | end
48 |
49 | it "should use the cache when a single id is requested" do
50 | expect{ Person.where(:name => "Fry").load }.to hit_cache(Person).on(:name).times(1)
51 | end
52 |
53 | it "should use the cache when a multiple ids are requested" do
54 | expect{ Person.where(:name => ["Fry", "Chase"]).load }.to hit_cache(Person).on(:name).times(2)
55 | end
56 |
57 | it "should use the cache when a single id is requested and the limit is 1" do
58 | expect{ Person.where(:name => "Fry").limit(1).load }.to hit_cache(Person).on(:name).times(1)
59 | end
60 |
61 | it "should not use the cache when a single id is requested and the limit is > 1" do
62 | expect{ Person.where(:name => "Fry").limit(2).load }.to_not use_cache(Person).on(:name)
63 | end
64 |
65 | it "should not use the cache when multiple ids are requested and the limit is 1" do
66 | expect{ Person.where(:name => ["Fry", "Chase"]).limit(1).load }.to_not use_cache(Person).on(:name)
67 | end
68 |
69 | it "should use the cache when a single id is requested together with other where clauses" do
70 | expect{ Person.where(:name => "Fry").where(:height => 1.67).load }.to hit_cache(Person).on(:name).times(1)
71 | end
72 |
73 | it "should use the cache when a multiple ids are requested together with other where clauses" do
74 | expect{ Person.where(:name => ["Fry", "Chase"]).where(:height => 1.67).load }.to hit_cache(Person).on(:name).times(2)
75 | end
76 |
77 | it "should use the cache when a single id is requested together with (simple) sort clauses" do
78 | expect{ Person.where(:name => "Fry").order("name ASC").load }.to hit_cache(Person).on(:name).times(1)
79 | end
80 |
81 | it "should use the cache when a single id is requested together with (simple) case insensitive sort clauses" do
82 | expect{ Person.where(:name => "Fry").order("name desc").load }.to hit_cache(Person).on(:name).times(1)
83 | end
84 |
85 | it "should use the cache when a single id is requested together with (simple) sort clauses with table prefix" do
86 | expect{ Person.where(:name => "Fry").order("people.name desc").load }.to hit_cache(Person).on(:name).times(1)
87 | end
88 |
89 | it "should not use the cache when a single id is requested together with an unknown sort clause" do
90 | expect{ Person.where(:name => "Fry").order("lower(people.name) desc").load }.to_not hit_cache(Person).on(:name).times(1)
91 | end
92 |
93 | it "should use the cache when a multiple ids are requested together with (simple) sort clauses" do
94 | expect{ Person.where(:name => ["Fry", "Chase"]).order("name ASC").load }.to hit_cache(Person).on(:name).times(2)
95 | end
96 | end
97 |
98 | context "record_change" do
99 | before(:each) do
100 | # fill cache
101 | @fry = Person.find_by(name: "Fry")
102 | @chase = Person.find_by(name: "Chase")
103 | end
104 |
105 | it "should invalidate destroyed records" do
106 | expect{ Person.where(:name => "Fry").load }.to hit_cache(Person).on(:name).times(1)
107 | @fry.destroy
108 | expect{ @people = Person.where(:name => "Fry").load }.to miss_cache(Person).on(:name).times(1)
109 | expect(@people).to be_empty
110 | # try again, to make sure the "missing record" is not cached
111 | expect{ Person.where(:name => "Fry").load }.to miss_cache(Person).on(:name).times(1)
112 | end
113 |
114 | it "should add updated records directly to the cache" do
115 | @fry.height = 1.71
116 | @fry.save!
117 | expect{ @person = Person.find_by(name: "Fry") }.to hit_cache(Person).on(:name).times(1)
118 | expect(@person.height).to eq(1.71)
119 | end
120 |
121 | it "should add created records directly to the cache" do
122 | Person.create!(:name => "Flower", :birthday => Date.civil(1990,07,29), :height => 1.80)
123 | expect{ @person = Person.find_by(name: "Flower") }.to hit_cache(Person).on(:name).times(1)
124 | expect(@person.height).to eq(1.80)
125 | end
126 |
127 | it "should add updated records to the cache, also when multiple ids are queried" do
128 | @fry.height = 1.71
129 | @fry.save!
130 | expect{ @people = Person.where(:name => ["Fry", "Chase"]).order("id ASC").load }.to hit_cache(Person).on(:name).times(2)
131 | expect(@people.map(&:height)).to eq([1.71, 1.91])
132 | end
133 |
134 | end
135 |
136 | context "invalidate" do
137 | before(:each) do
138 | @fry = Person.find_by(name: "Fry")
139 | @chase = Person.find_by(name: "Chase")
140 | end
141 |
142 | it "should invalidate single records" do
143 | Person.record_cache[:name].invalidate("Fry")
144 | expect{ Person.find_by(name: "Fry") }.to miss_cache(Person).on(:name).times(1)
145 | end
146 |
147 | it "should only miss the cache for the invalidated record when multiple ids are queried" do
148 | # miss on 1
149 | Person.record_cache[:name].invalidate("Fry")
150 | expect{ Person.where(:name => ["Fry", "Chase"]).load }.to miss_cache(Person).on(:name).times(1)
151 | # hit on 2
152 | Person.record_cache[:name].invalidate("Fry")
153 | expect{ Person.where(:name => ["Fry", "Chase"]).load }.to hit_cache(Person).on(:name).times(1)
154 | # nothing invalidated, both hit
155 | expect{ Person.where(:name => ["Fry", "Chase"]).load }.to hit_cache(Person).on(:name).times(2)
156 | end
157 |
158 | it "should invalidate records when using update_all" do
159 | Person.where(:id => ["Fry", "Chase", "Penny"]).load # fill id cache on all Adam Store apples
160 | expect{ @people = Person.where(:name => ["Fry", "Chase", "Penny"]).order("name ASC").load }.to hit_cache(Person).on(:name).times(2)
161 | expect(@people.map(&:name)).to eq(["Chase", "Fry", "Penny"])
162 | # update 2 of the 3 People
163 | Person.where(:name => ["Fry", "Penny"]).update_all(:height => 1.21)
164 | expect{ @people = Person.where(:name => ["Fry", "Chase", "Penny"]).order("height ASC").load }.to hit_cache(Person).on(:name).times(1)
165 | expect(@people.map(&:height)).to eq([1.21, 1.21, 1.91])
166 | end
167 |
168 | end
169 |
170 | end
171 |
--------------------------------------------------------------------------------
/spec/lib/strategy/util_spec.rb:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 | require 'spec_helper'
3 |
4 | RSpec.describe RecordCache::Strategy::Util do
5 |
6 | it "should serialize a record (currently Active Record only)" do
7 | expect(subject.serialize(Banana.find(1))).to eq({:a=>{"name"=>"Blue Banana 1", "id"=>1, "store_id"=>2, "person_id"=>4}, :c=>"Banana"})
8 | end
9 |
10 | it "should deserialize a record (currently Active Record only)" do
11 | expect(subject.deserialize({:a=>{"name"=>"Blue Banana 1", "id"=>1, "store_id"=>2, "person_id"=>4}, :c=>"Banana"})).to eq(Banana.find(1))
12 | end
13 |
14 | it "should call the after_finalize and after_find callbacks when deserializing a record" do
15 | record = subject.deserialize({:a=>{"name"=>"Blue Banana 1", "id"=>1, "store_id"=>2, "person_id"=>4}, :c=>"Banana"})
16 | expect(record.logs.sort).to eq(["after_find", "after_initialize"])
17 | end
18 |
19 | it "should not be a new record nor have changed attributes after deserializing a record" do
20 | record = subject.deserialize({:a=>{"id"=>1}, :c=>"Banana"})
21 | expect(record.new_record?).to be_falsey
22 | expect(record.changed_attributes).to be_empty
23 | end
24 |
25 | context "filter" do
26 | it "should apply filter" do
27 | apples = Apple.where(id: [1, 2]).to_a
28 | subject.filter!(apples, name: "Adams Apple 1")
29 | expect(apples).to eq([Apple.find_by(name: "Adams Apple 1")])
30 | end
31 |
32 | it "should return empty array when filter does not match any record" do
33 | apples = Apple.where(id: [1, 2])
34 | subject.filter!(apples, name: "Adams Apple Pie")
35 | expect(apples).to be_empty
36 | end
37 |
38 | it "should filter on text" do
39 | apples = Apple.where(id: [1, 2])
40 | subject.filter!(apples, name: "Adams Apple 1")
41 | expect(apples).to eq([Apple.find_by(name: "Adams Apple 1")])
42 | end
43 |
44 | it "should filter on text case insensitive" do
45 | apples = Apple.where(id: [1, 2])
46 | subject.filter!(apples, name: "adams aPPle 1")
47 | expect(apples).to eq([Apple.find_by(name: "Adams Apple 1")])
48 | end
49 |
50 | it "should filter on integers" do
51 | apples = Apple.where(id: [1, 2, 8, 9])
52 | subject.filter!(apples, store_id: 2)
53 | expect(apples.map(&:id).sort).to eq([8, 9])
54 | end
55 |
56 | it "should filter on dates" do
57 | people = Person.where(id: [1, 2, 3])
58 | subject.filter!(people, birthday: Date.civil(1953, 11, 11))
59 | expect(people.size).to eq(1)
60 | expect(people.first.name).to eq("Blue")
61 | end
62 |
63 | it "should filter on floats" do
64 | people = Person.where(id: [1, 2, 3])
65 | subject.filter!(people, height: 1.75)
66 | expect(people.size).to eq(2)
67 | expect(people.map(&:name).sort).to eq(["Blue", "Cris"])
68 | end
69 |
70 | it "should filter on arrays" do
71 | apples = Apple.where(:id => [1, 2, 8, 9])
72 | subject.filter!(apples, :store_id => [2, 4])
73 | expect(apples.map(&:id).sort).to eq([8, 9])
74 | end
75 |
76 | it "should filter on multiple fields" do
77 | # make sure two apples exist with the same name
78 | apple = Apple.find(8)
79 | apple.name = Apple.find(9).name
80 | apple.save!
81 |
82 | apples = Apple.where(id: [1, 2, 3, 8, 9, 10])
83 | subject.filter!(apples, store_id: [2, 4], name: apple.name)
84 | expect(apples.size).to eq(2)
85 | expect(apples.map(&:name)).to eq([apple.name, apple.name])
86 | expect(apples.map(&:id).sort).to eq([8,9])
87 | end
88 |
89 | it "should filter with more than 2 and conditions" do
90 | # this construction leads to a arel object with 3 Equality Nodes within a single And Node
91 | apples = Apple.where(store_id: [1,2]).where(store_id: 1, person_id: nil)
92 | expect(apples.size).to eq(2)
93 | expect(apples.map(&:id).sort).to eq([1, 2])
94 | end
95 |
96 | end
97 |
98 | context "sort" do
99 | it "should accept a Symbol as a sort order" do
100 | people = Person.where(:id => [1,2,3]).to_a
101 | subject.sort!(people, :name)
102 | expect(people.map(&:name)).to eq(["Adam", "Blue", "Cris"])
103 | end
104 |
105 | it "should accept a single Array as a sort order" do
106 | people = Person.where(:id => [1,2,3]).to_a
107 | subject.sort!(people, [:name, false])
108 | expect(people.map(&:name)).to eq(["Cris", "Blue", "Adam"])
109 | end
110 |
111 | it "should accept multiple Symbols as a sort order" do
112 | people = Person.where(:id => [2,3,4,5]).to_a
113 | subject.sort!(people, :height, :id)
114 | expect(people.map(&:height)).to eq([1.69, 1.75, 1.75, 1.91])
115 | expect(people.map(&:id)).to eq([4, 2, 3, 5])
116 | end
117 |
118 | it "should accept a mix of Symbols and Arrays as a sort order" do
119 | people = Person.where(:id => [2,3,4,5]).to_a
120 | subject.sort!(people, [:height, false], :id)
121 | expect(people.map(&:height)).to eq([1.91, 1.75, 1.75, 1.69])
122 | expect(people.map(&:id)).to eq([5, 2, 3, 4])
123 | end
124 |
125 | it "should accept multiple Arrays as a sort order" do
126 | people = Person.where(:id => [2,3,4,5]).to_a
127 | subject.sort!(people, [:height, false], [:id, false])
128 | expect(people.map(&:height)).to eq([1.91, 1.75, 1.75, 1.69])
129 | expect(people.map(&:id)).to eq([5, 3, 2, 4])
130 | end
131 |
132 | it "should accept an Array with Arrays as a sort order (default used by record cache)" do
133 | people = Person.where(:id => [2,3,4,5]).to_a
134 | subject.sort!(people, [[:height, false], [:id, false]])
135 | expect(people.map(&:height)).to eq([1.91, 1.75, 1.75, 1.69])
136 | expect(people.map(&:id)).to eq([5, 3, 2, 4])
137 | end
138 |
139 | it "should order nil first for ASC" do
140 | apples = Apple.where(:store_id => 1).to_a
141 | subject.sort!(apples, [:person_id, true])
142 | expect(apples.map(&:person_id)).to eq([nil, nil, 4, 4, 5])
143 | end
144 |
145 | it "should order nil last for DESC" do
146 | apples = Apple.where(:store_id => 1).to_a
147 | subject.sort!(apples, [:person_id, false])
148 | expect(apples.map(&:person_id)).to eq([5, 4, 4, nil, nil])
149 | end
150 |
151 | it "should order ascending on text" do
152 | people = Person.where(:id => [1,2,3,4]).to_a
153 | subject.sort!(people, [:name, true])
154 | expect(people.map(&:name)).to eq(["Adam", "Blue", "Cris", "Fry"])
155 | end
156 |
157 | it "should order descending on text" do
158 | people = Person.where(:id => [1,2,3,4]).to_a
159 | subject.sort!(people, [:name, false])
160 | expect(people.map(&:name)).to eq(["Fry", "Cris", "Blue", "Adam"])
161 | end
162 |
163 | it "should order ascending on integers" do
164 | people = Person.where(:id => [4,2,1,3]).to_a
165 | subject.sort!(people, [:id, true])
166 | expect(people.map(&:id)).to eq([1,2,3,4])
167 | end
168 |
169 | it "should order descending on integers" do
170 | people = Person.where(:id => [4,2,1,3]).to_a
171 | subject.sort!(people, [:id, false])
172 | expect(people.map(&:id)).to eq([4,3,2,1])
173 | end
174 |
175 | it "should order ascending on dates" do
176 | people = Person.where(:id => [1,2,3,4]).to_a
177 | subject.sort!(people, [:birthday, true])
178 | expect(people.map(&:birthday)).to eq([Date.civil(1953,11,11), Date.civil(1975,03,20), Date.civil(1975,03,20), Date.civil(1985,01,20)])
179 | end
180 |
181 | it "should order descending on dates" do
182 | people = Person.where(:id => [1,2,3,4]).to_a
183 | subject.sort!(people, [:birthday, false])
184 | expect(people.map(&:birthday)).to eq([Date.civil(1985,01,20), Date.civil(1975,03,20), Date.civil(1975,03,20), Date.civil(1953,11,11)])
185 | end
186 |
187 | it "should order ascending on float" do
188 | people = Person.where(:id => [1,2,3,4]).to_a
189 | subject.sort!(people, [:height, true])
190 | expect(people.map(&:height)).to eq([1.69, 1.75, 1.75, 1.83])
191 | end
192 |
193 | it "should order descending on float" do
194 | people = Person.where(:id => [1,2,3,4]).to_a
195 | subject.sort!(people, [:height, false])
196 | expect(people.map(&:height)).to eq([1.83, 1.75, 1.75, 1.69])
197 | end
198 |
199 | it "should order on multiple fields (ASC + ASC)" do
200 | people = Person.where(:id => [2,3,4,5]).to_a
201 | subject.sort!(people, [:height, true], [:id, true])
202 | expect(people.map(&:height)).to eq([1.69, 1.75, 1.75, 1.91])
203 | expect(people.map(&:id)).to eq([4, 2, 3, 5])
204 | end
205 |
206 | it "should order on multiple fields (ASC + DESC)" do
207 | people = Person.where(:id => [2,3,4,5]).to_a
208 | subject.sort!(people, [:height, true], [:id, false])
209 | expect(people.map(&:height)).to eq([1.69, 1.75, 1.75, 1.91])
210 | expect(people.map(&:id)).to eq([4, 3, 2, 5])
211 | end
212 |
213 | it "should order on multiple fields (DESC + ASC)" do
214 | people = Person.where(:id => [2,3,4,5]).to_a
215 | subject.sort!(people, [:height, false], [:id, true])
216 | expect(people.map(&:height)).to eq([1.91, 1.75, 1.75, 1.69])
217 | expect(people.map(&:id)).to eq([5, 2, 3, 4])
218 | end
219 |
220 | it "should order on multiple fields (DESC + DESC)" do
221 | people = Person.where(:id => [2,3,4,5]).to_a
222 | subject.sort!(people, [:height, false], [:id, false])
223 | expect(people.map(&:height)).to eq([1.91, 1.75, 1.75, 1.69])
224 | expect(people.map(&:id)).to eq([5, 3, 2, 4])
225 | end
226 |
227 | it "should use mysql style collation" do
228 | ids = []
229 | ids << Person.create!(:name => "ċedriĉ 3").id # latin other special
230 | ids << Person.create!(:name => "a cedric").id # first in ascending order
231 | ids << Person.create!(:name => "čedriĉ 4").id # latin another special
232 | ids << Person.create!(:name => "ćedriĉ Last").id # latin special lowercase
233 | ids << Person.create!(:name => "sedric 1").id # second to last latin in ascending order
234 | ids << Person.create!(:name => "Cedric 2").id # ascii uppercase
235 | ids << Person.create!(:name => "čedriĉ คฉ Almost last cedric").id # latin special, with non-latin
236 | ids << Person.create!(:name => "Sedric 2").id # last latin in ascending order
237 | ids << Person.create!(:name => "1 cedric").id # numbers before characters
238 | ids << Person.create!(:name => "cedric 1").id # ascii lowercase
239 | ids << Person.create!(:name => "คฉ Really last").id # non-latin characters last in ascending order
240 | ids << Person.create!(:name => "čedriĉ ꜩ Last").id # latin special, with latin non-collateable
241 |
242 | names_asc = ["1 cedric", "a cedric", "cedric 1", "Cedric 2", "ċedriĉ 3", "čedriĉ 4", "ćedriĉ Last", "čedriĉ คฉ Almost last cedric", "čedriĉ ꜩ Last", "sedric 1", "Sedric 2", "คฉ Really last"]
243 | people = Person.where(:id => ids).to_a
244 | subject.sort!(people, [:name, true])
245 | expect(people.map(&:name)).to eq(names_asc)
246 |
247 | subject.sort!(people, [:name, false])
248 | expect(people.map(&:name)).to eq(names_asc.reverse)
249 | end
250 | end
251 |
252 | end
253 |
--------------------------------------------------------------------------------
/spec/lib/version_store_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | RSpec.describe RecordCache::VersionStore do
4 |
5 | before(:each) do
6 | @version_store = RecordCache::Base.version_store
7 | @version_store.store.write("key1", 1000)
8 | @version_store.store.write("key2", 2000)
9 | end
10 |
11 | it "should only accept ActiveSupport cache stores" do
12 | expect{ RecordCache::VersionStore.new(Object.new) }.to raise_error("Store Object must respond to write")
13 | end
14 |
15 | context "current" do
16 | it "should retrieve the current version" do
17 | expect(@version_store.current("key1")).to eq(1000)
18 | end
19 |
20 | it "should retrieve nil for unknown keys" do
21 | expect(@version_store.current("unknown_key")).to be_nil
22 | end
23 | end
24 |
25 | context "current_multi" do
26 | it "should retrieve all versions" do
27 | expect(@version_store.current_multi({:id1 => "key1", :id2 => "key2"})).to eq({:id1 => 1000, :id2 => 2000})
28 | end
29 |
30 | it "should return nil for unknown keys" do
31 | expect(@version_store.current_multi({:id1 => "key1", :key3 => "unknown_key"})).to eq({:id1 => 1000, :key3 => nil})
32 | end
33 |
34 | it "should use read_multi on the underlying store" do
35 | allow(@version_store.store).to receive(:read_multi).with(/key[12]/, /key[12]/) { {"key1" => 5, "key2" => 6} }
36 | expect(@version_store.current_multi({:id1 => "key1", :id2 => "key2"})).to eq({:id1 => 5, :id2 => 6})
37 | end
38 | end
39 |
40 | context "renew" do
41 | it "should renew the version" do
42 | expect(@version_store.current("key1")).to eq(1000)
43 | @version_store.renew("key1")
44 | expect(@version_store.current("key1")).to_not eq(1000)
45 | end
46 |
47 | it "should renew the version for unknown keys" do
48 | expect(@version_store.current("unknown_key")).to be_nil
49 | @version_store.renew("unknown_key")
50 | expect(@version_store.current("unknown_key")).to_not be_nil
51 | end
52 |
53 | it "should call on_write_failure hook when renew fails" do
54 | allow(@version_store.store).to receive(:write) { false }
55 | failed = nil
56 | @version_store.on_write_failure{ |key| failed = key }
57 | @version_store.renew("key1")
58 | expect(failed).to eq("key1")
59 | end
60 |
61 | it "should not call on_write_failure hook when renew_for_read fails" do
62 | allow(@version_store.store).to receive(:write) { false }
63 | failed = "nothing failed"
64 | @version_store.on_write_failure{ |key| failed = key }
65 | @version_store.renew_for_read("key1")
66 | expect(failed).to eq("nothing failed")
67 | end
68 |
69 | it "should not call on_write_failure hook when renew succeeds" do
70 | failed = "nothing failed"
71 | @version_store.on_write_failure{ |key| failed = key }
72 | @version_store.renew("key1")
73 | expect(failed).to eq("nothing failed")
74 | end
75 |
76 | it "should write to the debug log" do
77 | expect{ @version_store.renew("key1") }.to log(:debug, /Version Store: renew key1: nil => \d+/)
78 | end
79 | end
80 |
81 | # deprecated
82 | context "increment" do
83 |
84 | it "should write to the debug log" do
85 | expect{ @version_store.increment("key1") }.to log(:debug, /increment is deprecated, use renew instead/)
86 | end
87 |
88 | end
89 |
90 | context "delete" do
91 | it "should delete the version" do
92 | expect(@version_store.current("key1")).to eq(1000)
93 | expect(@version_store.delete("key1")).to be_truthy
94 | expect(@version_store.current("key1")).to be_nil
95 | end
96 |
97 | it "should not raise an error when deleting the version for unknown keys" do
98 | expect(@version_store.current("unknown_key")).to be_nil
99 | expect(@version_store.delete("unknown_key")).to be_falsey
100 | expect(@version_store.current("unknown_key")).to be_nil
101 | end
102 |
103 | it "should call on_write_failure hook when delete fails" do
104 | allow(@version_store.store).to receive(:delete) { false }
105 | failed = nil
106 | @version_store.on_write_failure{ |key| failed = key }
107 | @version_store.delete("key1")
108 | expect(failed).to eq("key1")
109 | end
110 |
111 | it "should not call on_write_failure hook when delete succeeds" do
112 | failed = "nothing failed"
113 | @version_store.on_write_failure{ |key| failed = key }
114 | @version_store.delete("key1")
115 | expect(failed).to eq("nothing failed")
116 | end
117 |
118 | it "should write to the debug log" do
119 | expect{ @version_store.delete("key1") }.to log(:debug, %(Version Store: deleted key1))
120 | end
121 | end
122 |
123 | end
124 |
--------------------------------------------------------------------------------
/spec/models/address.rb:
--------------------------------------------------------------------------------
1 | class Address < ActiveRecord::Base
2 |
3 | cache_records :store => :shared, :key => "add", :index => [:store_id]
4 |
5 | serialize :location, Hash
6 |
7 | belongs_to :store
8 |
9 | end
10 |
--------------------------------------------------------------------------------
/spec/models/apple.rb:
--------------------------------------------------------------------------------
1 | class Apple < ActiveRecord::Base
2 | cache_records :store => :shared, :key => "apl", :index => [:store_id, :person_id], :ttl => 300
3 |
4 | belongs_to :store
5 | belongs_to :person
6 |
7 | end
8 |
--------------------------------------------------------------------------------
/spec/models/banana.rb:
--------------------------------------------------------------------------------
1 | class Banana < ActiveRecord::Base
2 |
3 | cache_records :store => :local, :index => [:person_id]
4 |
5 | belongs_to :store
6 | belongs_to :person
7 |
8 | after_initialize :do_after_initialize
9 | after_find :do_after_find
10 |
11 | def logs
12 | @logs ||= []
13 | end
14 |
15 | private
16 |
17 | def do_after_initialize
18 | self.logs << "after_initialize"
19 | true
20 | end
21 |
22 | def do_after_find
23 | self.logs << "after_find"
24 | true
25 | end
26 |
27 | end
28 |
--------------------------------------------------------------------------------
/spec/models/language.rb:
--------------------------------------------------------------------------------
1 | class Language < ActiveRecord::Base
2 |
3 | cache_records :store => :local, :full_table => true
4 |
5 | end
6 |
--------------------------------------------------------------------------------
/spec/models/pear.rb:
--------------------------------------------------------------------------------
1 | class Pear < ActiveRecord::Base
2 |
3 | belongs_to :store
4 | belongs_to :person
5 |
6 | end
7 |
--------------------------------------------------------------------------------
/spec/models/person.rb:
--------------------------------------------------------------------------------
1 | class Person < ActiveRecord::Base
2 |
3 | cache_records :store => :shared, :key => "per", :unique_index => :name
4 |
5 | has_many :apples # cached with index on person_id
6 | has_many :bananas # cached with index on person_id
7 | has_many :pears # not cached
8 |
9 | has_and_belongs_to_many :stores
10 |
11 | end
12 |
--------------------------------------------------------------------------------
/spec/models/store.rb:
--------------------------------------------------------------------------------
1 | class Store < ActiveRecord::Base
2 |
3 | cache_records :store => :local, :key => "st"
4 |
5 | belongs_to :owner, :class_name => "Person"
6 |
7 | has_many :apples, :autosave => true # cached with index on store
8 | has_many :bananas # cached without index on store
9 | has_many :pears # not cached
10 | has_one :address, :autosave => true
11 |
12 | has_and_belongs_to_many :customers, :class_name => "Person"
13 |
14 | end
15 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | ENV["RAILS_ENV"]="test"
2 |
3 | dir = File.dirname(__FILE__)
4 | $LOAD_PATH.unshift dir + "/../lib"
5 | $LOAD_PATH.unshift dir
6 |
7 | require 'simplecov'
8 | SimpleCov.start do
9 | add_filter '/spec/'
10 | end
11 |
12 | require 'rubygems'
13 | require 'rspec'
14 | require 'database_cleaner'
15 | require 'logger'
16 | require 'record_cache'
17 | require 'record_cache/test/resettable_version_store'
18 |
19 | require 'test_after_commit'
20 |
21 | # spec support files
22 | Dir[File.dirname(__FILE__) + "/support/**/*.rb"].each {|f| require f}
23 |
24 | # logging
25 | Dir.mkdir(dir + "/log") unless File.exists?(dir + "/log")
26 | ActiveRecord::Base.logger = Logger.new(dir + "/log/debug.log")
27 | # ActiveRecord::Base.logger = Logger.new(STDOUT)
28 |
29 | # SQL Lite
30 | ActiveRecord::Base.configurations = YAML::load(IO.read(dir + "/db/database.yml"))
31 | ActiveRecord::Base.establish_connection(ENV["DATABASE_ADAPTER"] || "sqlite3")
32 |
33 | # Initializers + Model + Data
34 | load(dir + "/initializers/record_cache.rb")
35 | load(dir + "/db/schema.rb")
36 | Dir["#{dir}/models/*.rb"].each {|f| load(f) }
37 | load(dir + "/db/seeds.rb")
38 |
39 | # backwards compatibility for previous versions of rails
40 | load(dir + "/initializers/backward_compatibility.rb")
41 |
42 | # Clear cache after each test
43 | RSpec.configure do |config|
44 | config.disable_monkey_patching!
45 | config.color = true
46 |
47 | config.before(:each) do
48 | RecordCache::Base.enable
49 | DatabaseCleaner.start
50 | end
51 |
52 | config.after(:each) do
53 | DatabaseCleaner.clean
54 | RecordCache::Base.version_store.reset!
55 | end
56 | end
57 |
--------------------------------------------------------------------------------
/spec/support/matchers/hit_cache_matcher.rb:
--------------------------------------------------------------------------------
1 | # Examples:
2 | # 1) expect{ Person.find(22) }.to hit_cache(Person)
3 | # _should have at least one hit in any of the cache strategies for the Person model_
4 | #
5 | # 2) expect{ Person.find(22) }.to hit_cache(Person).on(:id)
6 | # _should have at least one hit in the ID cache strategy for the Person model_
7 | #
8 | # 3) expect{ Person.find_by_ids(22, 23, 24) }.to hit_cache(Person).on(:id).times(2)
9 | # _should have exactly two hits in the :id cache strategy for the Person model_
10 | #
11 | # 4) expect{ Person.find_by_ids(22, 23, 24) }.to hit_cache(Person).times(3)
12 | # _should have exactly three hits in any of the cache strategies for the Person model_
13 | RSpec::Matchers.define :hit_cache do |model|
14 |
15 | def supports_block_expectations?
16 | true
17 | end
18 |
19 | chain :on do |strategy|
20 | @strategy = strategy
21 | end
22 |
23 | chain :times do |nr_of_hits|
24 | @expected_nr_of_hits = nr_of_hits
25 | end
26 |
27 | match do |proc|
28 | # reset statistics for the given model and start counting
29 | RecordCache::Statistics.reset!(model)
30 | RecordCache::Statistics.start
31 | # call the given proc
32 | proc.call
33 | # collect statistics for the model
34 | @stats = RecordCache::Statistics.find(model)
35 | # check the nr of hits
36 | @nr_of_hits = @strategy ? @stats[@strategy].hits : @stats.values.map{ |s| s.hits }.sum
37 | # test nr of hits
38 | @expected_nr_of_hits ? @nr_of_hits == @expected_nr_of_hits : @nr_of_hits > 0
39 | end
40 |
41 | failure_message do |proc|
42 | prepare_message
43 | "Expected #{@strategy_msg} for #{model.name} to be hit #{@times_msg}, but found #{@nr_of_hits}: #{@statistics_msg}"
44 | end
45 |
46 | failure_message_when_negated do |proc|
47 | prepare_message
48 | "Expected #{@strategy_msg} for #{model.name} not to be hit #{@times_msg}, but found #{@nr_of_hits}: #{@statistics_msg}"
49 | end
50 |
51 | def prepare_message
52 | @strategy_msg = @strategy ? "the #{@strategy} cache" : "any of the caches"
53 | @times_msg = @expected_nr_of_hits ? (@expected_nr_of_hits == 1 ? "exactly once" : "exactly #{@expected_nr_of_hits} times") : "at least once"
54 | @statistics_msg = @stats.map{|strategy, s| "#{strategy} => #{s.inspect}" }.join(", ")
55 | end
56 |
57 | end
58 |
--------------------------------------------------------------------------------
/spec/support/matchers/log.rb:
--------------------------------------------------------------------------------
1 | # Examples:
2 | # 1) expect{ Person.find(22) }.to log(:debug, %(UniqueIndexCache on 'id' hit for ids 1)
3 | # _should have at least one debug log statement as given above_
4 | #
5 | # 2) expect{ Person.find(22) }.to log(:debug, /^UniqueIndexCache/)
6 | # _should have at least one debug log statement starting with UniqueIndexCache_
7 | RSpec::Matchers.define :log do |severity, expected|
8 |
9 | def supports_block_expectations?
10 | true
11 | end
12 |
13 | match do |proc|
14 | logger = RecordCache::Base.logger
15 | # override the debug/info/warn/error method
16 | logger.instance_variable_set(:@found_messages, [])
17 | logger.instance_variable_set(:@found, false)
18 | logger.class.send(:alias_method, "orig_#{severity}", severity)
19 | logger.class.send(:define_method, severity) do |progname = nil, &block|
20 | unless @found
21 | actual= progname.is_a?(String) ? progname : block ? block.call : nil
22 | unless actual.blank?
23 | @found = actual.is_a?(String) && expected.is_a?(Regexp) ? actual =~ expected : actual == expected
24 | @found_messages << actual
25 | end
26 | end
27 | end
28 | # call the given proc
29 | proc.call
30 | # redefine
31 | logger.class.send(:alias_method, severity, "orig_#{severity}")
32 | # the result
33 | @found_messages = logger.instance_variable_get(:@found_messages)
34 | @found = logger.instance_variable_get(:@found)
35 | end
36 |
37 | failure_message do |proc|
38 | "Expected #{@found_messages.inspect} to include #{expected.inspect}"
39 | end
40 |
41 | failure_message_when_negated do |proc|
42 | "Expected #{@found_messages.inspect} not to include #{expected.inspect}"
43 | end
44 |
45 | end
--------------------------------------------------------------------------------
/spec/support/matchers/miss_cache_matcher.rb:
--------------------------------------------------------------------------------
1 | # Examples:
2 | # 1) expect{ Person.find(22) }.to miss_cache(Person)
3 | # _should have at least one miss in any of the cache strategies for the Person model_
4 | #
5 | # 2) expect{ Person.find(22) }.to miss_cache(Person).on(:id)
6 | # _should have at least one miss for the ID cache strategy for the Person model_
7 | #
8 | # 3) expect{ Person.find_by_ids(22, 23, 24) }.to miss_cache(Person).on(:id).times(2)
9 | # _should have exactly two misses in the :id cache strategy for the Person model_
10 | #
11 | # 4) expect{ Person.find_by_ids(22, 23, 24) }.to miss_cache(Person).times(3)
12 | # _should have exactly three misses in any of the cache strategies for the Person model_
13 | RSpec::Matchers.define :miss_cache do |model|
14 |
15 | def supports_block_expectations?
16 | true
17 | end
18 |
19 | chain :on do |strategy|
20 | @strategy = strategy
21 | end
22 |
23 | chain :times do |nr_of_misses|
24 | @expected_nr_of_misses = nr_of_misses
25 | end
26 |
27 | match do |proc|
28 | # reset statistics for the given model and start counting
29 | RecordCache::Statistics.reset!(model)
30 | RecordCache::Statistics.start
31 | # call the given proc
32 | proc.call
33 | # collect statistics for the model
34 | @stats = RecordCache::Statistics.find(model)
35 | # check the nr of misses
36 | @nr_of_misses = @strategy ? @stats[@strategy].misses : @stats.values.map{ |s| s.misses }.sum
37 | # test nr of misses
38 | @expected_nr_of_misses ? @nr_of_misses == @expected_nr_of_misses : @nr_of_misses > 0
39 | end
40 |
41 | failure_message do |proc|
42 | prepare_message
43 | "Expected #{@strategy_msg} for #{model.name} to be missed #{@times_msg}, but found #{@nr_of_misses}: #{@statistics_msg}"
44 | end
45 |
46 | failure_message_when_negated do |proc|
47 | prepare_message
48 | "Expected #{@strategy_msg} for #{model.name} not to be missed #{@times_msg}, but found #{@nr_of_misses}: #{@statistics_msg}"
49 | end
50 |
51 | def prepare_message
52 | @strategy_msg = @strategy ? "the #{@strategy} cache" : "any of the caches"
53 | @times_msg = @expected_nr_of_misses ? (@expected_nr_of_misses == 1 ? "exactly once" : "exactly #{@expected_nr_of_misses} times") : "at least once"
54 | @statistics_msg = @stats.map{|strategy, s| "#{strategy} => #{s.inspect}" }.join(", ")
55 | end
56 |
57 | end
58 |
--------------------------------------------------------------------------------
/spec/support/matchers/use_cache_matcher.rb:
--------------------------------------------------------------------------------
1 | # Examples:
2 | # 1) expect{ Person.find(22) }.to use_cache(Person)
3 | # _should perform at least one call (hit/miss) to any of the cache strategies for the Person model_
4 | #
5 | # 2) expect{ Person.find(22) }.to use_cache(Person).on(:id)
6 | # _should perform at least one call (hit/miss) to the ID cache strategy for the Person model_
7 | #
8 | # 3) expect{ Person.find_by_ids(22, 23, 24) }.to use_cache(Person).on(:id).times(2)
9 | # _should perform exactly two calls (hit/miss) to the :id cache strategy for the Person model_
10 | #
11 | # 4) expect{ Person.find_by_ids(22, 23, 24) }.to use_cache(Person).times(3)
12 | # _should perform exactly three calls (hit/miss) to any of the cache strategies for the Person model_
13 | RSpec::Matchers.define :use_cache do |model|
14 |
15 | def supports_block_expectations?
16 | true
17 | end
18 |
19 | chain :on do |strategy|
20 | @strategy = strategy
21 | end
22 |
23 | chain :times do |nr_of_calls|
24 | @expected_nr_of_calls = nr_of_calls
25 | end
26 |
27 | match do |proc|
28 | # reset statistics for the given model and start counting
29 | RecordCache::Statistics.reset!(model)
30 | RecordCache::Statistics.start
31 | # call the given proc
32 | proc.call
33 | # collect statistics for the model
34 | @stats = RecordCache::Statistics.find(model)
35 | # check the nr of calls
36 | @nr_of_calls = @strategy ? @stats[@strategy].calls : @stats.values.map{ |s| s.calls }.sum
37 | # test nr of calls
38 | @expected_nr_of_calls ? @nr_of_calls == @expected_nr_of_calls : @nr_of_calls > 0
39 | end
40 |
41 | failure_message do |proc|
42 | prepare_message
43 | "Expected #{@strategy_msg} for #{model.name} to be called #{@times_msg}, but found #{@nr_of_calls}: #{@statistics_msg}"
44 | end
45 |
46 | failure_message_when_negated do |proc|
47 | prepare_message
48 | "Expected #{@strategy_msg} for #{model.name} not to be called #{@times_msg}, but found #{@nr_of_calls}: #{@statistics_msg}"
49 | end
50 |
51 | def prepare_message
52 | @strategy_msg = @strategy ? "the #{@strategy} cache" : "any of the caches"
53 | @times_msg = @expected_nr_of_calls ? (@expected_nr_of_calls == 1 ? "exactly once" : "exactly #{@expected_nr_of_calls} times") : "at least once"
54 | @statistics_msg = @stats.map{|strategy, s| "#{strategy} => #{s.inspect}" }.join(", ")
55 | end
56 |
57 | end
58 |
--------------------------------------------------------------------------------