├── test ├── model │ ├── application_record.rb │ ├── paranoid.rb │ ├── topic.rb │ ├── order.rb │ ├── order_item.rb │ ├── image.rb │ ├── animal.rb │ ├── account.rb │ ├── contribution.rb │ ├── post.rb │ ├── book.rb │ └── user.rb ├── preloader_has_many_test.rb ├── enum_attr_test.rb ├── require_test.rb ├── paranoid_test.rb ├── polymorphic_association_test.rb ├── belongs_to_association_test.rb ├── second_level_cache_test.rb ├── base_test.rb ├── single_table_inheritance_test.rb ├── test_helper.rb ├── persistence_test.rb ├── preloader_belongs_to_test.rb ├── has_one_association_test.rb ├── preloader_non_integer_test.rb ├── record_marshal_test.rb ├── preloader_has_one_test.rb ├── finder_methods_test.rb ├── fetch_by_uniq_key_test.rb └── active_record_test_case_helper.rb ├── lib ├── second_level_cache │ ├── version.rb │ ├── active_record │ │ ├── core.rb │ │ ├── base.rb │ │ ├── persistence.rb │ │ ├── belongs_to_association.rb │ │ ├── has_one_association.rb │ │ ├── fetch_by_uniq_key.rb │ │ ├── preloader │ │ │ ├── legacy.rb │ │ │ └── association.rb │ │ └── finder_methods.rb │ ├── log_subscriber.rb │ ├── record_relation.rb │ ├── record_marshal.rb │ ├── config.rb │ ├── adapter │ │ └── paranoia.rb │ ├── active_record.rb │ └── mixin.rb └── second_level_cache.rb ├── Gemfile ├── gemfiles ├── Gemfile-6-1 ├── Gemfile-6-0 └── Gemfile-6-0-paranoia ├── .gitignore ├── Rakefile ├── .github └── workflows │ └── build.yml ├── LICENSE ├── second_level_cache.gemspec ├── CHANGELOG.md ├── .rubocop.yml └── README.md /test/model/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | self.abstract_class = true 3 | end 4 | -------------------------------------------------------------------------------- /lib/second_level_cache/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SecondLevelCache 4 | VERSION = "2.7.1" 5 | end 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec 6 | gem "pry" 7 | gem "rails", "~> 7" 8 | -------------------------------------------------------------------------------- /gemfiles/Gemfile-6-1: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec path: '..' 4 | gem "paranoia", "~> 2.4.2" 5 | gem "pry" 6 | gem 'rails', "~> 6.1" 7 | -------------------------------------------------------------------------------- /gemfiles/Gemfile-6-0: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec path: '..' 4 | gem "paranoia", "~> 2.4.2" 5 | gem "pry" 6 | gem 'rails', "~> 6.0.0" 7 | -------------------------------------------------------------------------------- /gemfiles/Gemfile-6-0-paranoia: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec path: '..' 4 | #gem "paranoia", "~> 2.4.2" 5 | gem "pry" 6 | gem 'rails', "~> 6.0.0" 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle 2 | /nbproject/* 3 | /.rvmrc 4 | /doc 5 | /pkg 6 | /tags 7 | /spec/log 8 | *.log 9 | *.gem 10 | *.sqlite3 11 | *-journal 12 | *.swp 13 | *.swo 14 | *~ 15 | *.lock 16 | vendor/bundle 17 | .idea 18 | .ruby-version 19 | -------------------------------------------------------------------------------- /test/model/paranoid.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Base.connection.create_table(:paranoids, force: true) do |t| 4 | t.datetime :deleted_at 5 | end 6 | 7 | class Paranoid < ApplicationRecord 8 | second_level_cache 9 | acts_as_paranoid 10 | end 11 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | # frozen_string_literal: true 3 | 4 | require "bundler/gem_tasks" 5 | require "rake/testtask" 6 | 7 | task default: :test 8 | 9 | Rake::TestTask.new do |t| 10 | t.libs << "lib" << "test" 11 | t.test_files = FileList["test/**/*_test.rb"] 12 | end 13 | -------------------------------------------------------------------------------- /test/model/topic.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Base.connection.create_table(:topics, force: true) do |t| 4 | t.string :title 5 | t.text :body 6 | 7 | t.timestamps null: false 8 | end 9 | 10 | class Topic < ApplicationRecord 11 | second_level_cache 12 | 13 | has_many :posts 14 | end 15 | -------------------------------------------------------------------------------- /test/model/order.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Base.connection.create_table(:orders, force: true, id: :uuid) do |t| 4 | t.text :body 5 | t.string :title 6 | 7 | t.timestamps null: false 8 | end 9 | 10 | class Order < ApplicationRecord 11 | second_level_cache 12 | 13 | has_many :order_items 14 | end 15 | -------------------------------------------------------------------------------- /test/model/order_item.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Base.connection.create_table(:order_items, force: true, id: :uuid) do |t| 4 | t.text :body 5 | t.string :slug 6 | t.string :order_id 7 | end 8 | 9 | class OrderItem < ApplicationRecord 10 | second_level_cache 11 | belongs_to :order, touch: true 12 | end 13 | -------------------------------------------------------------------------------- /test/model/image.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Base.connection.create_table(:images, force: true) do |t| 4 | t.string :url 5 | t.string :imagable_type 6 | t.integer :imagable_id 7 | end 8 | 9 | class Image < ApplicationRecord 10 | second_level_cache 11 | 12 | belongs_to :imagable, polymorphic: true, counter_cache: true 13 | end 14 | -------------------------------------------------------------------------------- /test/preloader_has_many_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class PreloaderHasManyTest < ActiveSupport::TestCase 6 | def test_preloader_returns_correct_records 7 | topic = Topic.create(id: 1) 8 | Post.create(id: 1) 9 | post = topic.posts.create 10 | 11 | assert_equal [post], Topic.includes(:posts).find(1).posts 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/model/animal.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Base.connection.create_table(:animals, force: true) do |t| 4 | t.string :type 5 | t.string :name 6 | t.timestamps null: false 7 | end 8 | 9 | class Animal < ApplicationRecord 10 | second_level_cache 11 | end 12 | 13 | class Dog < Animal 14 | second_level_cache 15 | end 16 | 17 | class Cat < Animal 18 | end 19 | -------------------------------------------------------------------------------- /test/model/account.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Base.connection.create_table(:accounts, force: true) do |t| 4 | t.integer :age 5 | t.string :site 6 | t.integer :user_id 7 | t.timestamps null: false 8 | end 9 | 10 | class Account < ApplicationRecord 11 | second_level_cache expires_in: 3.days 12 | belongs_to :user, foreign_key: :user_id, inverse_of: :account 13 | end 14 | -------------------------------------------------------------------------------- /test/enum_attr_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class EnumAttrTest < ActiveSupport::TestCase 6 | def setup 7 | @user = User.create name: "csdn", email: "test@csdn.com" 8 | end 9 | 10 | def test_enum_attr 11 | @user.archived! 12 | assert_equal "archived", @user.status 13 | assert_equal "archived", User.find(@user.id).status 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/model/contribution.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Base.connection.create_table(:contributions, force: true) do |t| 4 | t.integer :user_id 5 | t.text :data 6 | t.date :date 7 | end 8 | 9 | class Contribution < ApplicationRecord 10 | second_level_cache 11 | 12 | validates_uniqueness_of :user_id, scope: :date, if: -> { user_id_changed? || date_changed? } 13 | belongs_to :user 14 | end 15 | -------------------------------------------------------------------------------- /test/require_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "active_record" 5 | 6 | class RequireTest < ActiveSupport::TestCase 7 | def setup 8 | ActiveRecord::Relation.new(nil, table: nil, predicate_builder: nil) 9 | require "test_helper" 10 | @user = User.create name: "Dingding Ye", email: "yedingding@gmail.com" 11 | end 12 | 13 | def test_should_find_the_user 14 | assert_equal @user, User.find(@user.id) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/second_level_cache/active_record/core.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SecondLevelCache 4 | module ActiveRecord 5 | module Core 6 | def self.prepended(base) 7 | class << base 8 | prepend ClassMethods 9 | end 10 | end 11 | 12 | module ClassMethods 13 | def find(*ids) 14 | return all.find(ids.first) if ids.size == 1 15 | super(*ids) 16 | end 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/paranoid_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class ParanoidTest < ActiveSupport::TestCase 6 | def setup 7 | skip unless defined?(Paranoi) 8 | @paranoid = Paranoid.create 9 | end 10 | 11 | def test_should_expire_cache_when_destroy 12 | skip unless defined(Paranoi) 13 | @paranoid.destroy 14 | assert_nil Paranoid.find_by(id: @paranoid.id) 15 | assert_nil SecondLevelCache.cache_store.read(@paranoid.second_level_cache_key) 16 | assert_nil User.read_second_level_cache(@paranoid.id) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/model/post.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Base.connection.create_table(:posts, force: true) do |t| 4 | t.text :body 5 | t.string :slug 6 | t.integer :topic_id 7 | end 8 | 9 | ActiveRecord::Base.connection.create_table(:hotspots, force: true) do |t| 10 | t.integer :post_id 11 | t.string :summary 12 | end 13 | 14 | class Post < ApplicationRecord 15 | second_level_cache 16 | belongs_to :topic, touch: true 17 | end 18 | 19 | class Hotspot < ApplicationRecord 20 | belongs_to :post, required: false 21 | has_one :topic, through: :post 22 | end 23 | -------------------------------------------------------------------------------- /lib/second_level_cache/log_subscriber.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SecondLevelCache 4 | class LogSubscriber < ActiveSupport::LogSubscriber 5 | # preload.second_level_cache 6 | def preload(event) 7 | prefix = color("SecondLevelCache", CYAN) 8 | miss_ids = (event.payload[:miss] || []).join(",") 9 | hit_ids = (event.payload[:hit] || []).join(",") 10 | debug " #{prefix} preload #{event.payload[:key]} miss [#{miss_ids}], hit [#{hit_ids}]" 11 | end 12 | end 13 | end 14 | 15 | SecondLevelCache::LogSubscriber.attach_to :second_level_cache 16 | -------------------------------------------------------------------------------- /test/model/book.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Base.connection.create_table(:books, force: true) do |t| 4 | t.string :title 5 | t.string :body 6 | t.integer :user_id 7 | t.decimal :discount_percentage, precision: 5, scale: 2 8 | t.integer :images_count, default: 0 9 | t.date :publish_date 10 | t.boolean :normal, default: true, nil: false 11 | end 12 | 13 | class Book < ApplicationRecord 14 | second_level_cache 15 | 16 | default_scope -> { where(normal: true) } 17 | 18 | belongs_to :user, counter_cache: true 19 | has_many :images, as: :imagable 20 | end 21 | -------------------------------------------------------------------------------- /lib/second_level_cache/record_relation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SecondLevelCache 4 | class RecordRelation < Array 5 | # A fake Array for fix ActiveRecord 5.0.1 records_for method changed bug 6 | # 7 | # in ActiveRecord 5.0.0 called: 8 | # records_for(slice) 9 | # 10 | # but 5.0.1 called: 11 | # https://github.com/rails/rails/blob/master/activerecord/lib/active_record/associations/preloader/association.rb#L118 12 | # records_for(slice).load(&block) 13 | # 14 | # https://github.com/rails/rails/pull/26340 15 | def load(&_block) 16 | self 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/second_level_cache/record_marshal.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RecordMarshal 4 | class << self 5 | # dump ActiveRecord instance with only attributes before type cast. 6 | def dump(record) 7 | [record.class.name, record.attributes_before_type_cast] 8 | end 9 | 10 | # load a cached record 11 | def load(serialized, &block) 12 | return unless serialized 13 | 14 | serialized[0].constantize.instantiate(serialized[1], &block) 15 | end 16 | 17 | # load multi cached records 18 | def load_multi(serializeds, &block) 19 | serializeds.map { |serialized| load(serialized, &block) } 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/second_level_cache/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SecondLevelCache 4 | class Config 5 | class << self 6 | attr_writer :cache_store, :logger, :cache_key_prefix 7 | 8 | def cache_store 9 | @cache_store ||= Rails.cache if defined?(Rails) 10 | @cache_store 11 | end 12 | 13 | def logger 14 | ActiveSupport::Deprecation.warn("logger is deprecated and will be removed from SecondLevelCache 2.7.0") 15 | @logger ||= Rails.logger if defined?(Rails) 16 | @logger ||= Logger.new(STDOUT) 17 | end 18 | 19 | def cache_key_prefix 20 | @cache_key_prefix ||= "slc" 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/second_level_cache/adapter/paranoia.rb: -------------------------------------------------------------------------------- 1 | module SecondLevelCache 2 | module Adapter 3 | module Paranoia 4 | module ActiveRecord 5 | extend ActiveSupport::Concern 6 | 7 | included do 8 | after_destroy :expire_second_level_cache 9 | end 10 | end 11 | 12 | module Mixin 13 | extend ActiveSupport::Concern 14 | 15 | def write_second_level_cache 16 | # Avoid rewrite cache again, when record has been soft deleted 17 | return if respond_to?(:deleted?) && send(:deleted?) 18 | super 19 | end 20 | 21 | alias_method :update_second_level_cache, :write_second_level_cache 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/polymorphic_association_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class PolymorphicAssociationTest < ActiveSupport::TestCase 6 | def setup 7 | @user = User.create name: "csdn", email: "test@csdn.com" 8 | end 9 | 10 | def test_should_get_cache_when_use_polymorphic_association 11 | image = @user.images.create 12 | 13 | @user.write_second_level_cache 14 | assert_no_queries do 15 | assert_equal @user, image.imagable 16 | end 17 | end 18 | 19 | def test_should_write_polymorphic_association_cache 20 | image = @user.images.create 21 | assert_nil User.read_second_level_cache(@user.id) 22 | assert_equal @user, image.imagable 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/second_level_cache/active_record/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SecondLevelCache 4 | module ActiveRecord 5 | module Base 6 | def self.prepended(base) 7 | base.after_commit :update_second_level_cache, on: :update 8 | base.after_commit :write_second_level_cache, on: :create 9 | base.after_commit :expire_second_level_cache, on: :destroy 10 | 11 | class << base 12 | prepend ClassMethods 13 | end 14 | end 15 | 16 | module ClassMethods 17 | def update_counters(id, counters) 18 | super(id, counters).tap do 19 | Array(id).each { |i| expire_second_level_cache(i) } 20 | end 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/belongs_to_association_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class BelongsToAssociationTest < ActiveSupport::TestCase 6 | def setup 7 | @user = User.create name: "csdn", email: "test@csdn.com" 8 | end 9 | 10 | def test_should_get_cache_when_use_belongs_to_association 11 | book = @user.books.create 12 | 13 | @user.write_second_level_cache 14 | 15 | assert_no_queries do 16 | assert_equal @user, book.user 17 | end 18 | end 19 | 20 | def test_should_write_belongs_to_association_cache 21 | book = @user.books.create 22 | @user.expire_second_level_cache 23 | assert_nil User.read_second_level_cache(@user.id) 24 | assert_equal @user, book.user 25 | # assert_not_nil User.read_second_level_cache(@user.id) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/second_level_cache_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class SecondLevelCacheTest < ActiveSupport::TestCase 6 | def setup 7 | @user = User.create name: "csdn", email: "test@csdn.com" 8 | end 9 | 10 | def test_should_get_cache_key 11 | attr_list = User.attribute_types.map { |name, type| "#{name}: #{type.type}" } * ", " 12 | table_digest = Digest::MD5.hexdigest(attr_list) 13 | refute_nil table_digest 14 | assert_equal "slc/users/#{@user.id}/#{User::CACHE_VERSION}/#{table_digest}", @user.second_level_cache_key 15 | end 16 | 17 | def test_should_write_and_read_cache 18 | @user.write_second_level_cache 19 | refute_nil User.read_second_level_cache(@user.id) 20 | @user.expire_second_level_cache 21 | assert_nil User.read_second_level_cache(@user.id) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: push 3 | jobs: 4 | build: 5 | if: "!contains(github.event.head_commit.message, '[skip ci]')" 6 | runs-on: ubuntu-latest 7 | strategy: 8 | fail-fast: false 9 | matrix: 10 | include: 11 | - ruby: 3.0 12 | gemfile: Gemfile 13 | - ruby: 2.7 14 | gemfile: gemfiles/Gemfile-6-1 15 | - ruby: 2.7 16 | gemfile: gemfiles/Gemfile-6-0-paranoia 17 | - ruby: 2.6 18 | gemfile: gemfiles/Gemfile-6-0 19 | env: 20 | BUNDLE_GEMFILE: ${{ matrix.gemfile }} 21 | USE_OFFICIAL_GEM_SOURCE: 1 22 | steps: 23 | - uses: actions/checkout@v2 24 | - uses: ruby/setup-ruby@v1 25 | with: 26 | ruby-version: ${{ matrix.ruby }} 27 | bundler-cache: true 28 | - run: bundle exec rake test 29 | -------------------------------------------------------------------------------- /lib/second_level_cache/active_record/persistence.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SecondLevelCache 4 | module ActiveRecord 5 | module Persistence 6 | # update_column will call update_columns 7 | # https://github.com/rails/rails/blob/5-0-stable/activerecord/lib/active_record/persistence.rb#L315 8 | def update_columns(attributes) 9 | super(attributes).tap { update_second_level_cache } 10 | end 11 | 12 | # https://github.com/rails/rails/blob/5-0-stable/activerecord/lib/active_record/persistence.rb#L441 13 | def reload(options = nil) 14 | expire_second_level_cache 15 | super(options) 16 | end 17 | 18 | # https://github.com/rails/rails/blob/5-0-stable/activerecord/lib/active_record/persistence.rb#L490 19 | def touch(*names, **opts) 20 | # super: touch(*names, time: nil) 21 | super(*names, **opts).tap { update_second_level_cache } 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/second_level_cache/active_record/belongs_to_association.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SecondLevelCache 4 | module ActiveRecord 5 | module Associations 6 | module BelongsToAssociation 7 | def find_target 8 | return super unless klass.second_level_cache_enabled? 9 | return super if klass.default_scopes.present? || reflection.scope 10 | return super if reflection.active_record_primary_key.to_s != klass.primary_key 11 | 12 | cache_record = klass.read_second_level_cache(second_level_cache_key) 13 | if cache_record 14 | return cache_record.tap { |record| set_inverse_instance(record) } 15 | end 16 | 17 | record = super 18 | return nil unless record 19 | 20 | record.tap do |r| 21 | set_inverse_instance(r) 22 | r.write_second_level_cache 23 | end 24 | end 25 | 26 | private 27 | def second_level_cache_key 28 | owner[reflection.foreign_key] 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Hackershare 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/second_level_cache.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/all" 4 | require "second_level_cache/config" 5 | require "second_level_cache/record_marshal" 6 | require "second_level_cache/record_relation" 7 | require "second_level_cache/active_record" 8 | require "second_level_cache/log_subscriber" 9 | 10 | module SecondLevelCache 11 | def self.configure 12 | block_given? ? yield(Config) : Config 13 | end 14 | 15 | def self.without_second_level_cache 16 | old_cache_enabled = SecondLevelCache.cache_enabled? 17 | SecondLevelCache.cache_enabled = false 18 | 19 | yield 20 | ensure 21 | SecondLevelCache.cache_enabled = old_cache_enabled 22 | end 23 | 24 | def self.cache_enabled? 25 | if self.cache_store.is_a?(ActiveSupport::Cache::NullStore) 26 | return false 27 | end 28 | cache_enabled = Thread.current[:slc_cache_enabled] 29 | cache_enabled.nil? ? true : cache_enabled 30 | end 31 | 32 | def self.cache_enabled=(cache_enabled) 33 | Thread.current[:slc_cache_enabled] = cache_enabled 34 | end 35 | 36 | class << self 37 | delegate :logger, :cache_store, :cache_key_prefix, to: Config 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/base_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class BaseTest < ActiveSupport::TestCase 6 | def setup 7 | @user = User.create name: "csdn", email: "test@csdn.com" 8 | end 9 | 10 | def test_should_update_cache_when_update_attributes 11 | @user.update! name: "change" 12 | assert_equal @user.name, User.read_second_level_cache(@user.id).name 13 | end 14 | 15 | def test_should_update_cache_when_update_attribute 16 | @user.update_attribute :name, "change" 17 | assert_equal @user.name, User.read_second_level_cache(@user.id).name 18 | end 19 | 20 | def test_should_expire_cache_when_destroy 21 | @user = User.create name: "csdn", email: "test@csdn.com" 22 | @user.destroy 23 | assert_nil User.find_by(id: @user.id) 24 | assert_nil SecondLevelCache.cache_store.read(@user.second_level_cache_key) 25 | assert_nil User.read_second_level_cache(@user.id) 26 | end 27 | 28 | def test_should_expire_cache_when_update_counters 29 | assert_equal 0, @user.books_count 30 | @user.books.create! 31 | assert_nil User.read_second_level_cache(@user.id) 32 | user = User.find(@user.id) 33 | assert_equal 1, user.books_count 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/single_table_inheritance_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class SingleTableInheritanceTest < ActiveSupport::TestCase 6 | def test_superclass_find__caches_superclass_record 7 | animal = Animal.create 8 | assert_no_queries do 9 | assert_equal animal, Animal.find(animal.id) 10 | end 11 | end 12 | 13 | def test_superclass_find__caches_subclass_record 14 | dog = Dog.create 15 | assert_no_queries do 16 | assert_equal dog, Animal.find(dog.id) 17 | end 18 | end 19 | 20 | def test_subclass_find__caches_subclass_record 21 | dog = Dog.create 22 | dog_id = dog.id 23 | assert_no_queries do 24 | newdog = Dog.find(dog_id) 25 | assert_equal dog, newdog 26 | end 27 | end 28 | 29 | def test_subclass_find__doesnt_find_superclass_record 30 | animal = Animal.create 31 | assert_queries(:any) do 32 | assert_raises ActiveRecord::RecordNotFound do 33 | Dog.find(animal.id) 34 | end 35 | end 36 | end 37 | 38 | def test_superclass_find__caches_all_subclasses 39 | cat = Cat.create 40 | assert_no_queries do 41 | assert_equal cat, Animal.find(cat.id) 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/second_level_cache/active_record/has_one_association.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SecondLevelCache 4 | module ActiveRecord 5 | module Associations 6 | module HasOneAssociation 7 | def find_target 8 | return super unless klass.second_level_cache_enabled? 9 | return super if klass.default_scopes.present? || reflection.scope 10 | # TODO: implement cache with has_one scope 11 | 12 | through = reflection.options[:through] 13 | record = if through 14 | return super unless owner.class.reflections[through.to_s].klass.second_level_cache_enabled? 15 | begin 16 | reflection.klass.find(owner.send(through).read_attribute(reflection.foreign_key)) 17 | rescue StandardError 18 | nil 19 | end 20 | else 21 | uniq_keys = { reflection.foreign_key => owner[reflection.active_record_primary_key] } 22 | uniq_keys[reflection.type] = owner.class.base_class.name if reflection.options[:as] 23 | klass.fetch_by_uniq_keys(uniq_keys) 24 | end 25 | 26 | return nil unless record 27 | record.tap { |r| set_inverse_instance(r) } 28 | end 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/setup" 4 | require "minitest/autorun" 5 | require "active_support/all" 6 | require "active_record_test_case_helper" 7 | require "database_cleaner" 8 | require "active_record" 9 | require "pry" 10 | ActiveSupport.test_order = :sorted if ActiveSupport.respond_to?(:test_order=) 11 | # Force hook :active_record on_load event to make sure loader can work. 12 | ActiveSupport.run_load_hooks(:active_record, ActiveRecord::Base) 13 | 14 | require "second_level_cache" 15 | 16 | ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") 17 | 18 | require "model/application_record" 19 | require "model/user" 20 | require "model/book" 21 | require "model/image" 22 | require "model/topic" 23 | require "model/post" 24 | require "model/order" 25 | require "model/order_item" 26 | require "model/account" 27 | require "model/animal" 28 | require "model/contribution" 29 | require "model/paranoid" if defined?(Paranoid) 30 | 31 | DatabaseCleaner[:active_record].strategy = :truncation 32 | 33 | SecondLevelCache.configure do |config| 34 | config.cache_store = ActiveSupport::Cache::MemoryStore.new 35 | end 36 | 37 | module ActiveSupport 38 | class TestCase 39 | setup do 40 | SecondLevelCache.cache_store.clear 41 | DatabaseCleaner.start 42 | end 43 | 44 | teardown do 45 | DatabaseCleaner.clean 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /second_level_cache.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require File.expand_path("../lib/second_level_cache/version", __FILE__) 4 | lib = File.expand_path("../lib", __FILE__) 5 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 6 | 7 | Gem::Specification.new do |gem| 8 | gem.authors = ["Hooopo"] 9 | gem.email = ["hoooopo@gmail.com"] 10 | gem.description = "Write Through and Read Through caching library inspired by CacheMoney and cache_fu, support ActiveRecord 4." 11 | gem.summary = <<-SUMMARY 12 | SecondLevelCache is a write-through and read-through caching library inspired by Cache Money and cache_fu, support only Rails3 and ActiveRecord. 13 | 14 | Read-Through: Queries by ID, like current_user.articles.find(params[:id]), will first look in cache store and then look in the database for the results of that query. If there is a cache miss, it will populate the cache. 15 | 16 | Write-Through: As objects are created, updated, and deleted, all of the caches are automatically kept up-to-date and coherent. 17 | SUMMARY 18 | 19 | gem.homepage = "https://github.com/hooopo/second_level_cache" 20 | 21 | gem.files = Dir.glob("lib/**/*.rb") + [ 22 | "README.md", 23 | "LICENSE", 24 | "Rakefile", 25 | "Gemfile", 26 | "CHANGELOG.md", 27 | "second_level_cache.gemspec" 28 | ] 29 | gem.test_files = Dir.glob("test/**/*.rb") 30 | gem.executables = gem.files.grep(%r{^bin/}) 31 | gem.name = "second_level_cache" 32 | gem.require_paths = ["lib"] 33 | gem.version = SecondLevelCache::VERSION 34 | 35 | gem.add_runtime_dependency "activerecord", ">= 6.0" 36 | gem.add_runtime_dependency "activesupport", ">= 6.0" 37 | 38 | gem.add_development_dependency "database_cleaner" 39 | gem.add_development_dependency "rake" 40 | gem.add_development_dependency "rubocop" 41 | gem.add_development_dependency "sqlite3", "> 1.4" 42 | end 43 | -------------------------------------------------------------------------------- /test/model/user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Base.connection.create_table(:users, force: true) do |t| 4 | t.text :options 5 | t.text :json_options 6 | t.text :extras 7 | t.string :name, unique: true 8 | t.string :email 9 | t.integer :status, default: 0 10 | t.integer :books_count, default: 0 11 | t.integer :images_count, default: 0 12 | t.timestamps null: false, precision: 6 13 | end 14 | 15 | ActiveRecord::Base.connection.create_table(:forked_user_links, force: true) do |t| 16 | t.integer :forked_to_user_id 17 | t.integer :forked_from_user_id 18 | t.timestamps null: false 19 | end 20 | 21 | ActiveRecord::Base.connection.create_table(:namespaces, force: true) do |t| 22 | t.integer :user_id 23 | t.string :kind 24 | t.string :name 25 | t.timestamps null: false 26 | end 27 | 28 | class User < ApplicationRecord 29 | CACHE_VERSION = 3 30 | second_level_cache(version: CACHE_VERSION, expires_in: 3.days) 31 | 32 | serialize :options, Array 33 | serialize :json_options, JSON if ::ActiveRecord::VERSION::STRING >= "4.1.0" 34 | store :extras, accessors: %i[tagline gender] 35 | 36 | has_one :account, inverse_of: :user 37 | has_one :forked_user_link, foreign_key: "forked_to_user_id" 38 | has_one :forked_from_user, through: :forked_user_link 39 | has_many :namespaces 40 | has_one :namespace, -> { where(kind: nil) } 41 | has_many :books 42 | has_many :images, as: :imagable 43 | 44 | enum status: %i[active archived] 45 | end 46 | 47 | class Namespace < ApplicationRecord 48 | second_level_cache version: 1, expires_in: 3.days 49 | 50 | belongs_to :user 51 | end 52 | 53 | class ForkedUserLink < ApplicationRecord 54 | second_level_cache version: 1, expires_in: 1.day 55 | 56 | belongs_to :forked_from_user, class_name: "User" 57 | belongs_to :forked_to_user, class_name: "User" 58 | end 59 | -------------------------------------------------------------------------------- /test/persistence_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class PersistenceTest < ActiveSupport::TestCase 6 | def setup 7 | @user = User.create name: "csdn", email: "test@csdn.com" 8 | @topic = Topic.create title: "csdn" 9 | end 10 | 11 | def test_should_reload_object 12 | User.where(id: @user.id).update_all(email: "different@csdn.com") 13 | assert_equal "different@csdn.com", @user.reload.email 14 | end 15 | 16 | def test_should_reload_object_associations 17 | User.increment_counter :books_count, @user.id 18 | assert_equal 0, @user.books_count 19 | assert_equal 1, @user.reload.books_count 20 | end 21 | 22 | def test_should_update_cache_after_touch 23 | old_updated_time = @user.updated_at 24 | @user.touch 25 | assert old_updated_time != @user.updated_at 26 | new_user = User.find @user.id 27 | assert_equal new_user, @user 28 | end 29 | 30 | def test_should_update_cache_after_update_column 31 | @user.update_column :name, "new_name" 32 | new_user = User.find @user.id 33 | assert_equal new_user, @user 34 | end 35 | 36 | def test_should_update_cache_after_update_columns 37 | @user.update_columns name: "new_name1" 38 | new_user = User.find @user.id 39 | assert_equal new_user, @user 40 | end 41 | 42 | def test_should_update_cache_after_update_attribute 43 | @user.update_attribute :name, "new_name" 44 | new_user = User.find @user.id 45 | assert_equal new_user, @user 46 | end 47 | 48 | def test_should_update_cache_after_update 49 | @user.update name: "new_name" 50 | new_user = User.find @user.id 51 | assert_equal new_user, @user 52 | end 53 | 54 | def test_should_update_cache_after_update! 55 | @user.update! name: "new_name" 56 | new_user = User.find @user.id 57 | assert_equal new_user, @user 58 | end 59 | 60 | def test_should_return_true_if_touch_ok 61 | assert @topic.touch == true 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/preloader_belongs_to_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class PreloaderBelongsToTest < ActiveSupport::TestCase 6 | def test_preload_caches_includes 7 | topics = [ 8 | Topic.create(title: "title1", body: "body1"), 9 | Topic.create(title: "title2", body: "body2"), 10 | Topic.create(title: "title3", body: "body3") 11 | ] 12 | topics.each { |topic| topic.posts.create(body: "post#{topic.id}") } 13 | 14 | results = nil 15 | assert_queries(1) do 16 | results = Post.includes(:topic).order("id ASC").to_a 17 | end 18 | assert_equal topics, results.map(&:topic) 19 | end 20 | 21 | def test_when_read_multi_missed_from_cache_ar_will_fetch_missed_records_from_db 22 | topics = [ 23 | Topic.create(title: "title1", body: "body1"), 24 | Topic.create(title: "title2", body: "body2"), 25 | Topic.create(title: "title3", body: "body3") 26 | ] 27 | topics.each { |topic| topic.posts.create(body: "post#{topic.id}") } 28 | expired_topic = topics.first 29 | expired_topic.expire_second_level_cache 30 | 31 | results = nil 32 | assert_queries(2) do 33 | assert_sql(/WHERE\s\"topics\"\.\"id\"\s=\s?/m) do 34 | results = Post.includes(:topic).order("id ASC").to_a 35 | assert_equal expired_topic, results.first.topic 36 | end 37 | end 38 | 39 | assert_equal topics, results.map(&:topic) 40 | end 41 | 42 | def test_preloader_caches_includes_tried_set_inverse_instance 43 | user_id = Time.current.to_i 44 | Account.create(site: "foobar", user_id: user_id) 45 | User.create(id: user_id, name: "foobar", email: "foobar@test.com") 46 | accounts = Account.includes(:user) 47 | assert_equal accounts.first.object_id, accounts.first.user.account.object_id 48 | end 49 | 50 | def test_preloader_from_db_when_exists_scope 51 | user = User.create 52 | book = user.books.create 53 | image = book.images.create 54 | book.toggle!(:normal) 55 | assert_queries(:any) do 56 | assert_nil Image.includes(:imagable).where(id: image.id).first.imagable 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/second_level_cache/active_record/fetch_by_uniq_key.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SecondLevelCache 4 | module ActiveRecord 5 | module FetchByUniqKey 6 | def fetch_by_uniq_keys(where_values) 7 | cache_key = cache_uniq_key(where_values) 8 | obj_id = SecondLevelCache.cache_store.read(cache_key) 9 | 10 | if obj_id 11 | record = begin 12 | find(obj_id) 13 | rescue 14 | nil 15 | end 16 | end 17 | return record if record_attributes_equal_where_values?(record, where_values) 18 | record = where(where_values).first 19 | if record 20 | SecondLevelCache.cache_store.write(cache_key, record.id) 21 | record 22 | else 23 | SecondLevelCache.cache_store.delete(cache_key) 24 | nil 25 | end 26 | end 27 | 28 | def fetch_by_uniq_keys!(where_values) 29 | fetch_by_uniq_keys(where_values) || raise(::ActiveRecord::RecordNotFound) 30 | end 31 | 32 | def fetch_by_uniq_key(value, uniq_key_name) 33 | # puts "[Deprecated] will remove in the future, 34 | # use fetch_by_uniq_keys method instead." 35 | fetch_by_uniq_keys(uniq_key_name => value) 36 | end 37 | 38 | def fetch_by_uniq_key!(value, uniq_key_name) 39 | # puts "[Deprecated] will remove in the future, 40 | # use fetch_by_uniq_keys! method instead." 41 | fetch_by_uniq_key(value, uniq_key_name) || raise(::ActiveRecord::RecordNotFound) 42 | end 43 | 44 | private 45 | 46 | def cache_uniq_key(where_values) 47 | keys = where_values.collect do |k, v| 48 | v = Digest::MD5.hexdigest(v) if v.respond_to?(:size) && v.size >= 32 49 | [k, v].join("_") 50 | end 51 | 52 | ext_key = keys.join(",") 53 | "#{SecondLevelCache.configure.cache_key_prefix}/uniq_key_#{name}_#{ext_key}" 54 | end 55 | 56 | def record_attributes_equal_where_values?(record, where_values) 57 | # https://api.rubyonrails.org/classes/ActiveRecord/ModelSchema/ClassMethods.html#method-i-type_for_attribute 58 | where_values.all? { |k, v| record&.read_attribute(k) == type_for_attribute(k).cast(v) } 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /test/has_one_association_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class HasOneAssociationTest < ActiveSupport::TestCase 6 | def setup 7 | @user = User.create name: "hooopo", email: "hoooopo@gmail.com" 8 | @account = @user.create_account 9 | end 10 | 11 | def test_should_fetch_account_from_cache 12 | clean_user = @user.reload 13 | assert_no_queries do 14 | clean_user.account 15 | end 16 | end 17 | 18 | def test_should_fetch_has_one_through 19 | user = User.create name: "hooopo", email: "hoooopo@gmail.com", forked_from_user: @user 20 | clean_user = user.reload 21 | assert_equal User, clean_user.forked_from_user.class 22 | assert_equal @user.id, user.forked_from_user.id 23 | # If ForkedUserLink second_level_cache_enabled is true 24 | user.reload 25 | assert_no_queries do 26 | user.forked_from_user 27 | end 28 | # IF ForkedUserLink second_level_cache_enabled is false 29 | user.reload 30 | ForkedUserLink.without_second_level_cache do 31 | assert_queries(1) { user.forked_from_user } 32 | end 33 | # NoMethodError: undefined method `klass' for nil:NilClass active_record/has_one_association.rb:14:in `find_target' 34 | hotspot = Hotspot.create(summary: "summary") 35 | assert_equal hotspot.persisted?, true 36 | assert_nil hotspot.topic 37 | end 38 | 39 | def test_has_one_with_conditions 40 | user = User.create name: "hooopo", email: "hoooopo@gmail.com" 41 | Namespace.create(user_id: user.id, name: "ruby-china", kind: "group") 42 | user.create_namespace(name: "hooopo") 43 | Namespace.create(user_id: user.id, name: "rails", kind: "group") 44 | assert_not_equal user.namespace, nil 45 | clear_user = User.find(user.id) 46 | assert_equal clear_user.namespace.name, "hooopo" 47 | end 48 | 49 | def test_assign_relation 50 | assert_equal @user.account, @account 51 | new_account = Account.create 52 | @user.account = new_account 53 | assert_equal @user.account, new_account 54 | assert_equal @user.reload.account, new_account 55 | end 56 | 57 | def test_belongs_to_column_change 58 | assert_equal @user.account, @account 59 | @account.update(user_id: @user.id + 1) 60 | assert_nil @user.reload.account 61 | end 62 | 63 | def test_should_one_query_when_has_one_target_is_null 64 | Namespace.destroy_all 65 | @user.reload 66 | assert_queries(1) { @user.namespace } 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /test/preloader_non_integer_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class PreloaderNonIntegerTest < ActiveSupport::TestCase 6 | def test_belongs_to_preload_caches_includes_uuid 7 | orders = [ 8 | Order.create(id: "15944214-e4df-4e46-8d56-1f5864a0b90c", title: "title1", body: "body1"), 9 | Order.create(id: "25944214-e4df-4e46-8d56-1f5864a0b90c", title: "title2", body: "body2"), 10 | Order.create(id: "35944214-e4df-4e46-8d56-1f5864a0b90c", title: "title3", body: "body3") 11 | ] 12 | orders.each_with_index { |order, i| order.order_items.create(body: "order_item#{order.id}", id: "1#{i}944214-e4df-4e46-8d56-1f5864a0b90c") } 13 | 14 | results = nil 15 | assert_queries(1) do 16 | results = OrderItem.includes(:order).order("id ASC").to_a 17 | end 18 | assert_equal orders, results.map(&:order) 19 | end 20 | 21 | def test_belongs_to_when_read_multi_missed_from_cache_ar_will_fetch_missed_records_from_db_uuid 22 | orders = [ 23 | Order.create(id: "15944214-e4df-4e46-8d56-1f5864a0b90c", title: "title1", body: "body1"), 24 | Order.create(id: "25944214-e4df-4e46-8d56-1f5864a0b90c", title: "title2", body: "body2"), 25 | Order.create(id: "35944214-e4df-4e46-8d56-1f5864a0b90c", title: "title3", body: "body3") 26 | ] 27 | orders.each_with_index { |order, i| order.order_items.create(body: "order_item#{order.id}", id: "1#{i}944214-e4df-4e46-8d56-1f5864a0b90c") } 28 | expired_order = orders.first 29 | expired_order.expire_second_level_cache 30 | 31 | results = nil 32 | assert_queries(2) do 33 | assert_sql(/WHERE\s\"orders\"\.\"id\" = ?/m) do 34 | results = OrderItem.includes(:order).order("id ASC").to_a 35 | assert_equal expired_order, results.first.order 36 | end 37 | end 38 | 39 | assert_equal orders, results.map(&:order) 40 | end 41 | 42 | def test_has_many_preloader_returns_correct_results 43 | order = Order.create(id: "15944214-e4df-4e46-8d56-1f5864a0b90c") 44 | OrderItem.create(id: "11944214-e4df-4e46-8d56-1f5864a0b90c") 45 | order_item = order.order_items.create(id: "12944214-e4df-4e46-8d56-1f5864a0b90c") 46 | 47 | assert_equal [order_item], Order.includes(:order_items).find("15944214-e4df-4e46-8d56-1f5864a0b90c").order_items 48 | end 49 | 50 | def test_has_one_preloader_returns_correct_results 51 | user = User.create(id: 1) 52 | Account.create(id: 1) 53 | account = user.create_account 54 | 55 | assert_equal account, User.includes(:account).find(1).account 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/second_level_cache/active_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "second_level_cache/mixin" 4 | require "second_level_cache/active_record/base" 5 | require "second_level_cache/active_record/core" 6 | require "second_level_cache/active_record/fetch_by_uniq_key" 7 | require "second_level_cache/active_record/finder_methods" 8 | require "second_level_cache/active_record/persistence" 9 | require "second_level_cache/active_record/belongs_to_association" 10 | require "second_level_cache/active_record/has_one_association" 11 | require "second_level_cache/active_record/preloader/association" 12 | require "second_level_cache/active_record/preloader/legacy" 13 | 14 | # http://api.rubyonrails.org/classes/ActiveSupport/LazyLoadHooks.html 15 | # ActiveSupport.run_load_hooks(:active_record, ActiveRecord::Base) 16 | ActiveSupport.on_load(:active_record, run_once: true) do 17 | if Bundler.definition.dependencies.find { |x| x.name == "paranoia" } 18 | require "second_level_cache/adapter/paranoia" 19 | include SecondLevelCache::Adapter::Paranoia::ActiveRecord 20 | SecondLevelCache::Mixin.send(:prepend, SecondLevelCache::Adapter::Paranoia::Mixin) 21 | end 22 | 23 | include SecondLevelCache::Mixin 24 | prepend SecondLevelCache::ActiveRecord::Base 25 | extend SecondLevelCache::ActiveRecord::FetchByUniqKey 26 | prepend SecondLevelCache::ActiveRecord::Persistence 27 | 28 | ActiveRecord::Associations::BelongsToAssociation.send(:prepend, SecondLevelCache::ActiveRecord::Associations::BelongsToAssociation) 29 | ActiveRecord::Associations::HasOneAssociation.send(:prepend, SecondLevelCache::ActiveRecord::Associations::HasOneAssociation) 30 | ActiveRecord::Relation.send(:prepend, SecondLevelCache::ActiveRecord::FinderMethods) 31 | 32 | # https://github.com/rails/rails/blob/6-0-stable/activerecord/lib/active_record/associations/preloader/association.rb#L117 33 | if ::ActiveRecord.version < ::Gem::Version.new("7") 34 | ActiveRecord::Associations::Preloader::Association.send(:prepend, SecondLevelCache::ActiveRecord::Associations::Preloader::Association::Legacy) 35 | end 36 | 37 | if ::ActiveRecord.version >= ::Gem::Version.new("7") 38 | # https://github.com/rails/rails/blob/7-0-stable/activerecord/lib/active_record/associations/preloader/association.rb#L25 39 | ActiveRecord::Associations::Preloader::Association.send(:prepend, SecondLevelCache::ActiveRecord::Associations::Preloader::Association) 40 | ActiveRecord::Associations::Preloader::Association::LoaderQuery.send(:prepend, SecondLevelCache::ActiveRecord::Associations::Preloader::Association::LoaderQuery) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/second_level_cache/active_record/preloader/legacy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SecondLevelCache 4 | module ActiveRecord 5 | module Associations 6 | module Preloader 7 | module Association 8 | # For < Rails 7 9 | module Legacy 10 | RAILS6 = ::ActiveRecord.version >= ::Gem::Version.new("6") 11 | 12 | def records_for(ids, &block) 13 | return super unless klass.second_level_cache_enabled? 14 | return super unless reflection.is_a?(::ActiveRecord::Reflection::BelongsToReflection) 15 | return super if klass.default_scopes.present? || reflection.scope 16 | return super if association_key_name.to_s != klass.primary_key 17 | 18 | map_cache_keys = ids.map { |id| klass.second_level_cache_key(id) } 19 | records_from_cache = ::SecondLevelCache.cache_store.read_multi(*map_cache_keys) 20 | 21 | record_marshals = if RAILS6 22 | RecordMarshal.load_multi(records_from_cache.values) do |record| 23 | # This block is copy from: 24 | # https://github.com/rails/rails/blob/6-0-stable/activerecord/lib/active_record/associations/preloader/association.rb#L101 25 | owner = owners_by_key[convert_key(record[association_key_name])].first 26 | association = owner.association(reflection.name) 27 | association.set_inverse_instance(record) 28 | end 29 | else 30 | RecordMarshal.load_multi(records_from_cache.values, &block) 31 | end 32 | 33 | # NOTICE 34 | # Rails.cache.read_multi return hash that has keys only hitted. 35 | # eg. Rails.cache.read_multi(1,2,3) => {2 => hit_value, 3 => hit_value} 36 | hitted_ids = record_marshals.map { |record| record.read_attribute(association_key_name).to_s } 37 | missed_ids = ids.map(&:to_s) - hitted_ids 38 | ActiveSupport::Notifications.instrument("preload.second_level_cache", key: association_key_name, hit: hitted_ids, miss: missed_ids) 39 | return SecondLevelCache::RecordRelation.new(record_marshals) if missed_ids.empty? 40 | 41 | records_from_db = super(missed_ids, &block) 42 | records_from_db.map { |r| write_cache(r) } 43 | 44 | SecondLevelCache::RecordRelation.new(records_from_db + record_marshals) 45 | end 46 | 47 | private 48 | 49 | def write_cache(record) 50 | record.write_second_level_cache 51 | end 52 | end 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /test/record_marshal_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class RecordMarshalTest < ActiveSupport::TestCase 6 | def setup 7 | if ::ActiveRecord::VERSION::STRING >= "4.1.0" 8 | @json_options = { "name" => "Test", "age" => 18, "hash" => { "name" => "dup" } } 9 | @extras = { "tagline" => "Hello world", "gender" => 1 } 10 | @user = User.create name: "csdn", 11 | email: "test@csdn.com", 12 | options: [1, 2], 13 | extras: @extras, 14 | json_options: @json_options, 15 | status: :active 16 | else 17 | @user = User.create name: "csdn", 18 | email: "test@csdn.com", 19 | extras: @extras, 20 | options: [1, 2] 21 | end 22 | end 23 | 24 | def test_should_dump_active_record_object 25 | dumped = RecordMarshal.dump(@user) 26 | assert dumped.is_a?(Array) 27 | assert_equal "User", dumped[0] 28 | assert_equal dumped[1], ActiveRecord::Base.connection.select_all(User.where(id: @user.id)).first 29 | end 30 | 31 | def test_should_load_active_record_object 32 | @user.write_second_level_cache 33 | assert_equal @user, User.read_second_level_cache(@user.id) 34 | assert_equal Array, User.read_second_level_cache(@user.id).options.class 35 | assert_equal Array, User.read_second_level_cache(@user.id).reload.options.class 36 | assert_equal User.read_second_level_cache(@user.id).changed?, false 37 | assert_equal [1, 2], User.read_second_level_cache(@user.id).options 38 | assert_equal @extras, User.read_second_level_cache(@user.id).extras 39 | assert_equal "Hello world", User.read_second_level_cache(@user.id).tagline 40 | assert_equal 1, User.read_second_level_cache(@user.id).gender 41 | if ::ActiveRecord::VERSION::STRING >= "4.1.0" 42 | result = User.read_second_level_cache(@user.id) 43 | assert_equal @json_options["name"], result.json_options["name"] 44 | assert_equal @json_options, result.json_options 45 | end 46 | assert User.read_second_level_cache(@user.id).persisted? 47 | end 48 | 49 | def test_should_load_nil 50 | @user.expire_second_level_cache 51 | assert_nil User.read_second_level_cache(@user.id) 52 | end 53 | 54 | def test_should_load_active_record_object_without_association_cache 55 | @user.books 56 | @user.write_second_level_cache 57 | assert_equal false, User.read_second_level_cache(@user.id).association_cached?("id") 58 | end 59 | 60 | def test_should_thread_safe_load 61 | user = User.find @user.id 62 | assert_equal "active", user.status 63 | user = User.find @user.id 64 | assert_equal "active", user.status 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/second_level_cache/active_record/preloader/association.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SecondLevelCache 4 | module ActiveRecord 5 | module Associations 6 | module Preloader 7 | module Association 8 | # Override load_query method for add Association instance in arguments to LoaderQuery 9 | # https://github.com/rails/rails/blob/7-0-stable/activerecord/lib/active_record/associations/preloader/association.rb#L148 10 | def loader_query 11 | ::ActiveRecord::Associations::Preloader::Association::LoaderQuery.new(self, scope, association_key_name) 12 | end 13 | 14 | # Override load_records_for_keys for use SecondLevelCache before preload association 15 | # https://github.com/rails/rails/blob/8f5b35b6107c28125b571b9842e248b13f804e5c/activerecord/lib/active_record/associations/preloader/association.rb#L7 16 | module LoaderQuery 17 | attr_reader :association 18 | 19 | delegate :klass, to: :association 20 | 21 | def initialize(association, scope, association_key_name) 22 | @association = association 23 | @scope = scope 24 | @association_key_name = association_key_name 25 | end 26 | 27 | def reflection 28 | association.send(:reflection) 29 | end 30 | 31 | def load_records_for_keys(keys, &block) 32 | ids = keys.to_a 33 | 34 | return super unless klass.second_level_cache_enabled? 35 | return super unless reflection.is_a?(::ActiveRecord::Reflection::BelongsToReflection) 36 | return super if klass.default_scopes.present? || reflection.scope 37 | return super if association_key_name.to_s != klass.primary_key 38 | 39 | map_cache_keys = ids.map { |id| klass.second_level_cache_key(id) } 40 | records_from_cache = ::SecondLevelCache.cache_store.read_multi(*map_cache_keys) 41 | record_marshals = RecordMarshal.load_multi(records_from_cache.values, &block) 42 | 43 | # NOTICE 44 | # Rails.cache.read_multi return hash that has keys only hitted. 45 | # eg. Rails.cache.read_multi(1,2,3) => {2 => hit_value, 3 => hit_value} 46 | hitted_ids = record_marshals.map { |record| record.read_attribute(association_key_name).to_s } 47 | missed_ids = ids.map(&:to_s) - hitted_ids 48 | ActiveSupport::Notifications.instrument("preload.second_level_cache", key: association_key_name, hit: hitted_ids, miss: missed_ids) 49 | return SecondLevelCache::RecordRelation.new(record_marshals) if missed_ids.empty? 50 | 51 | records_from_db = super(missed_ids.to_set, &block) 52 | records_from_db.map { |r| r.write_second_level_cache } 53 | 54 | SecondLevelCache::RecordRelation.new(records_from_db + record_marshals) 55 | end 56 | end 57 | end 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/second_level_cache/mixin.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SecondLevelCache 4 | module Mixin 5 | extend ActiveSupport::Concern 6 | 7 | module ClassMethods 8 | attr_reader :second_level_cache_options 9 | 10 | delegate :logger, :cache_store, :cache_key_prefix, to: SecondLevelCache 11 | 12 | def second_level_cache(options = {}) 13 | @second_level_cache_enabled = true 14 | @second_level_cache_options = options 15 | @second_level_cache_options[:version] ||= 0 16 | @second_level_cache_options[:expires_in] ||= 1.week 17 | relation.class.send :prepend, SecondLevelCache::ActiveRecord::FinderMethods 18 | prepend SecondLevelCache::ActiveRecord::Core 19 | end 20 | 21 | def second_level_cache_enabled? 22 | if defined? @second_level_cache_enabled 23 | @second_level_cache_enabled == true && SecondLevelCache.cache_enabled? 24 | else 25 | false 26 | end 27 | end 28 | 29 | def without_second_level_cache(&blk) 30 | SecondLevelCache.without_second_level_cache(&blk) if blk 31 | end 32 | 33 | # Get MD5 digest of this Model schema 34 | # http://api.rubyonrails.org/classes/ActiveRecord/Core/ClassMethods.html#method-i-inspect 35 | def cache_version 36 | return @cache_version if defined? @cache_version 37 | # This line is copy from: 38 | # https://github.com/rails/rails/blob/f9a5f48/activerecord/lib/active_record/core.rb#L236 39 | attr_list = attribute_types.map { |name, type| "#{name}: #{type.type}" } * ", " 40 | model_schema_digest = Digest::MD5.hexdigest(attr_list) 41 | @cache_version = "#{second_level_cache_options[:version]}/#{model_schema_digest}" 42 | end 43 | 44 | def second_level_cache_key(id) 45 | "#{cache_key_prefix}/#{table_name.downcase}/#{id}/#{cache_version}" 46 | end 47 | 48 | def read_second_level_cache(id) 49 | return unless second_level_cache_enabled? 50 | RecordMarshal.load(SecondLevelCache.cache_store.read(second_level_cache_key(id))) 51 | end 52 | 53 | def expire_second_level_cache(id) 54 | return unless second_level_cache_enabled? 55 | SecondLevelCache.cache_store.delete(second_level_cache_key(id)) 56 | end 57 | end 58 | 59 | def second_level_cache_key 60 | klass.second_level_cache_key(id) 61 | end 62 | 63 | def klass 64 | self.class.base_class 65 | end 66 | 67 | def expire_second_level_cache 68 | return unless klass.second_level_cache_enabled? 69 | SecondLevelCache.cache_store.delete(second_level_cache_key) 70 | end 71 | 72 | def write_second_level_cache 73 | return unless klass.second_level_cache_enabled? 74 | 75 | marshal = RecordMarshal.dump(self) 76 | expires_in = klass.second_level_cache_options[:expires_in] 77 | SecondLevelCache.cache_store.write(second_level_cache_key, marshal, expires_in: expires_in) 78 | end 79 | 80 | alias_method :update_second_level_cache, :write_second_level_cache 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /test/preloader_has_one_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class PreloaderHasOneTest < ActiveSupport::TestCase 6 | # def test_preload_caches_includes 7 | # users = User.create([ 8 | # { name: "foobar1", email: "foobar1@test.com" }, 9 | # { name: "foobar2", email: "foobar2@test.com" }, 10 | # { name: "foobar3", email: "foobar3@test.com" } 11 | # ]) 12 | # namespaces = users.map { |user| user.create_namespace(name: user.name) } 13 | 14 | # assert_queries(2) { User.includes(:namespace).order(id: :asc).to_a } # Write cache 15 | # assert_queries(1) do 16 | # assert_equal namespaces, User.includes(:namespace).order(id: :asc).map(&:namespace) 17 | # end 18 | # end 19 | 20 | # def test_when_read_multi_missed_from_cache_should_will_fetch_missed_records_from_db 21 | # users = User.create([ 22 | # { name: "foobar1", email: "foobar1@test.com" }, 23 | # { name: "foobar2", email: "foobar2@test.com" }, 24 | # { name: "foobar3", email: "foobar3@test.com" } 25 | # ]) 26 | # namespaces = users.map { |user| user.create_namespace(name: user.name) } 27 | # assert_queries(2) { User.includes(:namespace).order(id: :asc).to_a } # Write cache 28 | # expired_namespace = namespaces.first 29 | # expired_namespace.expire_second_level_cache 30 | 31 | # assert_queries(2) do 32 | # assert_sql(/WHERE\s\"namespaces\".\"kind\"\sIS\sNULL\sAND\s\"namespaces\"\.\"user_id\"\s=\s?/m) do 33 | # results = User.includes(:namespace).order(id: :asc).to_a 34 | # assert_equal namespaces, results.map(&:namespace) 35 | # assert_equal expired_namespace, results.first.namespace 36 | # end 37 | # end 38 | # end 39 | 40 | # def test_preloader_returns_correct_records_after_modify 41 | # user = User.create(name: "foobar", email: "foobar@test.com") 42 | 43 | # old_namespace = user.create_namespace(name: "old") 44 | # assert_queries(2) { User.includes(:namespace).order(id: :asc).to_a } # Write cache 45 | # assert_queries(1) do 46 | # assert_equal old_namespace, User.includes(:namespace).first.namespace 47 | # end 48 | 49 | # new_namespace = user.create_namespace(name: "new") 50 | # assert_queries(2) { User.includes(:namespace).order(id: :asc).to_a } # Write cache 51 | # assert_queries(1) do 52 | # assert_equal new_namespace, User.includes(:namespace).first.namespace 53 | # end 54 | # end 55 | 56 | # def test_preloader_caches_includes_tried_set_inverse_instance 57 | # User.create(name: "foobar", email: "foobar@test.com").create_account(site: "foobar") 58 | # users = User.includes(:account) 59 | # assert_equal users.first.object_id, users.first.account.user.object_id 60 | # end 61 | 62 | def test_has_one_preloader_returns_correct_results 63 | user = User.create(id: 1) 64 | Account.create(id: 1) 65 | account = user.create_account 66 | 67 | assert_equal account, User.includes(:account).find(1).account 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /test/finder_methods_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class FinderMethodsTest < ActiveSupport::TestCase 6 | def setup 7 | @user = User.create name: "csdn", email: "test@csdn.com" 8 | @book = @user.books.create 9 | end 10 | 11 | def test_should_find_without_cache 12 | SecondLevelCache.cache_store.clear 13 | assert_equal @user, User.find(@user.id) 14 | end 15 | 16 | def test_should_find_with_string_id 17 | SecondLevelCache.cache_store.clear 18 | assert_equal @user, User.find(@user.id.to_s) 19 | assert_no_queries do 20 | assert_equal @user, User.find(@user.id.to_s) 21 | end 22 | end 23 | 24 | def test_should_find_with_has_many 25 | @book.write_second_level_cache 26 | assert_no_queries do 27 | assert_equal @book, @user.books.find(@book.id) 28 | end 29 | end 30 | 31 | def test_should_find_with_cache 32 | @user.write_second_level_cache 33 | assert_no_queries do 34 | assert_equal @user, User.find(@user.id) 35 | end 36 | end 37 | 38 | def test_should_find_with_condition 39 | @user.write_second_level_cache 40 | assert_no_queries do 41 | assert_equal @user, User.where(name: @user.name).find(@user.id) 42 | end 43 | end 44 | 45 | def test_should_not_find_from_cache_when_select_speical_columns 46 | @user.write_second_level_cache 47 | only_id_user = User.select("id").find(@user.id) 48 | assert_raises(ActiveModel::MissingAttributeError) do 49 | only_id_user.name 50 | end 51 | end 52 | 53 | def test_without_second_level_cache 54 | @user.name = "NewName" 55 | @user.write_second_level_cache 56 | User.without_second_level_cache do 57 | @from_db = User.find(@user.id) 58 | end 59 | refute_equal @user.name, @from_db.name 60 | end 61 | 62 | def test_should_fetch_from_db_if_where_use_string 63 | @user.write_second_level_cache 64 | assert_queries(:any) do 65 | assert_nil User.unscoped.where(id: @user.id).where("name = 'nonexistent'").first 66 | end 67 | assert_queries(:any) do 68 | assert_raises ActiveRecord::RecordNotFound do 69 | User.where("name = 'nonexistent'").find(@user.id) 70 | end 71 | end 72 | end 73 | 74 | def test_should_fetch_from_db_when_use_eager_load 75 | @user.write_second_level_cache 76 | assert_queries(:any) do 77 | assert_sql(/LEFT\sOUTER\sJOIN\s\"books\"/m) do 78 | User.eager_load(:books).find(@user.id) 79 | end 80 | end 81 | end 82 | 83 | def test_should_fetch_from_db_when_use_includes 84 | @user.write_second_level_cache 85 | assert_queries(:any) do 86 | assert_sql(/SELECT\s\"books\"\.\*\sFROM\s\"books\"/m) do 87 | User.includes(:books).find(@user.id) 88 | end 89 | end 90 | end 91 | 92 | def test_should_fetch_from_db_when_use_preload 93 | @user.write_second_level_cache 94 | assert_queries(:any) do 95 | assert_sql(/SELECT\s\"books\"\.\*\sFROM\s\"books\"/m) do 96 | User.preload(:books).find(@user.id) 97 | end 98 | end 99 | end 100 | 101 | def test_where_and_first_should_with_cache 102 | @user.write_second_level_cache 103 | assert_no_queries do 104 | assert_equal @user, User.unscoped.where(id: @user.id).first 105 | end 106 | end 107 | 108 | def test_where_and_last_should_with_cache 109 | @user.write_second_level_cache 110 | assert_no_queries do 111 | assert_equal @user, User.unscoped.where(id: @user.id).last 112 | end 113 | end 114 | 115 | def test_should_not_write_cache_for_first 116 | @user = User.select("id").first 117 | @user = User.find(@user.id) 118 | assert_equal "csdn", @user.name 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /lib/second_level_cache/active_record/finder_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SecondLevelCache 4 | module ActiveRecord 5 | module FinderMethods 6 | # TODO: find_some 7 | # http://api.rubyonrails.org/classes/ActiveRecord/FinderMethods.html#method-i-find_one 8 | # 9 | # Cacheable: 10 | # 11 | # current_user.articles.where(status: 1).visiable.find(params[:id]) 12 | # 13 | # Uncacheable: 14 | # 15 | # Article.where("user_id = '1'").find(params[:id]) 16 | # Article.where("user_id > 1").find(params[:id]) 17 | # Article.where("articles.user_id = 1").find(prams[:id]) 18 | # Article.where("user_id = 1 AND ...").find(params[:id]) 19 | def find_one(id) 20 | return super unless cachable? 21 | 22 | id = id.id if ActiveRecord::Base == id 23 | record = @klass.read_second_level_cache(id) 24 | return record if record && where_values_match_cache?(record) 25 | 26 | record = super 27 | record.write_second_level_cache 28 | record 29 | end 30 | 31 | # http://api.rubyonrails.org/classes/ActiveRecord/FinderMethods.html#method-i-first 32 | # 33 | # Cacheable: 34 | # 35 | # User.where(id: 1).first 36 | # User.where(id: 1).last 37 | # 38 | # Uncacheable: 39 | # 40 | # User.where(name: 'foo').first 41 | # User.where(age: 18).first 42 | # 43 | def first(limit = nil) 44 | return super if limit.to_i > 1 45 | return super unless cachable? 46 | # only have primary_key condition in where 47 | if where_values_hash.length == 1 && where_values_hash.key?(primary_key) 48 | record = @klass.read_second_level_cache(where_values_hash[primary_key]) 49 | return record if record 50 | end 51 | 52 | record = super 53 | record&.write_second_level_cache 54 | record 55 | end 56 | 57 | private 58 | # readonly_value - active_record/relation/query_methods.rb Rails 5.1 true/false 59 | def cachable? 60 | second_level_cache_enabled? && 61 | limit_one? && 62 | # !eager_loading? && 63 | includes_values.blank? && 64 | preload_values.blank? && 65 | eager_load_values.blank? && 66 | select_values.blank? && 67 | order_values_can_cache? && 68 | readonly_value.blank? && 69 | joins_values.blank? && 70 | !@klass.locking_enabled? && 71 | where_clause_predicates_all_equality? 72 | end 73 | 74 | def order_values_can_cache? 75 | return true if order_values.empty? 76 | return false unless order_values.one? 77 | return true if order_values.first == klass.primary_key 78 | return false unless order_values.first.is_a?(::Arel::Nodes::Ordering) 79 | return true if order_values.first.expr == klass.primary_key 80 | order_values.first.expr.try(:name) == klass.primary_key 81 | end 82 | 83 | def where_clause_predicates_all_equality? 84 | where_clause.send(:predicates).size == where_values_hash.size 85 | end 86 | 87 | def where_values_match_cache?(record) 88 | where_values_hash.all? do |key, value| 89 | if value.is_a?(Array) 90 | value.include?(record.read_attribute(key)) 91 | else 92 | record.read_attribute(key) == value 93 | end 94 | end 95 | end 96 | 97 | def limit_one? 98 | limit_value.blank? || limit_value == 1 99 | end 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /test/fetch_by_uniq_key_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class FetchByUinqKeyTest < ActiveSupport::TestCase 6 | def setup 7 | @user = User.create name: "hooopo", email: "hoooopo@gmail.com" 8 | @post = Post.create slug: "foobar", topic_id: 2 9 | @cache_prefix = SecondLevelCache.configure.cache_key_prefix 10 | end 11 | 12 | def test_cache_uniq_key 13 | assert_equal User.send(:cache_uniq_key, name: "hooopo"), "#{@cache_prefix}/uniq_key_User_name_hooopo" 14 | assert_equal User.send(:cache_uniq_key, foo: 1, bar: 2), "#{@cache_prefix}/uniq_key_User_foo_1,bar_2" 15 | assert_equal User.send(:cache_uniq_key, foo: 1, bar: nil), "#{@cache_prefix}/uniq_key_User_foo_1,bar_" 16 | long_val = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" 17 | assert_equal User.send(:cache_uniq_key, foo: 1, bar: long_val), "#{@cache_prefix}/uniq_key_User_foo_1,bar_#{Digest::MD5.hexdigest(long_val)}" 18 | assert Contribution.send(:cache_uniq_key, user_id: 1, date: Time.current.to_date), "#{@cache_prefix}/uniq_key_Contribution_user_id_1,date_#{Time.current.to_date}" 19 | end 20 | 21 | def test_record_attributes_equal_where_values 22 | book = Book.new 23 | assert_no_queries do 24 | book.title = "foobar" 25 | assert Book.send(:record_attributes_equal_where_values?, book, title: :foobar) 26 | book.discount_percentage = 60.00 27 | assert Book.send(:record_attributes_equal_where_values?, book, discount_percentage: "60") 28 | book.publish_date = Time.current.to_date 29 | assert Book.send(:record_attributes_equal_where_values?, book, publish_date: Time.current.to_date.to_s) 30 | book.title = nil 31 | assert Book.send(:record_attributes_equal_where_values?, book, title: nil) 32 | end 33 | end 34 | 35 | def test_should_query_from_db_using_primary_key 36 | Post.fetch_by_uniq_keys(topic_id: 2, slug: "foobar") 37 | @post.expire_second_level_cache 38 | assert_sql(/SELECT\s+"posts".* FROM "posts"\s+WHERE "posts"."id" = \? LIMIT ?/) do 39 | Post.fetch_by_uniq_keys(topic_id: 2, slug: "foobar") 40 | end 41 | end 42 | 43 | def test_should_not_hit_db_using_fetch_by_uniq_key_twice 44 | post = Post.fetch_by_uniq_keys(topic_id: 2, slug: "foobar") 45 | assert_equal post, @post 46 | assert_no_queries do 47 | Post.fetch_by_uniq_keys(topic_id: 2, slug: "foobar") 48 | end 49 | end 50 | 51 | def test_should_fail_when_fetch_by_uniq_key_with_bang_method 52 | assert_raises(ActiveRecord::RecordNotFound) do 53 | Post.fetch_by_uniq_keys!(topic_id: 2, slug: "foobar1") 54 | end 55 | 56 | assert_raises(ActiveRecord::RecordNotFound) do 57 | User.fetch_by_uniq_key!("xxxxx", :name) 58 | end 59 | end 60 | 61 | def test_should_return_nil_when_record_not_found 62 | assert_not_nil Post.fetch_by_uniq_keys(topic_id: 2, slug: "foobar") 63 | assert_nil Post.fetch_by_uniq_keys(topic_id: 3, slug: "foobar") 64 | end 65 | 66 | def test_should_work_with_fetch_by_uniq_key 67 | user = User.fetch_by_uniq_key(@user.name, :name) 68 | assert_equal user, @user 69 | end 70 | 71 | def test_should_return_correct_when_destroy_old_record_and_create_same_new_record 72 | savepoint do 73 | uniq_key = { email: "#{Time.now.to_i}@foobar.com" } 74 | old_user = User.create(uniq_key) 75 | new_user = old_user.deep_dup 76 | assert_equal old_user, User.fetch_by_uniq_keys(uniq_key) 77 | old_user.destroy 78 | 79 | # Dirty id cache should be removed 80 | assert_queries(2) { assert_nil User.fetch_by_uniq_keys(uniq_key) } 81 | assert_queries(1) { assert_nil User.fetch_by_uniq_keys(uniq_key) } 82 | 83 | new_user.save 84 | assert_equal new_user, User.fetch_by_uniq_keys(uniq_key) 85 | end 86 | end 87 | 88 | def test_should_return_correct_when_old_record_modify_uniq_key_and_new_record_use_same_uniq_key 89 | savepoint do 90 | uniq_key = { email: @user.email } 91 | assert_equal @user, User.fetch_by_uniq_keys(uniq_key) 92 | @user.update_attribute(:email, "#{Time.now.to_i}@foobar.com") 93 | new_user = User.create(uniq_key) 94 | assert_equal new_user, User.fetch_by_uniq_keys(uniq_key) 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | New version releases please visit https://github.com/hooopo/second_level_cache/releases 3 | 4 | 2.6.4 5 | ------- 6 | 7 | - Fix `undefined method klass` error for has_one through. (#123) 8 | 9 | 2.6.3 10 | ------- 11 | 12 | - Fix paranoia load error. 13 | 14 | 2.6.2 15 | ------- 16 | 17 | - Fix activerecord association cache. (#109) 18 | - Fix fetch_by_uniq_key cache key with prefix. (#120) 19 | 20 | 2.6.1 21 | ------- 22 | 23 | - Improve proload debug log output, and deprecated logger method. (#106) 24 | 25 | 2.6.0 26 | ------- 27 | 28 | - Add has_one through cache support. (#98) 29 | - Fix string query, eager_load, includes/preload for fetch from db. ( #103, #102, #101) 30 | - Fix preloader if exists default scope. (#104) 31 | - Change cache hit log as `DEBUG` level. (#105) 32 | 33 | 2.5.3 34 | ------- 35 | 36 | - Fix `fetch_by_uniq_keys` method that cache incorrect when A record modified uniq key and B reocrd used old uniq key of A record (#96) 37 | 38 | 2.5.2 39 | ------- 40 | 41 | - Fix methods argument as keyword warning in Ruby 2.7. (#94) 42 | 43 | 2.5.1 44 | ------- 45 | 46 | - Fix cache expire issue for SoftDelete cases. 47 | 48 | 2.5.0 49 | ------- 50 | 51 | - Rails 6 support. 52 | - Fix cache expire for soft delete. 53 | 54 | 2.4.3 55 | ------- 56 | 57 | - Fix caching for STI model. (#78) 58 | 59 | 2.4.2 60 | ------- 61 | 62 | - Fix for work with Paranoia gem. (#77) 63 | 64 | 2.4.1 65 | ------- 66 | 67 | - Fix relation finder. (#75) 68 | 69 | 2.4.0 70 | --------- 71 | 72 | - Fix for support Rails 5.2; 73 | - Now second_level_cache 2.4.x has required Rails > 5.2; 74 | - Enable `frozen_string_literal = true`; 75 | 76 | 2.3.1 77 | ------- 78 | 79 | - Fix some cases will raise "uninitialized constant SecondLevelCache::Mixin" error. (#66) 80 | 81 | 2.3.0 82 | ------- 83 | 84 | * Use Model schema digest as cache_version, so you don't need set `:version` option now. (#60) 85 | * Fix `store` serialize option (#62) 86 | * Remove `acts_as_cached` method now! Please use `second_level_cache`. (#59) 87 | 88 | 2.2.7 89 | ------- 90 | 91 | * Use `second_level_cache` instead of `acts_as_cached` method to setup in model. (#56) 92 | 93 | 2.2.6 94 | ------- 95 | 96 | * Fix warning in Ruby 2.4.0. (#54) 97 | 98 | 2.2.5 99 | ------- 100 | 101 | * Flush cache when belongs_to keys are changed; (#51) 102 | * Fix #52 in ActiveRecord 5.0.1, `records_for` API has changed, it's want an `ActiveRecord::Relation` instance to include a `load` method, but second_level_cached returned an Array. (#53) 103 | * Fix Rails 5.0.1 `@second_level_cache_enabled` not define warning. 104 | 105 | 2.2.4 106 | ------- 107 | 108 | * Fix update conflict in same thread or request context for Cache object. (#49) 109 | 110 | 2.2.3 111 | ------- 112 | 113 | * Fix issue with Rails enums. (#43) 114 | * Fix to update cache on `update_columns`, `update_attribute`. (#43) 115 | 116 | 2.2.2 117 | ------- 118 | 119 | * Add `where(id: n).first`, `where(id: n).last` hit cache support. This improve will avoid some gems query database, for example: [devise](https://github.com/plataformatec/devise) `current_user` method. 120 | 121 | 2.2.1 122 | ------- 123 | 124 | * ActiveRecord 5 ready! Do not support ActiveRecord 4 and lower versions now (use second_level_cache 2.1.x). 125 | * Requirement Ruby 2.3+. 126 | 127 | 2.0.0 128 | ------- 129 | 130 | * ActiveRecord 4 ready! 131 | * read multi support for preloading. `Article.includes(:user).limit(5).to_a` will fetch all articles' users from cache preferentially. 132 | * remove dependency warning 133 | * remove support for find_by_xx which will be removed in Rails 4.1 134 | 135 | 1.6.2 136 | ------- 137 | 138 | * [can disable/enable fetch_by_uinq_key method] 139 | * [Fix Bug: serialized attribute columns marshal issue #11] 140 | 141 | 1.6.1 142 | ------- 143 | 144 | * [Fix bug: undefined method `select_all_column?' for []:ActiveRecord::Relation] by sishen 145 | 146 | 1.6.0 147 | ------- 148 | 149 | * [write through cache] 150 | * [disable SecondLevelCache for spicial model] 151 | * [only cache `SELECT *` query] 152 | 153 | 1.5.1 154 | ------- 155 | 156 | * [use new marshal machanism to avoid clear assocation cache manually] 157 | 158 | 1.5.0 159 | ------- 160 | 161 | * [add cache version to quick clear cache for special model] 162 | 163 | 1.4.1 164 | ------- 165 | 166 | * [fix errors when belongs_to association return nil] 167 | 168 | 1.4.0 169 | ------- 170 | 171 | * [cache has one assciation] 172 | 173 | 1.3.2 174 | ------- 175 | 176 | * [fix has one assciation issue] 177 | 178 | 1.3.1 179 | ------- 180 | 181 | * [clean cache after update_column/increment!/decrement!] 182 | 183 | 1.3.0 184 | ------- 185 | 186 | * [clean cache after touch] 187 | 188 | 1.2.1 189 | ------- 190 | 191 | * [fix polymorphic association bug] 192 | 193 | 1.2.0 194 | ------- 195 | 196 | * [clear cache after update_counters](https://github.com/csdn-dev/second_level_cache/commit/240dde81199124092e0e8ad0500c167ac146e301) 197 | 198 | 199 | 200 | 201 | 202 | -------------------------------------------------------------------------------- /test/active_record_test_case_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/test_case" 4 | 5 | module ActiveRecordTestCaseHelper 6 | def teardown 7 | SQLCounter.clear_log 8 | end 9 | 10 | def assert_date_from_db(expected, actual, message = nil) 11 | assert_equal expected.to_s, actual.to_s, message 12 | end 13 | 14 | def capture(stream) 15 | stream = stream.to_s 16 | captured_stream = Tempfile.new(stream) 17 | stream_io = eval("$#{stream}") 18 | origin_stream = stream_io.dup 19 | stream_io.reopen(captured_stream) 20 | 21 | yield 22 | 23 | stream_io.rewind 24 | captured_stream.read 25 | ensure 26 | captured_stream.close 27 | captured_stream.unlink 28 | stream_io.reopen(origin_stream) 29 | end 30 | 31 | def capture_sql 32 | SQLCounter.clear_log 33 | yield 34 | SQLCounter.log_all.dup 35 | end 36 | 37 | def assert_sql(*patterns_to_match) 38 | capture_sql { yield } 39 | ensure 40 | failed_patterns = [] 41 | patterns_to_match.each do |pattern| 42 | failed_patterns << pattern unless SQLCounter.log_all.any? { |sql| pattern === sql } 43 | end 44 | assert failed_patterns.empty?, "Query pattern(s) #{failed_patterns.map(&:inspect).join(', ')} not found.#{SQLCounter.log.empty? ? '' : "\nQueries:\n#{SQLCounter.log.join("\n")}"}" 45 | end 46 | 47 | def assert_queries(num = 1, options = {}) 48 | ignore_none = options.fetch(:ignore_none) { num == :any } 49 | SQLCounter.clear_log 50 | x = yield 51 | the_log = ignore_none ? SQLCounter.log_all : SQLCounter.log 52 | if num == :any 53 | assert_operator the_log.size, :>=, 1, "1 or more queries expected, but none were executed." 54 | else 55 | mesg = "#{the_log.size} instead of #{num} queries were executed.#{the_log.empty? ? '' : "\nQueries:\n#{the_log.join("\n")}"}" 56 | assert_equal num, the_log.size, mesg 57 | end 58 | x 59 | end 60 | 61 | def assert_no_queries(options = {}, &block) 62 | options.reverse_merge! ignore_none: true 63 | assert_queries(0, options, &block) 64 | end 65 | 66 | def assert_column(model, column_name, msg = nil) 67 | assert exists_column?(model, column_name), msg 68 | end 69 | 70 | def assert_no_column(model, column_name, msg = nil) 71 | assert_not exists_column?(model, column_name), msg 72 | end 73 | 74 | def exists_column?(model, column_name) 75 | model.reset_column_information 76 | model.column_names.include?(column_name.to_s) 77 | end 78 | 79 | def savepoint 80 | if ActiveRecord::Base.connection.supports_savepoints? 81 | ActiveRecord::Base.connection.begin_transaction(joinable: false) 82 | yield 83 | ActiveRecord::Base.connection.rollback_transaction 84 | else 85 | yield 86 | end 87 | end 88 | 89 | class SQLCounter 90 | class << self 91 | attr_accessor :ignored_sql, :log, :log_all 92 | def clear_log 93 | self.log = [] 94 | self.log_all = [] 95 | end 96 | end 97 | 98 | clear_log 99 | 100 | self.ignored_sql = [/^PRAGMA/, /^SELECT currval/, /^SELECT CAST/, /^SELECT @@IDENTITY/, /^SELECT @@ROWCOUNT/, /^SAVEPOINT/, /^ROLLBACK TO SAVEPOINT/, /^RELEASE SAVEPOINT/, /^SHOW max_identifier_length/, /^BEGIN/, /^COMMIT/] 101 | 102 | # FIXME: this needs to be refactored so specific database can add their own 103 | # ignored SQL, or better yet, use a different notification for the queries 104 | # instead examining the SQL content. 105 | oracle_ignored = [/^select .*nextval/i, /^SAVEPOINT/, /^ROLLBACK TO/, /^\s*select .* from all_triggers/im, /^\s*select .* from all_constraints/im, /^\s*select .* from all_tab_cols/im] 106 | mysql_ignored = [/^SHOW TABLES/i, /^SHOW FULL FIELDS/, /^SHOW CREATE TABLE /i, /^SHOW VARIABLES /] 107 | postgresql_ignored = [/^\s*select\b.*\bfrom\b.*pg_namespace\b/im, /^\s*select tablename\b.*from pg_tables\b/im, /^\s*select\b.*\battname\b.*\bfrom\b.*\bpg_attribute\b/im, /^SHOW search_path/i] 108 | sqlite3_ignored = [/^\s*SELECT name\b.*\bFROM sqlite_master/im] 109 | 110 | [oracle_ignored, mysql_ignored, postgresql_ignored, sqlite3_ignored].each do |db_ignored_sql| 111 | ignored_sql.concat db_ignored_sql 112 | end 113 | 114 | attr_reader :ignore 115 | 116 | def initialize(ignore = Regexp.union(self.class.ignored_sql)) 117 | @ignore = ignore 118 | end 119 | 120 | def call(_name, _start, _finish, _message_id, values) 121 | sql = values[:sql] 122 | 123 | # FIXME: this seems bad. we should probably have a better way to indicate 124 | # the query was cached 125 | return if values[:name] == "CACHE" 126 | 127 | self.class.log_all << sql 128 | self.class.log << sql unless ignore.match?(sql) 129 | end 130 | 131 | ActiveSupport::Notifications.subscribe("sql.active_record", SQLCounter.new) 132 | end 133 | end 134 | 135 | ActiveSupport::TestCase.send(:include, ActiveRecordTestCaseHelper) 136 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | Exclude: 3 | - "test/dummy/**/*" 4 | DisabledByDefault: true 5 | TargetRubyVersion: 2.6 6 | 7 | # Prefer &&/|| over and/or. 8 | Style/AndOr: 9 | Enabled: true 10 | 11 | # Do not use braces for hash literals when they are the last argument of a 12 | # method call. 13 | 14 | # Align `when` with `case`. 15 | Layout/CaseIndentation: 16 | Enabled: true 17 | 18 | # Align comments with method definitions. 19 | Layout/CommentIndentation: 20 | Enabled: true 21 | 22 | Layout/ElseAlignment: 23 | Enabled: true 24 | 25 | # Align `end` with the matching keyword or starting expression except for 26 | # assignments, where it should be aligned with the LHS. 27 | Layout/EndAlignment: 28 | Enabled: true 29 | EnforcedStyleAlignWith: variable 30 | AutoCorrect: true 31 | 32 | Layout/EmptyLineAfterMagicComment: 33 | Enabled: true 34 | 35 | Layout/EmptyLinesAroundAccessModifier: 36 | Enabled: true 37 | EnforcedStyle: only_before 38 | 39 | Layout/EmptyLinesAroundBlockBody: 40 | Enabled: true 41 | 42 | # In a regular class definition, no empty lines around the body. 43 | Layout/EmptyLinesAroundClassBody: 44 | Enabled: true 45 | 46 | # In a regular method definition, no empty lines around the body. 47 | Layout/EmptyLinesAroundMethodBody: 48 | Enabled: true 49 | 50 | # In a regular module definition, no empty lines around the body. 51 | Layout/EmptyLinesAroundModuleBody: 52 | Enabled: true 53 | 54 | # Use Ruby >= 1.9 syntax for hashes. Prefer { a: :b } over { :a => :b }. 55 | Style/HashSyntax: 56 | Enabled: true 57 | 58 | Layout/FirstArgumentIndentation: 59 | Enabled: true 60 | 61 | # Method definitions after `private` or `protected` isolated calls need one 62 | # extra level of indentation. 63 | Layout/IndentationConsistency: 64 | Enabled: true 65 | EnforcedStyle: indented_internal_methods 66 | 67 | # Two spaces, no tabs (for indentation). 68 | Layout/IndentationWidth: 69 | Enabled: true 70 | 71 | Layout/LeadingCommentSpace: 72 | Enabled: true 73 | 74 | Layout/SpaceAfterColon: 75 | Enabled: true 76 | 77 | Layout/SpaceAfterComma: 78 | Enabled: true 79 | 80 | Layout/SpaceAfterSemicolon: 81 | Enabled: true 82 | 83 | Layout/SpaceAfterMethodName: 84 | Enabled: true 85 | 86 | Layout/SpaceAfterNot: 87 | Enabled: true 88 | 89 | Layout/SpaceAroundBlockParameters: 90 | Enabled: true 91 | 92 | Layout/SpaceAroundEqualsInParameterDefault: 93 | Enabled: true 94 | 95 | Layout/SpaceAroundKeyword: 96 | Enabled: true 97 | 98 | Layout/SpaceAroundOperators: 99 | Enabled: true 100 | 101 | Layout/SpaceBeforeSemicolon: 102 | Enabled: true 103 | 104 | Layout/SpaceBeforeComma: 105 | Enabled: true 106 | 107 | Layout/SpaceBeforeComment: 108 | Enabled: true 109 | 110 | Layout/SpaceBeforeFirstArg: 111 | Enabled: true 112 | 113 | Layout/SpaceInsideRangeLiteral: 114 | Enabled: true 115 | 116 | Layout/SpaceInsideReferenceBrackets: 117 | Enabled: true 118 | 119 | Layout/SpaceInsideStringInterpolation: 120 | Enabled: true 121 | 122 | Style/DefWithParentheses: 123 | Enabled: true 124 | 125 | # Defining a method with parameters needs parentheses. 126 | Style/MethodDefParentheses: 127 | Enabled: true 128 | 129 | Style/RedundantFreeze: 130 | Enabled: true 131 | 132 | # Use `foo {}` not `foo{}`. 133 | Layout/SpaceBeforeBlockBraces: 134 | Enabled: true 135 | 136 | # Use `foo { bar }` not `foo {bar}`. 137 | Layout/SpaceInsideBlockBraces: 138 | Enabled: true 139 | EnforcedStyleForEmptyBraces: space 140 | 141 | # Use `{ a: 1 }` not `{a:1}`. 142 | Layout/SpaceInsideHashLiteralBraces: 143 | Enabled: true 144 | 145 | Layout/SpaceInsideParens: 146 | Enabled: true 147 | 148 | # Check quotes usage according to lint rule below. 149 | Style/StringLiterals: 150 | Enabled: true 151 | EnforcedStyle: double_quotes 152 | 153 | # Detect hard tabs, no hard tabs. 154 | Layout/IndentationStyle: 155 | Enabled: true 156 | 157 | # Blank lines should not have any spaces. 158 | Layout/TrailingEmptyLines: 159 | Enabled: true 160 | 161 | # No trailing whitespace. 162 | Layout/TrailingWhitespace: 163 | Enabled: true 164 | 165 | # Use quotes for string literals when they are enough. 166 | Style/RedundantPercentQ: 167 | Enabled: true 168 | 169 | Lint/AmbiguousOperator: 170 | Enabled: true 171 | 172 | Lint/AmbiguousRegexpLiteral: 173 | Enabled: false 174 | 175 | Lint/ErbNewArguments: 176 | Enabled: true 177 | 178 | # Use my_method(my_arg) not my_method( my_arg ) or my_method my_arg. 179 | Lint/RequireParentheses: 180 | Enabled: true 181 | 182 | Lint/ShadowingOuterLocalVariable: 183 | Enabled: true 184 | 185 | Lint/RedundantStringCoercion: 186 | Enabled: true 187 | 188 | Lint/UriEscapeUnescape: 189 | Enabled: true 190 | 191 | Lint/UselessAssignment: 192 | Enabled: false 193 | Exclude: 194 | - "**/test/**/*" 195 | 196 | Lint/DeprecatedClassMethods: 197 | Enabled: true 198 | 199 | Style/ParenthesesAroundCondition: 200 | Enabled: true 201 | 202 | Style/RedundantBegin: 203 | Enabled: true 204 | 205 | Style/RedundantReturn: 206 | Enabled: true 207 | AllowMultipleReturnValues: true 208 | 209 | Style/Semicolon: 210 | Enabled: true 211 | AllowAsExpressionSeparator: true 212 | 213 | # Prefer Foo.method over Foo::method 214 | Style/ColonMethodCall: 215 | Enabled: true 216 | 217 | Style/TrivialAccessors: 218 | Enabled: true 219 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SecondLevelCache 2 | 3 | [![Gem Version](https://badge.fury.io/rb/second_level_cache.svg)](http://badge.fury.io/rb/second_level_cache) 4 | [![build](https://github.com/hooopo/second_level_cache/actions/workflows/build.yml/badge.svg)](https://github.com/hooopo/second_level_cache/actions/workflows/build.yml) 5 | [![Code Climate](https://codeclimate.com/github/hooopo/second_level_cache.svg)](https://codeclimate.com/github/hooopo/second_level_cache) 6 | 7 | SecondLevelCache is a write-through and read-through caching library inspired by Cache Money and cache_fu, support ActiveRecord 4, ActiveRecord 5 and ActiveRecord 6. 8 | 9 | Read-Through: Queries by ID, like `current_user.articles.find(params[:id])`, will first look in cache store and then look in the database for the results of that query. If there is a cache miss, it will populate the cache. 10 | 11 | Write-Through: As objects are created, updated, and deleted, all of the caches are automatically kept up-to-date and coherent. 12 | 13 | ## Install 14 | 15 | In your gem file: 16 | 17 | ActiveRecord 7 18 | 19 | ```ruby 20 | gem 'second_level_cache', '~> 2.7' 21 | ``` 22 | 23 | ActiveRecord 5.2 and 6.0: 24 | 25 | ```ruby 26 | gem 'second_level_cache', '~> 2.6.3' 27 | ``` 28 | 29 | ActiveRecord 5.0.x, 5.1.x: 30 | 31 | ```ruby 32 | gem 'second_level_cache', '~> 2.3.0' 33 | ``` 34 | 35 | For ActiveRecord 4: 36 | 37 | ```ruby 38 | gem "second_level_cache", "~> 2.1.9" 39 | ``` 40 | 41 | For ActiveRecord 3: 42 | 43 | ```ruby 44 | gem "second_level_cache", "~> 1.6" 45 | ``` 46 | 47 | ## Usage 48 | 49 | For example, cache User objects: 50 | 51 | ```ruby 52 | class User < ActiveRecord::Base 53 | second_level_cache expires_in: 1.week 54 | end 55 | ``` 56 | 57 | Then it will fetch cached object in this situations: 58 | 59 | ```ruby 60 | User.find(1) 61 | user.articles.find(1) 62 | User.where(status: 1).find(1) 63 | User.where(id: 1).first # or .last 64 | article.user 65 | ``` 66 | 67 | Cache key: 68 | 69 | ```ruby 70 | user = User.find(1) 71 | user.second_level_cache_key # We will get the key looks like "slc/user/1/0" 72 | ``` 73 | 74 | Expires cache: 75 | 76 | ```ruby 77 | user = User.find(1) 78 | user.expire_second_level_cache 79 | ``` 80 | 81 | or expires cache using class method: 82 | 83 | ```ruby 84 | User.expire_second_level_cache(1) 85 | ``` 86 | 87 | Disable SecondLevelCache: 88 | 89 | ```ruby 90 | User.without_second_level_cache do 91 | user = User.find(1) 92 | # ... 93 | end 94 | ``` 95 | 96 | Only `SELECT *` query will be cached: 97 | 98 | ```ruby 99 | # this query will NOT be cached 100 | User.select("id, name").find(1) 101 | ``` 102 | 103 | ## Notice 104 | 105 | - SecondLevelCache cache by model name and id, so only find_one query will work. 106 | - Only equal conditions query WILL get cache; and SQL string query like `User.where("name = 'Hooopo'").find(1)` WILL NOT work. 107 | - SecondLevelCache sync cache after transaction commit: 108 | 109 | ```ruby 110 | # user and account's write_second_level_cache operation will invoke after the logger. 111 | ActiveRecord::Base.transaction do 112 | user.save 113 | account.save 114 | Rails.logger.info "info" 115 | end # <- Cache write 116 | 117 | # if you want to do something after user and account's write_second_level_cache operation, do this way: 118 | ActiveRecord::Base.transaction do 119 | user.save 120 | account.save 121 | end # <- Cache write 122 | Rails.logger.info "info" 123 | ``` 124 | 125 | - If you are using SecondLevelCache with database_cleaner, you should set cleaning strategy to `:truncation`: 126 | 127 | ```ruby 128 | DatabaseCleaner.strategy = :truncation 129 | ``` 130 | 131 | ## Configure 132 | 133 | In production env, we recommend to use [Dalli](https://github.com/mperham/dalli) as Rails cache store. 134 | 135 | ```ruby 136 | config.cache_store = [:dalli_store, APP_CONFIG["memcached_host"], { namespace: "ns", compress: true }] 137 | ``` 138 | 139 | ## Tips: 140 | 141 | - When you want to clear only second level cache apart from other cache for example fragment cache in cache store, 142 | you can only change the `cache_key_prefix` (default: `slc`): 143 | 144 | ```ruby 145 | SecondLevelCache.configure.cache_key_prefix = "slc1" 146 | ``` 147 | 148 | - SecondLevelCache was added model schema digest as cache version, this means when you add/remove/change columns, the caches of this Model will expires. 149 | - When your want change the model cache version by manualy, just add the `version` option like this: 150 | 151 | ```ruby 152 | class User < ActiveRecord::Base 153 | second_level_cache version: 2, expires_in: 1.week 154 | end 155 | ``` 156 | 157 | - It provides a great feature, not hits db when fetching record via unique key (not primary key). 158 | 159 | ```ruby 160 | # this will fetch from cache 161 | user = User.fetch_by_uniq_keys(nick_name: "hooopo") 162 | post = Post.fetch_by_uniq_keys(user_id: 2, slug: "foo") 163 | 164 | # this also fetch from cache 165 | user = User.fetch_by_uniq_keys!(nick_name: "hooopo") # this will raise `ActiveRecord::RecordNotFound` Exception when nick name not exists. 166 | ``` 167 | 168 | - You can use Rails's [Eager Loading](http://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations) feature as normal. Even better, second_level_cache will transform the `IN` query into a Rails.cache.multi_read operation. For example: 169 | 170 | ```ruby 171 | Answer.includes(:question).limit(10).order("id DESC").each{|answer| answer.question.title} 172 | Answer Load (0.2ms) SELECT `answers`.* FROM `answers` ORDER BY id DESC LIMIT 10 # Only one SQL query and one Rails.cache.read_multi fetching operation. 173 | ``` 174 | 175 | [Details for read_multi feature](http://hooopo.writings.io/articles/a9cae5e0). 176 | 177 | ## Original design by: 178 | 179 | - [chloerei](https://github.com/chloerei) 180 | - [hooopo](https://github.com/hooopo) 181 | 182 | ## Contributors 183 | 184 | [Contributor List](https://github.com/hooopo/second_level_cache/graphs/contributors) 185 | 186 | ## License 187 | 188 | MIT License 189 | --------------------------------------------------------------------------------