record-cache 0.1.2 → 0.1.3

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 (52) hide show
  1. checksums.yaml +15 -0
  2. data/lib/record_cache.rb +2 -1
  3. data/lib/record_cache/base.rb +63 -22
  4. data/lib/record_cache/datastore/active_record.rb +5 -3
  5. data/lib/record_cache/datastore/active_record_30.rb +95 -38
  6. data/lib/record_cache/datastore/active_record_31.rb +157 -54
  7. data/lib/record_cache/datastore/active_record_32.rb +444 -0
  8. data/lib/record_cache/dispatcher.rb +47 -47
  9. data/lib/record_cache/multi_read.rb +14 -1
  10. data/lib/record_cache/query.rb +36 -25
  11. data/lib/record_cache/statistics.rb +5 -5
  12. data/lib/record_cache/strategy/base.rb +49 -19
  13. data/lib/record_cache/strategy/full_table_cache.rb +81 -0
  14. data/lib/record_cache/strategy/index_cache.rb +38 -36
  15. data/lib/record_cache/strategy/unique_index_cache.rb +130 -0
  16. data/lib/record_cache/strategy/util.rb +12 -12
  17. data/lib/record_cache/test/resettable_version_store.rb +2 -9
  18. data/lib/record_cache/version.rb +1 -1
  19. data/lib/record_cache/version_store.rb +23 -16
  20. data/spec/db/schema.rb +12 -0
  21. data/spec/db/seeds.rb +10 -0
  22. data/spec/lib/active_record/visitor_spec.rb +22 -0
  23. data/spec/lib/base_spec.rb +21 -0
  24. data/spec/lib/dispatcher_spec.rb +24 -46
  25. data/spec/lib/multi_read_spec.rb +6 -6
  26. data/spec/lib/query_spec.rb +43 -43
  27. data/spec/lib/statistics_spec.rb +28 -28
  28. data/spec/lib/strategy/base_spec.rb +98 -87
  29. data/spec/lib/strategy/full_table_cache_spec.rb +68 -0
  30. data/spec/lib/strategy/index_cache_spec.rb +112 -69
  31. data/spec/lib/strategy/query_cache_spec.rb +83 -0
  32. data/spec/lib/strategy/unique_index_on_id_cache_spec.rb +317 -0
  33. data/spec/lib/strategy/unique_index_on_string_cache_spec.rb +168 -0
  34. data/spec/lib/strategy/util_spec.rb +67 -49
  35. data/spec/lib/version_store_spec.rb +22 -41
  36. data/spec/models/address.rb +9 -0
  37. data/spec/models/apple.rb +1 -1
  38. data/spec/models/banana.rb +21 -2
  39. data/spec/models/language.rb +5 -0
  40. data/spec/models/person.rb +1 -1
  41. data/spec/models/store.rb +2 -1
  42. data/spec/spec_helper.rb +7 -4
  43. data/spec/support/after_commit.rb +2 -0
  44. data/spec/support/matchers/hit_cache_matcher.rb +10 -6
  45. data/spec/support/matchers/log.rb +45 -0
  46. data/spec/support/matchers/miss_cache_matcher.rb +10 -6
  47. data/spec/support/matchers/use_cache_matcher.rb +10 -6
  48. metadata +156 -161
  49. data/lib/record_cache/strategy/id_cache.rb +0 -93
  50. data/lib/record_cache/strategy/request_cache.rb +0 -49
  51. data/spec/lib/strategy/id_cache_spec.rb +0 -168
  52. data/spec/lib/strategy/request_cache_spec.rb +0 -85
@@ -0,0 +1,15 @@
1
+ ---
2
+ !binary "U0hBMQ==":
3
+ metadata.gz: !binary |-
4
+ NjcyYzNmZjBjM2VjZGU4MzMzMGI1YjBkNDdiNmQ5NWZiODNiZjE3OA==
5
+ data.tar.gz: !binary |-
6
+ MzU0ZDM3ZjE1YzhmNTY2MTM3M2MwZmU4ZjZhZTI5ZGQ1NWMwNTMwZg==
7
+ SHA512:
8
+ metadata.gz: !binary |-
9
+ NDE4MTQxYjA2ZDQ2ZTE1ZWVlOTk1MjY5OGU3ZDJjNThjZGQ4MjIxZTE1MWQy
10
+ MjMyZmViZmUzZWY2MGU5NDUzOWNhOWFiYWY0MTlmYTAzMTJlOTVjZTA2N2Yz
11
+ ODgyNDFiMWY5YzU1MzRhYzNjZWM5M2UyMjU1OGFmNzJlMjliMGI=
12
+ data.tar.gz: !binary |-
13
+ NDBiNGYyZjIwNDlkMzg5MDU0YjhhNjBjNThiMDhjMzk1YzE1YjgxODUwYTJj
14
+ ZTk5YzAzNzY1ZmU4MWI2ZjYyYTU1MjQ4NjNkOTEwZmIxMWMzNzAyYWY5NGJj
15
+ YTc2ZDVlN2UyOTZhMzBhY2NjMmUwMWQ0NDI5MjliYjY5MGZjM2Y=
@@ -1,6 +1,7 @@
1
1
  # Record Cache files
2
+ require "record_cache/version"
2
3
  ["query", "version_store", "multi_read",
3
- "strategy/util", "strategy/base", "strategy/id_cache", "strategy/index_cache", "strategy/request_cache",
4
+ "strategy/util", "strategy/base", "strategy/unique_index_cache", "strategy/full_table_cache", "strategy/index_cache",
4
5
  "statistics", "dispatcher", "base"].each do |file|
5
6
  require File.dirname(__FILE__) + "/record_cache/#{file}.rb"
6
7
  end
@@ -17,7 +17,16 @@ module RecordCache
17
17
 
18
18
  # The logger instance (Rails.logger if present)
19
19
  def logger
20
- @logger ||= defined?(::Rails) ? ::Rails.logger : ::ActiveRecord::Base.logger
20
+ @logger ||= (rails_logger || ::ActiveRecord::Base.logger)
21
+ end
22
+
23
+ # Provide a different logger for Record Cache related information
24
+ def logger=(logger)
25
+ @logger = logger
26
+ end
27
+
28
+ def rails_logger
29
+ defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
21
30
  end
22
31
 
23
32
  # Set the ActiveSupport::Cache::Store instance that contains the current record(group) versions.
@@ -29,16 +38,17 @@ module RecordCache
29
38
  # The ActiveSupport::Cache::Store instance that contains the current record(group) versions.
30
39
  # Note that it must point to a single Store shared by all webservers (defaults to Rails.cache)
31
40
  def version_store
32
- @version_store ||= RecordCache::VersionStore.new(RecordCache::MultiRead.test(Rails.cache))
41
+ self.version_store = Rails.cache unless @version_store
42
+ @version_store
33
43
  end
34
-
35
- # Register a store with a specific id for reference with :store in +cache_records+
44
+
45
+ # Register a cache store by id for future reference with the :store option for +cache_records+
36
46
  # e.g. RecordCache::Base.register_store(:server, ActiveSupport::Cache.lookup_store(:memory_store))
37
47
  def register_store(id, store)
38
48
  stores[id] = RecordCache::MultiRead.test(store)
39
49
  end
40
50
 
41
- # The hash of stores (store_id => store)
51
+ # The hash of registered record stores (store_id => store)
42
52
  def stores
43
53
  @stores ||= {}
44
54
  end
@@ -56,6 +66,27 @@ module RecordCache
56
66
  @status = RecordCache::ENABLED
57
67
  end
58
68
 
69
+ # Executes the block with caching enabled.
70
+ # Useful in testing scenarios.
71
+ #
72
+ # RecordCache::Base.enabled do
73
+ # @foo = Article.find(1)
74
+ # @foo.update_attributes(:time_spent => 45)
75
+ # @foo = Article.find(1)
76
+ # @foo.time_spent.should be_nil
77
+ # TimeSpent.last.amount.should == 45
78
+ # end
79
+ #
80
+ def enabled(&block)
81
+ previous_status = @status
82
+ begin
83
+ @status = RecordCache::ENABLED
84
+ yield
85
+ ensure
86
+ @status = previous_status
87
+ end
88
+ end
89
+
59
90
  # Retrieve the current status
60
91
  def status
61
92
  @status ||= RecordCache::ENABLED
@@ -78,57 +109,67 @@ module RecordCache
78
109
 
79
110
  module ClassMethods
80
111
  # Cache the instances of this model
81
- # options:
112
+ # generic options:
82
113
  # :store => the cache store for the instances, e.g. :memory_store, :dalli_store* (default: Rails.cache)
83
114
  # or one of the store ids defined using +RecordCache::Base.register_store+
84
115
  # :key => provide a unique shorter key to limit the cache key length (default: model.name)
116
+ #
117
+ # cache strategy specific options:
85
118
  # :index => one or more attributes (Symbols) for which the ids are cached for the value of the attribute
86
- # :request_cache => Set to true in case the exact same query is executed more than once during a single request
87
- # If set to true somewhere, make sure to add the following to your application controller:
88
- # before_filter { |c| RecordCache::Strategy::RequestCache.clear }
89
119
  #
90
120
  # Hints:
91
121
  # - Dalli is a high performance pure Ruby client for accessing memcached servers, see https://github.com/mperham/dalli
92
122
  # - use :store => :memory_store in case all records can easily fit in server memory
93
123
  # - use :index => :account_id in case the records are (almost) always queried as a full set per account
94
124
  # - use :index => :person_id for aggregated has_many associations
95
- def cache_records(options)
96
- @rc_dispatcher = RecordCache::Dispatcher.new(self) unless defined?(@rc_dispatcher)
97
- store = RecordCache::MultiRead.test(options[:store] ? RecordCache::Base.stores[options[:store]] || ActiveSupport::Cache.lookup_store(options[:store]) : (defined?(::Rails) ? Rails.cache : ActiveSupport::Cache.lookup_store(:memory_store)))
98
- # always register an ID Cache
99
- record_cache.register(:id, ::RecordCache::Strategy::IdCache, store, options)
100
- # parse :index option
101
- [options[:index]].flatten.compact.map(&:to_sym).each do |index|
102
- record_cache.register(index, ::RecordCache::Strategy::IndexCache, store, options.merge({:index => index}))
125
+ def cache_records(options = {})
126
+ unless @rc_dispatcher
127
+ @rc_dispatcher = RecordCache::Dispatcher.new(self)
128
+ # Callback for Data Store specific initialization
129
+ record_cache_init
130
+
131
+ class << self
132
+ alias_method_chain :inherited, :record_cache
133
+ end
103
134
  end
104
- # parse :request_cache option
105
- record_cache.register(:request_cache, ::RecordCache::Strategy::RequestCache, store, options) if options[:request_cache]
106
- # Callback for Data Store specific initialization
107
- record_cache_init
135
+ # parse the requested strategies from the given options
136
+ @rc_dispatcher.parse(options)
108
137
  end
109
138
 
110
139
  # Returns true if record cache is defined and active for this class
111
140
  def record_cache?
112
- record_cache && RecordCache::Base.status == RecordCache::ENABLED
141
+ record_cache && record_cache.instance_variable_get('@base') == self && RecordCache::Base.status == RecordCache::ENABLED
113
142
  end
114
143
 
115
144
  # Returns the RecordCache (class) instance
116
145
  def record_cache
117
146
  @rc_dispatcher
118
147
  end
148
+
149
+ def inherited_with_record_cache(subclass)
150
+ class << subclass
151
+ def record_cache
152
+ self.superclass.record_cache
153
+ end
154
+ end
155
+ inherited_without_record_cache(subclass)
156
+ end
119
157
  end
120
158
 
121
159
  module InstanceMethods
122
160
  def record_cache_create
123
161
  self.class.record_cache.record_change(self, :create) unless RecordCache::Base.status == RecordCache::DISABLED
162
+ true
124
163
  end
125
164
 
126
165
  def record_cache_update
127
166
  self.class.record_cache.record_change(self, :update) unless RecordCache::Base.status == RecordCache::DISABLED
167
+ true
128
168
  end
129
169
 
130
170
  def record_cache_destroy
131
171
  self.class.record_cache.record_change(self, :destroy) unless RecordCache::Base.status == RecordCache::DISABLED
172
+ true
132
173
  end
133
174
  end
134
175
 
@@ -4,8 +4,10 @@ require 'active_record'
4
4
  ActiveRecord::Base.send(:include, RecordCache::Base)
5
5
 
6
6
  # To be able to fetch records from the cache and invalidate records in the cache
7
- # some internal Active Record methods needs to be aliased.
7
+ # some internal Active Record methods need to be aliased.
8
8
  # The downside of using internal methods, is that they may change in different releases,
9
9
  # hence the following code:
10
- AR_VERSION = ActiveRecord::VERSION::MAJOR == 3 && ActiveRecord::VERSION::MINOR == 0 ? 30 : 31
11
- require File.dirname(__FILE__) + "/active_record_#{AR_VERSION}.rb"
10
+ AR_VERSION = "#{ActiveRecord::VERSION::MAJOR}#{ActiveRecord::VERSION::MINOR}"
11
+ filename = "#{File.dirname(__FILE__)}/active_record_#{AR_VERSION}.rb"
12
+ abort("No support for Active Record version #{AR_VERSION}") unless File.exists?(filename)
13
+ require filename
@@ -10,38 +10,49 @@ module RecordCache
10
10
  alias_method_chain :find_by_sql, :record_cache
11
11
  end
12
12
  end
13
- include InstanceMethods
14
13
  end
15
14
  end
16
15
 
17
16
  module ClassMethods
17
+ # the tests are always run within a transaction, so the threshold is one higher
18
+ RC_TRANSACTIONS_THRESHOLD = ENV['RAILS_ENV'] == 'test' ? 1 : 0
18
19
 
19
20
  # add cache invalidation hooks on initialization
20
21
  def record_cache_init
21
- after_commit :record_cache_create, :on => :create
22
- after_commit :record_cache_update, :on => :update
23
- after_commit :record_cache_destroy, :on => :destroy
22
+ after_commit :record_cache_create, :on => :create, :prepend => true
23
+ after_commit :record_cache_update, :on => :update, :prepend => true
24
+ after_commit :record_cache_destroy, :on => :destroy, :prepend => true
24
25
  end
25
-
26
+
26
27
  # Retrieve the records, possibly from cache
27
- def find_by_sql_with_record_cache(*args)
28
- # no caching please
29
- return find_by_sql_without_record_cache(*args) unless record_cache?
28
+ def find_by_sql_with_record_cache(sql)
29
+ # shortcut, no caching please
30
+ return find_by_sql_without_record_cache(sql) unless record_cache?
30
31
 
31
- # check the piggy-back'd ActiveRelation record to see if the query can be retrieved from cache
32
- sql = args[0]
33
32
  arel = sql.instance_variable_get(:@arel)
34
- query = arel ? RecordCache::Arel::QueryVisitor.new.accept(arel.ast) : nil
35
- cacheable = query && record_cache.cacheable?(query)
36
- # log only in debug mode!
37
- RecordCache::Base.logger.debug("#{cacheable ? 'Fetch from cache' : 'Not cacheable'} (#{query}): SQL = #{sql}") if RecordCache::Base.logger.debug?
38
- # retrieve the records from cache if the query is cacheable otherwise go straight to the DB
39
- cacheable ? record_cache.fetch(query) : find_by_sql_without_record_cache(*args)
33
+ sanitized_sql = sanitize_sql(sql)
34
+
35
+ records = if connection.instance_variable_get(:@query_cache_enabled)
36
+ query_cache = connection.instance_variable_get(:@query_cache)
37
+ query_cache["rc/#{sanitized_sql}"] ||= try_record_cache(sanitized_sql, arel)
38
+ elsif connection.open_transactions > RC_TRANSACTIONS_THRESHOLD
39
+ connection.send(:select, sanitized_sql, "#{name} Load")
40
+ else
41
+ try_record_cache(sanitized_sql, arel)
42
+ end
43
+ records.collect! { |record| instantiate(record) } if records[0].is_a?(Hash)
44
+ records
45
+ end
46
+
47
+ def try_record_cache(sql, arel)
48
+ query = arel && arel.respond_to?(:ast) ? RecordCache::Arel::QueryVisitor.new.accept(arel.ast) : nil
49
+ record_cache.fetch(query) do
50
+ connection.send(:select, sql, "#{name} Load")
51
+ end
40
52
  end
41
- end
42
53
 
43
- module InstanceMethods
44
54
  end
55
+
45
56
  end
46
57
  end
47
58
 
@@ -62,7 +73,7 @@ module RecordCache
62
73
 
63
74
  module ClassMethods
64
75
  end
65
-
76
+
66
77
  module InstanceMethods
67
78
  def to_sql_with_record_cache
68
79
  sql = to_sql_without_record_cache
@@ -71,20 +82,23 @@ module RecordCache
71
82
  end
72
83
  end
73
84
  end
74
-
85
+
75
86
  # Visitor for the ActiveRelation to extract a simple cache query
76
87
  # Only accepts single select queries with equality where statements
77
88
  # Rejects queries with grouping / having / offset / etc.
78
89
  class QueryVisitor < ::Arel::Visitors::Visitor
90
+ DESC = "DESC".freeze
91
+ COMMA = ",".freeze
92
+
79
93
  def initialize
80
94
  super()
81
95
  @cacheable = true
82
96
  @query = ::RecordCache::Query.new
83
97
  end
84
98
 
85
- def accept object
99
+ def accept ast
86
100
  super
87
- @cacheable ? @query : nil
101
+ @cacheable && !ast.lock ? @query : nil
88
102
  end
89
103
 
90
104
  private
@@ -93,12 +107,16 @@ module RecordCache
93
107
  @cacheable = false
94
108
  end
95
109
 
96
- alias :visit_Arel_Nodes_Ordering :not_cacheable
97
-
110
+ def skip o
111
+ end
112
+
98
113
  alias :visit_Arel_Nodes_TableAlias :not_cacheable
99
114
 
115
+ alias :visit_Arel_Nodes_Lock :not_cacheable
116
+
100
117
  alias :visit_Arel_Nodes_Sum :not_cacheable
101
118
  alias :visit_Arel_Nodes_Max :not_cacheable
119
+ alias :visit_Arel_Nodes_Min :not_cacheable
102
120
  alias :visit_Arel_Nodes_Avg :not_cacheable
103
121
  alias :visit_Arel_Nodes_Count :not_cacheable
104
122
 
@@ -110,6 +128,13 @@ module RecordCache
110
128
  alias :visit_Arel_Nodes_InsertStatement :not_cacheable
111
129
  alias :visit_Arel_Nodes_UpdateStatement :not_cacheable
112
130
 
131
+ alias :visit_Arel_Nodes_Except :not_cacheable
132
+ alias :visit_Arel_Nodes_Exists :not_cacheable
133
+ alias :visit_Arel_Nodes_Intersect :not_cacheable
134
+ alias :visit_Arel_Nodes_Union :not_cacheable
135
+ alias :visit_Arel_Nodes_UnionAll :not_cacheable
136
+
137
+ alias :visit_Arel_Nodes_As :skip
113
138
 
114
139
  alias :unary :not_cacheable
115
140
  alias :visit_Arel_Nodes_Group :unary
@@ -131,14 +156,14 @@ module RecordCache
131
156
  end
132
157
  alias :visit_Arel_Nodes_Top :visit_Arel_Nodes_Limit
133
158
 
159
+ GROUPING_EQUALS_REGEXP = /^\W?(\w*)\W?\.\W?(\w*)\W?\s*=\s*(\d+)$/ # `calendars`.account_id = 5
160
+ GROUPING_IN_REGEXP = /^^\W?(\w*)\W?\.\W?(\w*)\W?\s*IN\s*\(([\d\s,]+)\)$/ # `service_instances`.`id` IN (118,80,120,82)
134
161
  def visit_Arel_Nodes_Grouping o
135
162
  return unless @cacheable
136
- # "`calendars`.account_id = 5"
137
- if @table_name && o.expr =~ /^`#{@table_name}`\.`?(\w*)`?\s*=\s*(\d+)$/
138
- @cacheable = @query.where($1, $2.to_i)
139
- # "`service_instances`.`id` IN (118,80,120,82)"
140
- elsif o.expr =~ /^`#{@table_name}`\.`?(\w*)`?\s*IN\s*\(([\d\s,]+)\)$/
141
- @cacheable = @query.where($1, $2.split(',').map(&:to_i))
163
+ if @table_name && o.expr =~ GROUPING_EQUALS_REGEXP && $1 == @table_name
164
+ @cacheable = @query.where($2, $3.to_i)
165
+ elsif @table_name && o.expr =~ GROUPING_IN_REGEXP && $1 == @table_name
166
+ @cacheable = @query.where($2, $3.split(',').map(&:to_i))
142
167
  else
143
168
  @cacheable = false
144
169
  end
@@ -160,12 +185,13 @@ module RecordCache
160
185
  visit o.cores
161
186
  end
162
187
  end
163
-
188
+
189
+ ORDER_BY_REGEXP = /^\s*([\w\.]*)\s*(|ASC|asc|DESC|desc)\s*$/ # people.id DESC
164
190
  def handle_order_by(order)
165
- order.to_s.split(",").each do |o|
166
- # simple sort order (+peope.id+ can be replaced by +id+, as joins are not allowed anyways)
167
- if o.match(/^\s*([\w\.]*)\s*(|ASC|DESC|)\s*$/)
168
- asc = $2 == "DESC" ? false : true
191
+ order.to_s.split(COMMA).each do |o|
192
+ # simple sort order (+people.id+ can be replaced by +id+, as joins are not allowed anyways)
193
+ if o.match(ORDER_BY_REGEXP)
194
+ asc = $2.upcase == DESC ? false : true
169
195
  @query.order_by($1.split('.').last, asc)
170
196
  else
171
197
  @cacheable = false
@@ -189,6 +215,7 @@ module RecordCache
189
215
  alias :visit_Arel_Attributes_String :visit_Arel_Attributes_Attribute
190
216
  alias :visit_Arel_Attributes_Time :visit_Arel_Attributes_Attribute
191
217
  alias :visit_Arel_Attributes_Boolean :visit_Arel_Attributes_Attribute
218
+ alias :visit_Arel_Attributes_Decimal :visit_Arel_Attributes_Attribute
192
219
 
193
220
  def visit_Arel_Nodes_Equality o
194
221
  key, value = visit(o.left), visit(o.right)
@@ -249,7 +276,7 @@ module RecordCache
249
276
  end
250
277
 
251
278
  module RecordCache
252
-
279
+
253
280
  # Patch ActiveRecord::Relation to make sure update_all will invalidate all referenced records
254
281
  module ActiveRecord
255
282
  module UpdateAll
@@ -262,19 +289,49 @@ module RecordCache
262
289
  end
263
290
  end
264
291
  end
265
-
292
+
266
293
  module ClassMethods
267
294
  end
268
295
 
269
296
  module InstanceMethods
297
+ def __find_in_clause(sub_select)
298
+ return nil unless sub_select.arel.constraints.count == 1
299
+ constraint = sub_select.arel.constraints.first
300
+ return constraint if constraint.is_a?(::Arel::Nodes::In) # directly an IN clause
301
+ return nil unless constraint.respond_to?(:children) && constraint.children.count == 1
302
+ constraint = constraint.children.first
303
+ return constraint if constraint.is_a?(::Arel::Nodes::In) # AND with IN clause
304
+ nil
305
+ end
306
+
270
307
  def update_all_with_record_cache(updates, conditions = nil, options = {})
271
308
  result = update_all_without_record_cache(updates, conditions, options)
272
309
 
273
310
  if record_cache?
274
311
  # when this condition is met, the arel.update method will be called on the current scope, see ActiveRecord::Relation#update_all
275
312
  unless conditions || options.present? || @limit_value.present? != @order_values.present?
313
+ # get all attributes that contain a unique index for this model
314
+ unique_index_attributes = RecordCache::Strategy::UniqueIndexCache.attributes(self)
276
315
  # go straight to SQL result (without instantiating records) for optimal performance
277
- connection.execute(select('id').to_sql).each{ |row| record_cache.invalidate(:id, (row.is_a?(Hash) ? row['id'] : row.first).to_i ) }
316
+ RecordCache::Base.version_store.multi do
317
+ sub_select = select(unique_index_attributes.map(&:to_s).join(','))
318
+ in_clause = __find_in_clause(sub_select)
319
+ if unique_index_attributes.size == 1 && in_clause &&
320
+ in_clause.left.try(:name).to_s == unique_index_attributes.first.to_s
321
+ # common case where the unique index is the (only) constraint on the query: SELECT id FROM people WHERE id in (...)
322
+ attribute = unique_index_attributes.first
323
+ in_clause.right.each do |value|
324
+ record_cache.invalidate(attribute, value)
325
+ end
326
+ else
327
+ connection.execute(sub_select.to_sql).each do |row|
328
+ # invalidate the unique index for all attributes
329
+ unique_index_attributes.each_with_index do |attribute, index|
330
+ record_cache.invalidate(attribute, (row.is_a?(Hash) ? row[attribute.to_s] : row[index]))
331
+ end
332
+ end
333
+ end
334
+ end
278
335
  end
279
336
  end
280
337
 
@@ -10,42 +10,52 @@ module RecordCache
10
10
  alias_method_chain :find_by_sql, :record_cache
11
11
  end
12
12
  end
13
- include InstanceMethods
14
13
  end
15
14
  end
16
15
 
17
16
  module ClassMethods
17
+ # the tests are always run within a transaction, so the threshold is one higher
18
+ RC_TRANSACTIONS_THRESHOLD = ENV['RAILS_ENV'] == 'test' ? 1 : 0
18
19
 
19
20
  # add cache invalidation hooks on initialization
20
21
  def record_cache_init
21
- after_commit :record_cache_create, :on => :create
22
- after_commit :record_cache_update, :on => :update
23
- after_commit :record_cache_destroy, :on => :destroy
22
+ after_commit :record_cache_create, :on => :create, :prepend => true
23
+ after_commit :record_cache_update, :on => :update, :prepend => true
24
+ after_commit :record_cache_destroy, :on => :destroy, :prepend => true
24
25
  end
25
-
26
+
26
27
  # Retrieve the records, possibly from cache
27
- def find_by_sql_with_record_cache(*args)
28
- # no caching please
29
- return find_by_sql_without_record_cache(*args) unless record_cache?
30
-
28
+ def find_by_sql_with_record_cache(sql, binds = [])
29
+ # shortcut, no caching please
30
+ return find_by_sql_without_record_cache(sql, binds) unless record_cache?
31
+
31
32
  # check the piggy-back'd ActiveRelation record to see if the query can be retrieved from cache
32
- arel = args[0]
33
- if arel.is_a?(String)
34
- arel = arel.instance_variable_get(:@arel)
35
- puts "TODO: RECORD_CACHE !!!!!!!! Found String with arel piggyback: #{arel}"
36
- end
33
+ arel = sql.is_a?(String) ? sql.instance_variable_get(:@arel) : sql
34
+
35
+ sanitized_sql = sanitize_sql(sql)
36
+ sanitized_sql = connection.visitor.accept(sanitized_sql.ast) if sanitized_sql.respond_to?(:ast)
37
+
38
+ records = if connection.instance_variable_get(:@query_cache_enabled)
39
+ query_cache = connection.instance_variable_get(:@query_cache)
40
+ query_cache["rc/#{sanitized_sql}"][binds] ||= try_record_cache(arel, sanitized_sql, binds)
41
+ elsif connection.open_transactions > RC_TRANSACTIONS_THRESHOLD
42
+ connection.send(:select, sanitized_sql, "#{name} Load", binds)
43
+ else
44
+ try_record_cache(arel, sanitized_sql, binds)
45
+ end
46
+ records.collect! { |record| instantiate(record) } if records[0].is_a?(Hash)
47
+ records
48
+ end
37
49
 
38
- query = arel ? RecordCache::Arel::QueryVisitor.new(args[1]).accept(arel.ast) : nil
39
- cacheable = query && record_cache.cacheable?(query)
40
- # log only in debug mode!
41
- RecordCache::Base.logger.debug("#{cacheable ? 'Fetch from cache' : 'Not cacheable'} (#{query}): SQL = #{arel.to_sql}") if RecordCache::Base.logger.debug?
42
- # retrieve the records from cache if the query is cacheable otherwise go straight to the DB
43
- cacheable ? record_cache.fetch(query) : find_by_sql_without_record_cache(*args)
50
+ def try_record_cache(arel, sql, binds)
51
+ query = arel && arel.respond_to?(:ast) ? RecordCache::Arel::QueryVisitor.new(binds).accept(arel.ast) : nil
52
+ record_cache.fetch(query) do
53
+ connection.send(:select, sql, "#{name} Load", binds)
54
+ end
44
55
  end
45
- end
46
56
 
47
- module InstanceMethods
48
57
  end
58
+
49
59
  end
50
60
  end
51
61
 
@@ -66,7 +76,7 @@ module RecordCache
66
76
 
67
77
  module ClassMethods
68
78
  end
69
-
79
+
70
80
  module InstanceMethods
71
81
  def to_sql_with_record_cache
72
82
  sql = to_sql_without_record_cache
@@ -75,11 +85,14 @@ module RecordCache
75
85
  end
76
86
  end
77
87
  end
78
-
88
+
79
89
  # Visitor for the ActiveRelation to extract a simple cache query
80
90
  # Only accepts single select queries with equality where statements
81
91
  # Rejects queries with grouping / having / offset / etc.
82
92
  class QueryVisitor < ::Arel::Visitors::Visitor
93
+ DESC = "DESC".freeze
94
+ COMMA = ",".freeze
95
+
83
96
  def initialize(bindings)
84
97
  super()
85
98
  @bindings = (bindings || []).inject({}){ |h, cv| column, value = cv; h[column.name] = value; h}
@@ -87,9 +100,9 @@ module RecordCache
87
100
  @query = ::RecordCache::Query.new
88
101
  end
89
102
 
90
- def accept object
103
+ def accept ast
91
104
  super
92
- @cacheable ? @query : nil
105
+ @cacheable && !ast.lock ? @query : nil
93
106
  end
94
107
 
95
108
  private
@@ -98,14 +111,46 @@ module RecordCache
98
111
  @cacheable = false
99
112
  end
100
113
 
101
- alias :visit_Arel_Nodes_Ordering :not_cacheable
102
-
114
+ def skip o
115
+ end
116
+
103
117
  alias :visit_Arel_Nodes_TableAlias :not_cacheable
104
118
 
105
- alias :visit_Arel_Nodes_Sum :not_cacheable
106
- alias :visit_Arel_Nodes_Max :not_cacheable
107
- alias :visit_Arel_Nodes_Avg :not_cacheable
108
- alias :visit_Arel_Nodes_Count :not_cacheable
119
+ alias :visit_Arel_Nodes_Lock :not_cacheable
120
+
121
+ alias :visit_Arel_Nodes_Sum :not_cacheable
122
+ alias :visit_Arel_Nodes_Max :not_cacheable
123
+ alias :visit_Arel_Nodes_Min :not_cacheable
124
+ alias :visit_Arel_Nodes_Avg :not_cacheable
125
+ alias :visit_Arel_Nodes_Count :not_cacheable
126
+ alias :visit_Arel_Nodes_Addition :not_cacheable
127
+ alias :visit_Arel_Nodes_Subtraction :not_cacheable
128
+ alias :visit_Arel_Nodes_Multiplication :not_cacheable
129
+ alias :visit_Arel_Nodes_NamedFunction :not_cacheable
130
+
131
+ alias :visit_Arel_Nodes_Bin :not_cacheable
132
+ alias :visit_Arel_Nodes_Distinct :not_cacheable
133
+ alias :visit_Arel_Nodes_DistinctOn :not_cacheable
134
+ alias :visit_Arel_Nodes_Division :not_cacheable
135
+ alias :visit_Arel_Nodes_Except :not_cacheable
136
+ alias :visit_Arel_Nodes_Exists :not_cacheable
137
+ alias :visit_Arel_Nodes_InfixOperation :not_cacheable
138
+ alias :visit_Arel_Nodes_Intersect :not_cacheable
139
+ alias :visit_Arel_Nodes_Union :not_cacheable
140
+ alias :visit_Arel_Nodes_UnionAll :not_cacheable
141
+ alias :visit_Arel_Nodes_With :not_cacheable
142
+ alias :visit_Arel_Nodes_WithRecursive :not_cacheable
143
+
144
+ def visit_Arel_Nodes_JoinSource o
145
+ # left and right are array, but using blank as it also works for nil
146
+ @cacheable = o.left.blank? || o.right.blank?
147
+ end
148
+
149
+ alias :visit_Arel_Nodes_As :skip
150
+ alias :visit_Arel_Nodes_Ascending :skip
151
+ alias :visit_Arel_Nodes_Descending :skip
152
+ alias :visit_Arel_Nodes_False :skip
153
+ alias :visit_Arel_Nodes_True :skip
109
154
 
110
155
  alias :visit_Arel_Nodes_StringJoin :not_cacheable
111
156
  alias :visit_Arel_Nodes_InnerJoin :not_cacheable
@@ -136,14 +181,14 @@ module RecordCache
136
181
  end
137
182
  alias :visit_Arel_Nodes_Top :visit_Arel_Nodes_Limit
138
183
 
184
+ GROUPING_EQUALS_REGEXP = /^\W?(\w*)\W?\.\W?(\w*)\W?\s*=\s*(\d+)$/ # `calendars`.account_id = 5
185
+ GROUPING_IN_REGEXP = /^^\W?(\w*)\W?\.\W?(\w*)\W?\s*IN\s*\(([\d\s,]+)\)$/ # `service_instances`.`id` IN (118,80,120,82)
139
186
  def visit_Arel_Nodes_Grouping o
140
187
  return unless @cacheable
141
- # "`calendars`.account_id = 5"
142
- if @table_name && o.expr =~ /^`#{@table_name}`\.`?(\w*)`?\s*=\s*(\d+)$/
143
- @cacheable = @query.where($1, $2.to_i)
144
- # "`service_instances`.`id` IN (118,80,120,82)"
145
- elsif o.expr =~ /^`#{@table_name}`\.`?(\w*)`?\s*IN\s*\(([\d\s,]+)\)$/
146
- @cacheable = @query.where($1, $2.split(',').map(&:to_i))
188
+ if @table_name && o.expr =~ GROUPING_EQUALS_REGEXP && $1 == @table_name
189
+ @cacheable = @query.where($2, $3.to_i)
190
+ elsif @table_name && o.expr =~ GROUPING_IN_REGEXP && $1 == @table_name
191
+ @cacheable = @query.where($2, $3.split(',').map(&:to_i))
147
192
  else
148
193
  @cacheable = false
149
194
  end
@@ -153,6 +198,7 @@ module RecordCache
153
198
  @cacheable = false unless o.groups.empty?
154
199
  visit o.froms if @cacheable
155
200
  visit o.wheres if @cacheable
201
+ visit o.source if @cacheable
156
202
  # skip o.projections
157
203
  end
158
204
 
@@ -165,12 +211,13 @@ module RecordCache
165
211
  visit o.cores
166
212
  end
167
213
  end
168
-
214
+
215
+ ORDER_BY_REGEXP = /^\s*([\w\.]*)\s*(|ASC|asc|DESC|desc)\s*$/ # people.id DESC
169
216
  def handle_order_by(order)
170
- order.to_s.split(",").each do |o|
171
- # simple sort order (+peope.id+ can be replaced by +id+, as joins are not allowed anyways)
172
- if o.match(/^\s*([\w\.]*)\s*(|ASC|DESC|)\s*$/)
173
- asc = $2 == "DESC" ? false : true
217
+ order.to_s.split(COMMA).each do |o|
218
+ # simple sort order (+people.id+ can be replaced by +id+, as joins are not allowed anyways)
219
+ if o.match(ORDER_BY_REGEXP)
220
+ asc = $2.upcase == DESC ? false : true
174
221
  @query.order_by($1.split('.').last, asc)
175
222
  else
176
223
  @cacheable = false
@@ -182,10 +229,6 @@ module RecordCache
182
229
  @table_name = o.name
183
230
  end
184
231
 
185
- def visit_Arel_Nodes_Ordering o
186
- [visit(o.expr), o.descending]
187
- end
188
-
189
232
  def visit_Arel_Attributes_Attribute o
190
233
  o.name.to_sym
191
234
  end
@@ -194,12 +237,14 @@ module RecordCache
194
237
  alias :visit_Arel_Attributes_String :visit_Arel_Attributes_Attribute
195
238
  alias :visit_Arel_Attributes_Time :visit_Arel_Attributes_Attribute
196
239
  alias :visit_Arel_Attributes_Boolean :visit_Arel_Attributes_Attribute
240
+ alias :visit_Arel_Attributes_Decimal :visit_Arel_Attributes_Attribute
197
241
 
198
242
  def visit_Arel_Nodes_Equality o
199
243
  key, value = visit(o.left), visit(o.right)
200
- if value.to_s == "?"
244
+ # several different binding markers exist depending on the db driver used (MySQL, Postgress supported)
245
+ if value.to_s =~ /^(\?|\u0000|\$\d+)$/
201
246
  # puts "bindings: #{@bindings.inspect}, key = #{key.to_s}"
202
- value = @bindings[key.to_s] || "?"
247
+ value = @bindings[key.to_s] || value
203
248
  end
204
249
  # puts " =====> equality found: #{key.inspect}@#{key.class.name} => #{value.inspect}@#{value.class.name}"
205
250
  @query.where(key, value)
@@ -207,8 +252,7 @@ module RecordCache
207
252
  alias :visit_Arel_Nodes_In :visit_Arel_Nodes_Equality
208
253
 
209
254
  def visit_Arel_Nodes_And o
210
- visit(o.left)
211
- visit(o.right)
255
+ visit(o.children)
212
256
  end
213
257
 
214
258
  alias :visit_Arel_Nodes_Or :not_cacheable
@@ -236,6 +280,7 @@ module RecordCache
236
280
  o
237
281
  end
238
282
  alias :visit_Arel_Nodes_SqlLiteral :visit_Object
283
+ alias :visit_Arel_Nodes_BindParam :visit_Object
239
284
  alias :visit_Arel_SqlLiteral :visit_Object # This is deprecated
240
285
  alias :visit_String :visit_Object
241
286
  alias :visit_NilClass :visit_Object
@@ -258,7 +303,7 @@ module RecordCache
258
303
  end
259
304
 
260
305
  module RecordCache
261
-
306
+
262
307
  # Patch ActiveRecord::Relation to make sure update_all will invalidate all referenced records
263
308
  module ActiveRecord
264
309
  module UpdateAll
@@ -271,19 +316,49 @@ module RecordCache
271
316
  end
272
317
  end
273
318
  end
274
-
319
+
275
320
  module ClassMethods
276
321
  end
277
322
 
278
323
  module InstanceMethods
324
+ def __find_in_clause(sub_select)
325
+ return nil unless sub_select.arel.constraints.count == 1
326
+ constraint = sub_select.arel.constraints.first
327
+ return constraint if constraint.is_a?(::Arel::Nodes::In) # directly an IN clause
328
+ return nil unless constraint.respond_to?(:children) && constraint.children.count == 1
329
+ constraint = constraint.children.first
330
+ return constraint if constraint.is_a?(::Arel::Nodes::In) # AND with IN clause
331
+ nil
332
+ end
333
+
279
334
  def update_all_with_record_cache(updates, conditions = nil, options = {})
280
335
  result = update_all_without_record_cache(updates, conditions, options)
281
336
 
282
337
  if record_cache?
283
338
  # when this condition is met, the arel.update method will be called on the current scope, see ActiveRecord::Relation#update_all
284
339
  unless conditions || options.present? || @limit_value.present? != @order_values.present?
340
+ # get all attributes that contain a unique index for this model
341
+ unique_index_attributes = RecordCache::Strategy::UniqueIndexCache.attributes(self)
285
342
  # go straight to SQL result (without instantiating records) for optimal performance
286
- connection.execute(select('id').to_sql).each{ |row| record_cache.invalidate(:id, (row.is_a?(Hash) ? row['id'] : row.first).to_i ) }
343
+ RecordCache::Base.version_store.multi do
344
+ sub_select = select(unique_index_attributes.map(&:to_s).join(','))
345
+ in_clause = __find_in_clause(sub_select)
346
+ if unique_index_attributes.size == 1 && in_clause &&
347
+ in_clause.left.try(:name).to_s == unique_index_attributes.first.to_s
348
+ # common case where the unique index is the (only) constraint on the query: SELECT id FROM people WHERE id in (...)
349
+ attribute = unique_index_attributes.first
350
+ in_clause.right.each do |value|
351
+ record_cache.invalidate(attribute, value)
352
+ end
353
+ else
354
+ connection.execute(sub_select.to_sql).each do |row|
355
+ # invalidate the unique index for all attributes
356
+ unique_index_attributes.each_with_index do |attribute, index|
357
+ record_cache.invalidate(attribute, (row.is_a?(Hash) ? row[attribute.to_s] : row[index]))
358
+ end
359
+ end
360
+ end
361
+ end
287
362
  end
288
363
  end
289
364
 
@@ -320,6 +395,33 @@ module RecordCache
320
395
  end
321
396
  end
322
397
  end
398
+
399
+ module HasOne
400
+ class << self
401
+ def included(klass)
402
+ klass.extend ClassMethods
403
+ klass.send(:include, InstanceMethods)
404
+ klass.class_eval do
405
+ alias_method_chain :delete, :record_cache
406
+ end
407
+ end
408
+ end
409
+
410
+ module ClassMethods
411
+ end
412
+
413
+ module InstanceMethods
414
+ def delete_with_record_cache(method = options[:dependent])
415
+ # invalidate :id cache for all record
416
+ if load_target
417
+ target.class.record_cache.invalidate(target.id) if target.class.record_cache? unless target.new_record?
418
+ end
419
+ # invalidate the referenced class for the attribute/value pair on the index cache
420
+ @reflection.klass.record_cache.invalidate(@reflection.foreign_key.to_sym, @owner.id) if @reflection.klass.record_cache?
421
+ delete_without_record_cache(method)
422
+ end
423
+ end
424
+ end
323
425
  end
324
426
 
325
427
  end
@@ -328,3 +430,4 @@ ActiveRecord::Base.send(:include, RecordCache::ActiveRecord::Base)
328
430
  Arel::TreeManager.send(:include, RecordCache::Arel::TreeManager)
329
431
  ActiveRecord::Relation.send(:include, RecordCache::ActiveRecord::UpdateAll)
330
432
  ActiveRecord::Associations::HasManyAssociation.send(:include, RecordCache::ActiveRecord::HasMany)
433
+ ActiveRecord::Associations::HasOneAssociation.send(:include, RecordCache::ActiveRecord::HasOne)