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,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArCache
4
+ class Record < ActiveRecord::Base # rubocop:disable Rails/ApplicationRecord
5
+ self.table_name = 'ar_cache_records'
6
+
7
+ default_scope { skip_ar_cache }
8
+
9
+ def self.get(table_name)
10
+ find_by(table_name: table_name)
11
+ end
12
+
13
+ def self.version(table)
14
+ (get(table.name) || store(table)).version
15
+ end
16
+
17
+ def self.update_version(table)
18
+ record = get(table.name)
19
+ return store(table).version unless record
20
+
21
+ record.update_version
22
+ record.version
23
+ end
24
+
25
+ def self.store(table)
26
+ record = get(table.name) || new(table_name: table.name)
27
+ record.store(table)
28
+ record
29
+ end
30
+
31
+ def store(table)
32
+ with_optimistic_retry do
33
+ self.version += 1 unless table_md5 == table.md5
34
+ self.table_md5 = table.md5
35
+
36
+ save! if changed?
37
+ end
38
+ end
39
+
40
+ def update_version
41
+ with_optimistic_retry do
42
+ self.version += 1
43
+ save!
44
+ end
45
+ end
46
+
47
+ private def with_optimistic_retry
48
+ yield
49
+ rescue ::ActiveRecord::StaleObjectError
50
+ reload
51
+ retry
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArCache
4
+ class Store
5
+ @options = { raw: true, expires_in: ArCache::Configuration.expires_in }.freeze
6
+
7
+ class << self
8
+ delegate :delete, :delete_multi, :clear, :exist?, to: 'ArCache::Configuration.cache_store'
9
+
10
+ def write(name, value)
11
+ ArCache::Configuration.cache_store.write(name, dump(value), @options)
12
+ end
13
+
14
+ def write_multi(hash)
15
+ hash.each { |k, v| hash[k] = dump(v) }
16
+ ArCache::Configuration.cache_store.write_multi(hash, @options)
17
+ end
18
+
19
+ def read(name)
20
+ load(ArCache::Configuration.cache_store.read(name, @options))
21
+ end
22
+
23
+ def read_multi(names)
24
+ entries = ArCache::Configuration.cache_store.read_multi(*names, @options)
25
+ entries.each { |k, v| entries[k] = load(v) }
26
+ entries
27
+ end
28
+
29
+ private def dump(value)
30
+ ArCache::Configuration.coder.dump(value)
31
+ end
32
+
33
+ private def load(value)
34
+ ArCache::Configuration.coder.load(value) if value
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArCache
4
+ class Table
5
+ include Marshal
6
+
7
+ OPTIONS = %i[disabled select_disabled unique_indexes ignored_columns].freeze
8
+
9
+ singleton_class.attr_reader :all
10
+
11
+ attr_reader :name, :primary_key, :unique_indexes, :column_indexes, :column_names, :md5,
12
+ :ignored_columns, :cache_key_prefix
13
+
14
+ delegate :connection, to: ActiveRecord::Base, private: true
15
+
16
+ @lock = Mutex.new
17
+
18
+ @all = []
19
+
20
+ def self.new(table_name)
21
+ @lock.synchronize do
22
+ @all.find { |table| table.name == table_name } || super
23
+ end
24
+ end
25
+
26
+ def initialize(table_name)
27
+ @name = table_name
28
+ @cache_key_prefix = "arcache:#{@name}:version"
29
+ @primary_key = connection.primary_key(@name)
30
+ columns = connection.columns(@name)
31
+ options = ArCache::Configuration.get_table_options(@name)
32
+ @unique_indexes = normalize_unique_indexes(options.delete(:unique_indexes), columns).freeze
33
+ options.each { |k, v| instance_variable_set("@#{k}", v) }
34
+ @disabled = true if @primary_key.nil? # ArCache is depend on primary key implementation.
35
+ @column_names = (columns.map(&:name) - @ignored_columns).freeze
36
+ @column_indexes = @unique_indexes.flatten.uniq.freeze
37
+ coder = ArCache::Configuration.coder
38
+ @md5 = Digest::MD5.hexdigest("#{coder}-#{@disabled}-#{columns.to_json}-#{@ignored_columns.to_json}")
39
+
40
+ ArCache::Record.store(self)
41
+
42
+ self.class.all << self
43
+ end
44
+
45
+ def disabled?
46
+ @disabled
47
+ end
48
+
49
+ def enabled?
50
+ !disabled?
51
+ end
52
+
53
+ def select_disabled?
54
+ @select_disabled
55
+ end
56
+
57
+ def select_enabled?
58
+ !@select_disabled
59
+ end
60
+
61
+ def version
62
+ version = ArCache::Store.read(cache_key_prefix)
63
+ unless version
64
+ version = ArCache::Record.version(self)
65
+ ArCache::Store.write(cache_key_prefix, version)
66
+ end
67
+
68
+ version
69
+ end
70
+
71
+ def update_version
72
+ return -1 if disabled?
73
+
74
+ version = ArCache::Record.update_version(self)
75
+ ArCache::Store.write(cache_key_prefix, version)
76
+ version
77
+ end
78
+
79
+ def primary_cache_key(id)
80
+ "#{cache_key_prefix}:#{version}:#{primary_key}=#{id}"
81
+ end
82
+
83
+ def cache_key(where_values_hash, index, multi_values_key = nil, key_value = nil)
84
+ where_value = index.map do |column|
85
+ value = column == multi_values_key ? key_value : where_values_hash[column]
86
+ "#{column}=#{value}"
87
+ end.sort.join('&')
88
+
89
+ "#{cache_key_prefix}:#{version}:#{where_value}"
90
+ end
91
+
92
+ private def normalize_unique_indexes(indexes, columns)
93
+ indexes = indexes.empty? ? query_unique_indexes(columns) : custom_unique_indexes(indexes, columns)
94
+ (indexes - [primary_key]).sort_by(&:size).unshift([primary_key])
95
+ end
96
+
97
+ private def query_unique_indexes(columns) # rubocop:disable Metrics/CyclomaticComplexity
98
+ connection.indexes(name).filter_map do |index|
99
+ next unless index.unique
100
+
101
+ index.columns.each do |column|
102
+ column = columns.find { |c| c.name == column }
103
+ next if column.null
104
+ next if column.type == :datetime
105
+ end
106
+
107
+ index.columns
108
+ rescue NoMethodError # The index.columns maybe isn't Array type
109
+ next
110
+ end
111
+ end
112
+
113
+ private def custom_unique_indexes(indexes)
114
+ indexes.each do |index|
115
+ index.each do |column|
116
+ column = columns.find { |c| c.name == column }
117
+ raise ArgumentError, "The #{name} table not found #{column.inspect} column" if column.nil?
118
+
119
+ if column.type == :datetime
120
+ raise ArgumentError, "The #{column.inspect} is datetime type, ArCache do't support datetime type"
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArCache
4
+ VERSION = '1.0.0'
5
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArCache
4
+ class WhereClause
5
+ attr_reader :klass, :table, :predicates
6
+
7
+ def initialize(klass, predicates)
8
+ @klass = klass
9
+ @table = klass.ar_cache_table
10
+ @predicates = predicates
11
+ @missed_values = []
12
+ end
13
+
14
+ def cacheable?
15
+ return @cacheable if defined?(@cacheable)
16
+
17
+ @cacheable = predicates.any? && where_values_hash.length == predicates.length && hit_unique_index?
18
+ end
19
+
20
+ def hit_unique_index?
21
+ table.unique_indexes.each do |index|
22
+ @index = index
23
+ @multi_values_key = nil
24
+ count = 0
25
+
26
+ bool = index.all? do |column|
27
+ where_values_hash[column].tap do |value|
28
+ if value.is_a?(Array)
29
+ @multi_values_key = column
30
+ count += 1
31
+ end
32
+ end
33
+ end
34
+
35
+ return true if bool && count < 2
36
+ end
37
+
38
+ false
39
+ end
40
+
41
+ def single?
42
+ @multi_values_key.nil?
43
+ end
44
+
45
+ def primary_key_index?
46
+ (@multi_values_key || @index.first) == table.primary_key
47
+ end
48
+
49
+ def cache_hash
50
+ return @cache_hash if defined?(@cache_hash)
51
+
52
+ @cache_hash = {}
53
+ multi_values_key = @multi_values_key || @index.first
54
+
55
+ Array(where_values_hash[multi_values_key]).each do |v|
56
+ @cache_hash[table.cache_key(where_values_hash, @index, multi_values_key, v)] = v
57
+ end
58
+
59
+ return @cache_hash if primary_key_index?
60
+
61
+ @original_cache_hash = @cache_hash
62
+ @cache_hash = ArCache::Store.read_multi(@cache_hash.keys)
63
+ @original_cache_hash.each { |k, v| @missed_values << v unless @cache_hash.key?(k) }
64
+ @cache_hash = @cache_hash.invert
65
+
66
+ @cache_hash
67
+ end
68
+
69
+ def cache_keys
70
+ keys = cache_hash.keys
71
+ keys += cache_hash.values unless primary_key_index?
72
+
73
+ keys
74
+ end
75
+
76
+ def missed_hash
77
+ @missed_hash ||= @missed_values.empty? ? {} : { (@multi_values_key || @index.first) => @missed_values }
78
+ end
79
+
80
+ def add_missed_values(key)
81
+ if primary_key_index?
82
+ @missed_values << cache_hash[key]
83
+ else
84
+ @missed_values << @original_cache_hash[cache_hash[key]]
85
+ end
86
+ end
87
+
88
+ def add_invalid_keys(key)
89
+ @invalid_keys ||= []
90
+ @invalid_keys << key
91
+ @invalid_keys << cache_hash[key] unless primary_key_index?
92
+ @invalid_keys
93
+ end
94
+
95
+ def delete_invalid_keys
96
+ ArCache::Store.delete_multi(@invalid_keys) if @invalid_keys
97
+ end
98
+
99
+ # This module is based on ActiveRecord::Relation::WhereClause modified
100
+ module Raw
101
+ def where_values_hash
102
+ @where_values_hash ||= equalities(predicates).each_with_object({}) do |node, hash|
103
+ # Don't support Arel::Nodes::NamedFunction.
104
+ # But we don't judge it, because it will raise exception if it is Arel::Nodes::NamedFunction object.
105
+ next if table.name != node.left.relation.name
106
+
107
+ name = node.left.name.to_s
108
+ value = extract_node_value(node.right)
109
+ next if value.respond_to?(:size) && value.size > ArCache::Configuration.index_column_max_size
110
+
111
+ hash[name] = value
112
+ end
113
+ rescue NoMethodError, ActiveModel::RangeError
114
+ @where_values_hash = {}
115
+ end
116
+ alias to_h where_values_hash
117
+
118
+ private def equalities(predicates)
119
+ equalities = []
120
+
121
+ predicates.each do |node|
122
+ if equality_node?(node)
123
+ equalities << node
124
+ elsif node.is_a?(Arel::Nodes::And)
125
+ equalities.concat equalities(node.children)
126
+ end
127
+ end
128
+
129
+ equalities
130
+ end
131
+
132
+ private def equality_node?(node)
133
+ !node.is_a?(String) && node.equality?
134
+ end
135
+
136
+ private def extract_node_value(node)
137
+ value = case node
138
+ when Array
139
+ node.map { |v| extract_node_value(v) }
140
+ when Arel::Nodes::BindParam
141
+ node.value.value_for_database # Maybe raise ActiveModel::RangeError
142
+ when Arel::Nodes::Casted, Arel::Nodes::Quoted
143
+ node.value_for_database # Maybe raise ActiveModel::RangeError
144
+ end
145
+
146
+ value.is_a?(Date) ? value.to_s : value
147
+ end
148
+ end
149
+ include Raw
150
+ end
151
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/migration'
5
+ require 'rails/generators/active_record/migration'
6
+
7
+ module ArCache
8
+ module Generators
9
+ class InstallGenerator < Rails::Generators::Base
10
+ include Rails::Generators::Migration
11
+ include ::ActiveRecord::Generators::Migration
12
+
13
+ source_root File.expand_path('templates', __dir__)
14
+
15
+ def copy_initializer_file
16
+ copy_file 'configuration.rb', 'config/initializers/ar_cache.rb'
17
+
18
+ migration_template 'migrate/create_ar_cache_records.rb', 'db/migrate/create_ar_cache_records.rb'
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ # For more information, please see: https://github.com/OuYangJinTing/ar_cache/README.md
4
+ ArCache.configure do |config|
5
+ # WARNING: The should uncomment only when your database default isolation level is "READ UNCOMMITTED"!
6
+ # config.read_uncommitted = true # defaul false
7
+
8
+ # config.cache_store = ActiveSupport::Cache::Store # default Rails.cache || ActiveSupport::Cache::MemoryStore.new
9
+
10
+ # Cache key automatic expiration time.
11
+ # config.expires_in = Numeric # default 1 week
12
+
13
+ # Serialize and deserialize cached data.
14
+ # config.coder = [YAML|JSON] # default YAML
15
+
16
+ # Support the maximum length of index column value.
17
+ # config.index_column_max_size = Integer # default 64
18
+
19
+ # ArCache switch.
20
+ # config.disabled = Boolean # default false
21
+
22
+ # Whether to support selecct columns query
23
+ # config.select_disabled = Boolean # default true
24
+
25
+ # config.tables_options = {
26
+ # table_name: {
27
+ # disabled: Boolean,
28
+ # select_disabled: Boolean,
29
+ # unique_indexes: Array # eg: [:id, [:name, :statue]], The default is the unique index column of the table.
30
+ # ignored_columns: Array # eg: [:created_at, :updated_at], defaule [].
31
+ # },
32
+ # ...
33
+ # }
34
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateArCacheMonitors < ActiveRecord::Migration[<%= ActiveRecord::VERSION::MAJOR %>.<%= ActiveRecord::VERSION::MINOR %>]
4
+ def change
5
+ create_table :ar_cache_records do |t|
6
+ t.string :table_name, null: false
7
+ t.string :table_md5, null: false, limit: 32, default: '0' * 32
8
+ t.integer :version, null: false, default: 0
9
+ t.integer :lock_version, null: false, default: 0
10
+
11
+ t.timestamps null: false
12
+
13
+ t.index :table_name, unique: true
14
+ end
15
+ end
16
+ end