ar_cache 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/main.yml +68 -0
  3. data/.gitignore +8 -0
  4. data/.rubocop.yml +56 -0
  5. data/CODE_OF_CONDUCT.md +84 -0
  6. data/Gemfile +7 -0
  7. data/Gemfile.common +17 -0
  8. data/Gemfile.lock +192 -0
  9. data/LICENSE.txt +21 -0
  10. data/README.md +166 -0
  11. data/Rakefile +28 -0
  12. data/ar_cache.gemspec +34 -0
  13. data/bin/activerecord-test +20 -0
  14. data/bin/console +15 -0
  15. data/bin/setup +8 -0
  16. data/gemfiles/rails-6-1 +3 -0
  17. data/gemfiles/rails-edge +3 -0
  18. data/lib/ar_cache.rb +22 -0
  19. data/lib/ar_cache/active_record.rb +36 -0
  20. data/lib/ar_cache/active_record/associations/has_one_through_association.rb +39 -0
  21. data/lib/ar_cache/active_record/associations/singular_association.rb +18 -0
  22. data/lib/ar_cache/active_record/connection_adapters/abstract/database_statements.rb +76 -0
  23. data/lib/ar_cache/active_record/connection_adapters/abstract/transaction.rb +90 -0
  24. data/lib/ar_cache/active_record/core.rb +21 -0
  25. data/lib/ar_cache/active_record/insert_all.rb +17 -0
  26. data/lib/ar_cache/active_record/model_schema.rb +27 -0
  27. data/lib/ar_cache/active_record/persistence.rb +23 -0
  28. data/lib/ar_cache/active_record/relation.rb +48 -0
  29. data/lib/ar_cache/configuration.rb +59 -0
  30. data/lib/ar_cache/log_subscriber.rb +8 -0
  31. data/lib/ar_cache/marshal.rb +81 -0
  32. data/lib/ar_cache/mock_table.rb +55 -0
  33. data/lib/ar_cache/query.rb +95 -0
  34. data/lib/ar_cache/record.rb +54 -0
  35. data/lib/ar_cache/store.rb +38 -0
  36. data/lib/ar_cache/table.rb +126 -0
  37. data/lib/ar_cache/version.rb +5 -0
  38. data/lib/ar_cache/where_clause.rb +151 -0
  39. data/lib/generators/ar_cache/install_generator.rb +22 -0
  40. data/lib/generators/ar_cache/templates/configuration.rb +34 -0
  41. data/lib/generators/ar_cache/templates/migrate/create_ar_cache_records.rb.tt +16 -0
  42. metadata +107 -0
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArCache
4
+ module ActiveRecord
5
+ module ConnectionAdapters
6
+ module NullTransaction
7
+ def add_ar_cache_keys(keys, delay: false) # rubocop:disable Lint/UnusedMethodArgument
8
+ ArCache::Store.delete_multi(keys)
9
+ end
10
+
11
+ def add_ar_cache_table(table, delay: false) # rubocop:disable Lint/UnusedMethodArgument
12
+ table.update_version
13
+ end
14
+
15
+ def add_changed_table(_); end
16
+ end
17
+
18
+ module Transaction
19
+ include NullTransaction
20
+
21
+ def initialize(...)
22
+ super
23
+ @ar_cache_keys = []
24
+ @ar_cache_tables = []
25
+ end
26
+
27
+ def add_ar_cache_keys(keys, delay: false)
28
+ super if !delay && read_uncommitted?
29
+ @ar_cache_keys.push(*keys)
30
+ end
31
+
32
+ def add_ar_cache_table(table, delay: false)
33
+ add_changed_table(table.name) unless delay
34
+
35
+ super if !delay && read_uncommitted?
36
+ @ar_cache_tables.push(table)
37
+ end
38
+
39
+ def add_changed_table(table_name)
40
+ connection.transaction_manager.add_changed_table(table_name)
41
+ end
42
+
43
+ # FIXME: Cache update and transaction commit may cause dirty reads during this period!
44
+ def commit
45
+ super
46
+ ensure
47
+ if @run_commit_callbacks
48
+ @ar_cache_tables.uniq(&:name).each(&:update_version) if @ar_cache_tables.any?
49
+ ArCache::Store.delete_multi(@ar_cache_keys.uniq) if @ar_cache_keys.any?
50
+ else
51
+ transaction = connection.current_transaction
52
+ @ar_cache_tables.each { |table| transaction.add_ar_cache_table(table, delay: true) }
53
+ transaction.add_ar_cache_keys(@ar_cache_keys, delay: true)
54
+ end
55
+ end
56
+
57
+ def read_uncommitted?
58
+ ArCache::Configuration.read_uncommitted ||
59
+ isolation_level == :read_uncommitted ||
60
+ !connection.transaction_manager.fully_joinable?
61
+ end
62
+ end
63
+
64
+ module TransactionManager
65
+ def initialize(...)
66
+ super
67
+ @changed_tables = {}
68
+ end
69
+
70
+ def add_changed_table(table_name)
71
+ @changed_tables[table_name] = true if fully_joinable?
72
+ end
73
+
74
+ def changed_table?(table_name)
75
+ @changed_tables.key?(table_name)
76
+ end
77
+
78
+ def fully_joinable?
79
+ @stack.all?(&:joinable?)
80
+ end
81
+
82
+ def within_new_transaction(...)
83
+ super
84
+ ensure
85
+ @changed_tables = {} if @stack.count(&:joinable?).zero?
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArCache
4
+ module ActiveRecord
5
+ module Core
6
+ module ClassMethods
7
+ delegate :skip_ar_cache, to: :all
8
+
9
+ # The #find use statement cache execute querying first, so need force skip.
10
+ def find(...)
11
+ ar_cache_table.enabled? ? all.find(...) : super
12
+ end
13
+
14
+ # The #find_by use statement cache execute querying first, so need force skip.
15
+ def find_by(...)
16
+ ar_cache_table.enabled? ? all.find_by(...) : super
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArCache
4
+ module ActiveRecord
5
+ module InsertAll
6
+ def execute
7
+ super.tap do
8
+ if on_duplicate == :update
9
+ connection.current_transaction.add_ar_cache_table(model.ar_cache_table)
10
+ else
11
+ connection.transaction_manager.add_changed_table(model.table_name)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArCache
4
+ module ActiveRecord
5
+ module ModelSchema
6
+ module ClassMethods
7
+ def table_name=(...)
8
+ super.tap { remove_instance_variable(:@ar_cache_table) if defined?(@ar_cache_table) }
9
+ end
10
+
11
+ def ar_cache_table
12
+ @ar_cache_table ||= begin
13
+ if abstract_class? || self == ArCache::Record
14
+ ArCache::MockTable
15
+ else
16
+ ArCache::Table.new(table_name)
17
+ end
18
+ end
19
+ end
20
+ end
21
+
22
+ def ar_cache_table
23
+ self.class.ar_cache_table
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArCache
4
+ module ActiveRecord
5
+ module Persistence
6
+ def reload(options = nil)
7
+ self.class.connection.clear_query_cache
8
+
9
+ fresh_object =
10
+ if options && options[:lock]
11
+ self.class.unscoped { self.class.skip_ar_cache.lock(options[:lock]).find(id) }
12
+ else
13
+ self.class.unscoped { self.class.skip_ar_cache.find(id) }
14
+ end
15
+
16
+ @attributes = fresh_object.instance_variable_get(:@attributes)
17
+ @new_record = false
18
+ @previously_new_record = false
19
+ self
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArCache
4
+ module ActiveRecord
5
+ module Relation
6
+ def skip_ar_cache
7
+ tap { @skip_ar_cache = true }
8
+ end
9
+
10
+ def explain
11
+ @skip_ar_cache = true
12
+ super
13
+ end
14
+
15
+ private def exec_queries(&block) # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity, Metrics/MethodLength
16
+ skip_query_cache_if_necessary do
17
+ records =
18
+ if where_clause.contradiction?
19
+ []
20
+ elsif eager_loading?
21
+ apply_join_dependency do |relation, join_dependency|
22
+ if relation.null_relation?
23
+ []
24
+ else
25
+ relation = join_dependency.apply_column_aliases(relation)
26
+ rows = connection.select_all(relation.arel, 'SQL')
27
+ join_dependency.instantiate(rows, strict_loading_value, &block)
28
+ end.freeze
29
+ end
30
+ elsif @skip_ar_cache ||
31
+ klass.ar_cache_table.disabled? ||
32
+ connection.transaction_manager.changed_table?(table_name)
33
+ klass.find_by_sql(arel, &block).freeze
34
+ else
35
+ ArCache::Query.new(self).exec_queries(&block).freeze
36
+ end
37
+
38
+ preload_associations(records) unless skip_preloading_value
39
+
40
+ records.each(&:readonly!) if readonly_value
41
+ records.each(&:strict_loading!) if strict_loading_value
42
+
43
+ records
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module ArCache
6
+ class Configuration
7
+ singleton_class.attr_accessor :disabled, :select_disabled, :expires_in, :read_uncommitted, :index_column_max_size
8
+ singleton_class.attr_reader :cache_store, :tables_options, :coder
9
+
10
+ def self.configure
11
+ block_given? ? yield(self) : self
12
+ end
13
+
14
+ def self.cache_store=(cache_store)
15
+ unless cache_store.is_a?(ActiveSupport::Cache::Store)
16
+ raise ArgumentError, 'The cache_store must be an ActiveSupport::Cache::Store object'
17
+ end
18
+
19
+ @cache_store = cache_store
20
+ end
21
+
22
+ def self.tables_options=(options)
23
+ options.each do |name, hash|
24
+ raise ArgumentError, "The #{name.inspect} must be converted to Symbol type" unless name.is_a?(Symbol)
25
+
26
+ hash.each_key do |k|
27
+ raise ArgumentError, "The #{k.inspect} must be converted to Symbol type" unless k.is_a?(Symbol)
28
+ end
29
+ end
30
+
31
+ @tables_options = options
32
+ end
33
+
34
+ def self.coder=(coder)
35
+ raise ArgumentError, 'The coder only support use YAML or JSON' unless [::YAML, ::JSON].include?(coder)
36
+
37
+ @coder = coder
38
+ end
39
+
40
+ def self.get_table_options(name)
41
+ options = tables_options[name.to_sym] || {}
42
+ options[:disabled] = disabled unless options.key?(:disabled)
43
+ options[:select_disabled] = select_disabled unless options.key?(:select_disabled)
44
+ options[:ignored_columns] = Array(options[:ignored_columns]).map(&:to_s)
45
+ options[:unique_indexes] = Array(options[:unique_indexes]).map { |index| Array(index).map(&:to_s).uniq }.uniq
46
+ options
47
+ end
48
+
49
+ # The set default values
50
+ @cache_store = defined?(Rails) ? Rails.cache : ActiveSupport::Cache::MemoryStore.new
51
+ @tables_options = {}
52
+ @coder = ::YAML
53
+ @disabled = false
54
+ @select_disabled = true
55
+ @expires_in = 604_800 # 1 week
56
+ @read_uncommitted = false
57
+ @index_column_max_size = 64
58
+ end
59
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # TODO
4
+ module ArCache
5
+ class LogSubscriber < ActiveSupport::LogSubscriber
6
+ attach_to :ar_cache
7
+ end
8
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArCache
4
+ module Marshal
5
+ def delete(*ids)
6
+ return -1 if disabled?
7
+
8
+ ArCache::Store.delete_multi(ids.map { |id| primary_cache_key(id) })
9
+ end
10
+
11
+ # WARNING:
12
+ # In order to ensure that the written data is consistent with the database,
13
+ # only the record from the query can be written.
14
+ def write(records)
15
+ return -1 if disabled?
16
+
17
+ cache_hash = {}
18
+ records.each do |record|
19
+ attributes = record.attributes_before_type_cast
20
+ key = nil
21
+
22
+ unique_indexes.each_with_index do |index, i|
23
+ if i.zero? # is primary key
24
+ key = primary_cache_key(attributes[primary_key])
25
+ cache_hash[key] = attributes
26
+ else
27
+ cache_hash[cache_key(attributes, index)] = key
28
+ end
29
+ end
30
+ end
31
+
32
+ ArCache::Store.write_multi(cache_hash)
33
+ rescue Encoding::UndefinedConversionError
34
+ 0
35
+ end
36
+
37
+ def read(where_clause, select_values, &block)
38
+ entries_hash = ArCache::Store.read_multi(where_clause.cache_hash.keys)
39
+ where_clause.cache_hash.each_key { |k| where_clause.add_missed_values(k) unless entries_hash.key?(k) }
40
+
41
+ records = []
42
+
43
+ entries_hash.each do |k, entry|
44
+ entry = entry.slice(*select_values) if select_values
45
+ wrong_key = detect_wrong_key(entry, where_clause.to_h)
46
+
47
+ if wrong_key
48
+ where_clause.add_missed_values(k)
49
+ where_clause.add_invalid_keys(k) if column_indexes.include?(wrong_key)
50
+ else
51
+ records << instantiate(where_clause.klass, entry, &block)
52
+ end
53
+ end
54
+
55
+ where_clause.delete_invalid_keys
56
+
57
+ records
58
+ end
59
+
60
+ private def detect_wrong_key(entry, where_values_hash)
61
+ where_values_hash.detect do |k, v|
62
+ value = entry[k]
63
+ next if value.nil?
64
+
65
+ if v.is_a?(Array)
66
+ return k unless v.include?(value)
67
+ else
68
+ return k unless v == value
69
+ end
70
+ end
71
+ end
72
+
73
+ private def instantiate(klass, attributes, &block)
74
+ attributes.except!(*klass.ignored_columns) if klass.ignored_columns.any?
75
+
76
+ return klass.instantiate(attributes, &block) if attributes.key?(klass.inheritance_column)
77
+
78
+ klass.send(:instantiate_instance_of, klass, attributes, &block)
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArCache
4
+ class MockTable
5
+ class << self
6
+ def disabled?
7
+ true
8
+ end
9
+
10
+ def enabled?
11
+ false
12
+ end
13
+
14
+ def select_disabled?
15
+ true
16
+ end
17
+
18
+ def select_enabled?
19
+ false
20
+ end
21
+
22
+ def version
23
+ -1
24
+ end
25
+
26
+ def update_version(...)
27
+ -1
28
+ end
29
+
30
+ def primary_key
31
+ ''
32
+ end
33
+
34
+ def primary_cache_key(...)
35
+ ''
36
+ end
37
+
38
+ def cache_key(...)
39
+ ''
40
+ end
41
+
42
+ def write(...)
43
+ -1
44
+ end
45
+
46
+ def delete(...)
47
+ -1
48
+ end
49
+
50
+ def read(...)
51
+ []
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArCache
4
+ class Query
5
+ attr_reader :relation, :table, :where_clause
6
+
7
+ def initialize(relation)
8
+ @relation = relation
9
+ @table = @relation.klass.ar_cache_table
10
+ @where_clause = ArCache::WhereClause.new(@relation.klass, @relation.where_clause.send(:predicates))
11
+ end
12
+
13
+ def exec_queries(&block)
14
+ return relation.skip_ar_cache.send(:exec_queries, &block) unless exec_queries_cacheable?
15
+
16
+ records = table.read(where_clause, @select_values, &block)
17
+
18
+ missed_relation = if records.empty?
19
+ relation
20
+ elsif where_clause.missed_hash.any?
21
+ relation.rewhere(where_clause.missed_hash)
22
+ end
23
+
24
+ if missed_relation
25
+ if table.ignored_columns.any? && relation.select_values.any?
26
+ missed_relation = missed_relation.reselect(table.column_names)
27
+ end
28
+ records += relation.find_by_sql(missed_relation.arel, &block).tap { |rs| table.write(rs) }
29
+ end
30
+
31
+ records_order(records)
32
+ end
33
+
34
+ private def exec_queries_cacheable? # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
35
+ return false if relation.skip_query_cache_value
36
+ return false if relation.lock_value
37
+ return false if relation.group_values.any?
38
+ return false if relation.joins_values.any?
39
+ return false if relation.left_outer_joins_values.any?
40
+ return false if relation.offset_value
41
+ return false unless relation.from_clause.empty?
42
+ return false unless where_clause.cacheable?
43
+ return false unless select_values_cacheable?
44
+ return false unless order_values_cacheable?
45
+ return false unless limit_value_cacheable?
46
+
47
+ true
48
+ end
49
+
50
+ private def select_values_cacheable?
51
+ return true if relation.select_values.empty?
52
+ return false if table.select_disabled?
53
+
54
+ @select_values = relation.select_values.map(&:to_s)
55
+ (@select_values - table.column_names).empty?
56
+ end
57
+
58
+ private def order_values_cacheable?
59
+ return true if where_clause.single?
60
+
61
+ size = relation.order_values.size
62
+ return true if size.zero?
63
+ return false if size > 1
64
+
65
+ first_order_value = relation.order_values.first
66
+ case first_order_value
67
+ when Arel::Nodes::Ordering
68
+ @order_name = first_order_value.expr.name
69
+ @order_desc = first_order_value.descending?
70
+ when String
71
+ @order_name, @order_desc = first_order_value.downcase.split
72
+ return false unless table.column_names.include?(@order_name)
73
+
74
+ @order_desc = @order_desc == 'desc'
75
+ else
76
+ return false
77
+ end
78
+
79
+ true
80
+ end
81
+
82
+ private def limit_value_cacheable?
83
+ where_clause.single? || relation.limit_value.nil?
84
+ end
85
+
86
+ private def records_order(records)
87
+ return records if records.size < 2
88
+
89
+ method = "#{@order_name || table.primary_key}_for_database"
90
+ return records.sort! { |a, b| b.send(method) <=> a.send(method) } if @order_desc
91
+
92
+ records.sort! { |a, b| a.send(method) <=> b.send(method) }
93
+ end
94
+ end
95
+ end