├── .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 | [![Build Status](http://img.shields.io/travis/orslumen/record-cache.svg?style=flat)](https://travis-ci.org/orslumen/record-cache) 5 | [![Gem Version](http://img.shields.io/gem/v/record-cache.svg?style=flat)](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 | --------------------------------------------------------------------------------