record-cache 0.1.2 → 0.1.3

Sign up to get free protection for your applications and to get access to all the features.
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)