record-cache 0.1.3 → 0.1.4

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 (35) hide show
  1. checksums.yaml +8 -8
  2. data/lib/record_cache/base.rb +4 -4
  3. data/lib/record_cache/datastore/active_record_30.rb +1 -1
  4. data/lib/record_cache/datastore/active_record_31.rb +1 -1
  5. data/lib/record_cache/datastore/active_record_32.rb +2 -2
  6. data/lib/record_cache/datastore/active_record_40.rb +445 -0
  7. data/lib/record_cache/datastore/active_record_41.rb +446 -0
  8. data/lib/record_cache/strategy/full_table_cache.rb +1 -1
  9. data/lib/record_cache/strategy/util.rb +20 -3
  10. data/lib/record_cache/version.rb +1 -1
  11. data/spec/db/create-record-cache-db_and_user.sql +5 -0
  12. data/spec/db/database.yml +7 -0
  13. data/spec/db/schema.rb +9 -15
  14. data/spec/initializers/backward_compatibility.rb +32 -0
  15. data/spec/lib/active_record/visitor_spec.rb +1 -1
  16. data/spec/lib/base_spec.rb +2 -2
  17. data/spec/lib/dispatcher_spec.rb +1 -1
  18. data/spec/lib/multi_read_spec.rb +1 -1
  19. data/spec/lib/query_spec.rb +1 -1
  20. data/spec/lib/statistics_spec.rb +1 -1
  21. data/spec/lib/strategy/base_spec.rb +39 -39
  22. data/spec/lib/strategy/full_table_cache_spec.rb +18 -18
  23. data/spec/lib/strategy/index_cache_spec.rb +58 -52
  24. data/spec/lib/strategy/query_cache_spec.rb +1 -1
  25. data/spec/lib/strategy/unique_index_on_id_cache_spec.rb +57 -45
  26. data/spec/lib/strategy/unique_index_on_string_cache_spec.rb +47 -45
  27. data/spec/lib/strategy/util_spec.rb +49 -43
  28. data/spec/lib/version_store_spec.rb +1 -1
  29. data/spec/models/apple.rb +1 -2
  30. data/spec/spec_helper.rb +16 -7
  31. data/spec/support/matchers/hit_cache_matcher.rb +1 -1
  32. data/spec/support/matchers/miss_cache_matcher.rb +1 -1
  33. data/spec/support/matchers/use_cache_matcher.rb +1 -1
  34. metadata +63 -17
  35. data/spec/support/after_commit.rb +0 -73
@@ -0,0 +1,446 @@
1
+ module RecordCache
2
+ module ActiveRecord
3
+
4
+ module Base
5
+ class << self
6
+ def included(klass)
7
+ klass.extend ClassMethods
8
+ klass.class_eval do
9
+ class << self
10
+ alias_method_chain :find_by_sql, :record_cache
11
+ end
12
+ end
13
+ end
14
+ end
15
+
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
19
+
20
+ # add cache invalidation hooks on initialization
21
+ def record_cache_init
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
25
+ end
26
+
27
+ # Retrieve the records, possibly from cache
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
+
32
+ # check the piggy-back'd ActiveRelation record to see if the query can be retrieved from cache
33
+ arel = sql.is_a?(String) ? sql.instance_variable_get(:@arel) : sql
34
+
35
+ sanitized_sql = sanitize_sql(sql)
36
+ sanitized_sql = connection.to_sql(sanitized_sql, binds) if sanitized_sql.respond_to?(:ast)
37
+
38
+ records = if connection.query_cache_enabled
39
+ query_cache = connection.instance_variable_get(:@query_cache)
40
+ query_cache["rc/#{sanitized_sql}"][binds] ||= try_record_cache(arel, sql, binds)
41
+
42
+ elsif connection.open_transactions > RC_TRANSACTIONS_THRESHOLD
43
+ find_by_sql_without_record_cache(sql, binds)
44
+
45
+ else
46
+ try_record_cache(arel, sql, binds)
47
+ end
48
+ records
49
+ end
50
+
51
+ def try_record_cache(arel, sql, binds)
52
+ query = arel && arel.respond_to?(:ast) ? RecordCache::Arel::QueryVisitor.new(binds).accept(arel.ast) : nil
53
+ record_cache.fetch(query) do
54
+ find_by_sql_without_record_cache(sql, binds)
55
+ end
56
+ end
57
+
58
+ end
59
+
60
+ end
61
+ end
62
+
63
+ module Arel
64
+
65
+ # The method <ActiveRecord::Base>.find_by_sql is used to actually
66
+ # retrieve the data from the DB.
67
+ # Unfortunately the ActiveRelation record is not accessible from
68
+ # there, so it is piggy-back'd in the SQL string.
69
+ module TreeManager
70
+ def self.included(klass)
71
+ klass.extend ClassMethods
72
+ klass.send(:include, InstanceMethods)
73
+ klass.class_eval do
74
+ alias_method_chain :to_sql, :record_cache
75
+ end
76
+ end
77
+
78
+ module ClassMethods
79
+ end
80
+
81
+ module InstanceMethods
82
+ def to_sql_with_record_cache
83
+ sql = to_sql_without_record_cache
84
+ sql.instance_variable_set(:@arel, self)
85
+ sql
86
+ end
87
+ end
88
+ end
89
+
90
+ # Visitor for the ActiveRelation to extract a simple cache query
91
+ # Only accepts single select queries with equality where statements
92
+ # Rejects queries with grouping / having / offset / etc.
93
+ class QueryVisitor < ::Arel::Visitors::Visitor
94
+ DESC = "DESC".freeze
95
+ COMMA = ",".freeze
96
+
97
+ def initialize(bindings)
98
+ super()
99
+ @bindings = (bindings || []).inject({}){ |h, cv| column, value = cv; h[column.name] = value; h}
100
+ @cacheable = true
101
+ @query = ::RecordCache::Query.new
102
+ end
103
+
104
+ def accept ast
105
+ super
106
+ @cacheable && !ast.lock ? @query : nil
107
+ end
108
+
109
+ private
110
+
111
+ def not_cacheable o, attribute
112
+ @cacheable = false
113
+ end
114
+
115
+ def skip o, attribute
116
+ end
117
+
118
+ alias :visit_Arel_Nodes_TableAlias :not_cacheable
119
+
120
+ alias :visit_Arel_Nodes_Lock :not_cacheable
121
+
122
+ alias :visit_Arel_Nodes_Sum :not_cacheable
123
+ alias :visit_Arel_Nodes_Max :not_cacheable
124
+ alias :visit_Arel_Nodes_Min :not_cacheable
125
+ alias :visit_Arel_Nodes_Avg :not_cacheable
126
+ alias :visit_Arel_Nodes_Count :not_cacheable
127
+ alias :visit_Arel_Nodes_Addition :not_cacheable
128
+ alias :visit_Arel_Nodes_Subtraction :not_cacheable
129
+ alias :visit_Arel_Nodes_Multiplication :not_cacheable
130
+ alias :visit_Arel_Nodes_NamedFunction :not_cacheable
131
+
132
+ alias :visit_Arel_Nodes_Bin :not_cacheable
133
+ alias :visit_Arel_Nodes_Distinct :not_cacheable
134
+ alias :visit_Arel_Nodes_DistinctOn :not_cacheable
135
+ alias :visit_Arel_Nodes_Division :not_cacheable
136
+ alias :visit_Arel_Nodes_Except :not_cacheable
137
+ alias :visit_Arel_Nodes_Exists :not_cacheable
138
+ alias :visit_Arel_Nodes_InfixOperation :not_cacheable
139
+ alias :visit_Arel_Nodes_Intersect :not_cacheable
140
+ alias :visit_Arel_Nodes_Union :not_cacheable
141
+ alias :visit_Arel_Nodes_UnionAll :not_cacheable
142
+ alias :visit_Arel_Nodes_With :not_cacheable
143
+ alias :visit_Arel_Nodes_WithRecursive :not_cacheable
144
+
145
+ def visit_Arel_Nodes_JoinSource o, attribute
146
+ # left and right are array, but using blank as it also works for nil
147
+ @cacheable = o.left.blank? || o.right.blank?
148
+ end
149
+
150
+ alias :visit_Arel_Nodes_CurrentRow :not_cacheable
151
+ alias :visit_Arel_Nodes_Extract :not_cacheable
152
+ alias :visit_Arel_Nodes_Following :not_cacheable
153
+ alias :visit_Arel_Nodes_NamedWindow :not_cacheable
154
+ alias :visit_Arel_Nodes_Over :not_cacheable
155
+ alias :visit_Arel_Nodes_Preceding :not_cacheable
156
+ alias :visit_Arel_Nodes_Range :not_cacheable
157
+ alias :visit_Arel_Nodes_Rows :not_cacheable
158
+ alias :visit_Arel_Nodes_Window :not_cacheable
159
+
160
+ alias :visit_Arel_Nodes_As :skip
161
+ alias :visit_Arel_SelectManager :not_cacheable
162
+ alias :visit_Arel_Nodes_Ascending :skip
163
+ alias :visit_Arel_Nodes_Descending :skip
164
+ alias :visit_Arel_Nodes_False :skip
165
+ alias :visit_Arel_Nodes_True :skip
166
+
167
+ alias :visit_Arel_Nodes_StringJoin :not_cacheable
168
+ alias :visit_Arel_Nodes_InnerJoin :not_cacheable
169
+ alias :visit_Arel_Nodes_OuterJoin :not_cacheable
170
+
171
+ alias :visit_Arel_Nodes_DeleteStatement :not_cacheable
172
+ alias :visit_Arel_Nodes_InsertStatement :not_cacheable
173
+ alias :visit_Arel_Nodes_UpdateStatement :not_cacheable
174
+
175
+
176
+ alias :unary :not_cacheable
177
+ alias :visit_Arel_Nodes_Group :unary
178
+ alias :visit_Arel_Nodes_Having :unary
179
+ alias :visit_Arel_Nodes_Not :unary
180
+ alias :visit_Arel_Nodes_On :unary
181
+ alias :visit_Arel_Nodes_UnqualifiedColumn :unary
182
+
183
+ def visit_Arel_Nodes_Offset o, attribute
184
+ @cacheable = false unless o.expr == 0
185
+ end
186
+
187
+ def visit_Arel_Nodes_Values o, attribute
188
+ visit o.expressions if @cacheable
189
+ end
190
+
191
+ def visit_Arel_Nodes_Limit o, attribute
192
+ @query.limit = o.expr
193
+ end
194
+ alias :visit_Arel_Nodes_Top :visit_Arel_Nodes_Limit
195
+
196
+ GROUPING_EQUALS_REGEXP = /^\W?(\w*)\W?\.\W?(\w*)\W?\s*=\s*(\d+)$/ # `calendars`.account_id = 5
197
+ GROUPING_IN_REGEXP = /^^\W?(\w*)\W?\.\W?(\w*)\W?\s*IN\s*\(([\d\s,]+)\)$/ # `service_instances`.`id` IN (118,80,120,82)
198
+ def visit_Arel_Nodes_Grouping o, attribute
199
+ return unless @cacheable
200
+ if @table_name && o.expr =~ GROUPING_EQUALS_REGEXP && $1 == @table_name
201
+ @cacheable = @query.where($2, $3.to_i)
202
+ elsif @table_name && o.expr =~ GROUPING_IN_REGEXP && $1 == @table_name
203
+ @cacheable = @query.where($2, $3.split(',').map(&:to_i))
204
+ else
205
+ @cacheable = false
206
+ end
207
+ end
208
+
209
+ def visit_Arel_Nodes_SelectCore o, attribute
210
+ @cacheable = false unless o.groups.empty?
211
+ visit o.froms if @cacheable
212
+ visit o.wheres if @cacheable
213
+ visit o.source if @cacheable
214
+ @cacheable = o.projections.none?{ |projection| projection.to_s =~ /distinct/i } unless o.projections.empty? if @cacheable
215
+ end
216
+
217
+ def visit_Arel_Nodes_SelectStatement o, attribute
218
+ @cacheable = false if o.cores.size > 1
219
+ if @cacheable
220
+ visit o.offset
221
+ o.orders.map { |x| handle_order_by(visit x) } if @cacheable && o.orders.size > 0
222
+ visit o.limit
223
+ visit o.cores
224
+ end
225
+ end
226
+
227
+ ORDER_BY_REGEXP = /^\s*([\w\.]*)\s*(|ASC|asc|DESC|desc)\s*$/ # people.id DESC
228
+ def handle_order_by(order)
229
+ order.to_s.split(COMMA).each do |o|
230
+ # simple sort order (+people.id+ can be replaced by +id+, as joins are not allowed anyways)
231
+ if o.match(ORDER_BY_REGEXP)
232
+ asc = $2.upcase == DESC ? false : true
233
+ @query.order_by($1.split('.').last, asc)
234
+ else
235
+ @cacheable = false
236
+ end
237
+ end
238
+ end
239
+
240
+ def visit_Arel_Table o, attribute
241
+ @table_name = o.name
242
+ end
243
+
244
+ def visit_Arel_Attributes_Attribute o, attribute
245
+ o.name.to_sym
246
+ end
247
+ alias :visit_Arel_Attributes_Integer :visit_Arel_Attributes_Attribute
248
+ alias :visit_Arel_Attributes_Float :visit_Arel_Attributes_Attribute
249
+ alias :visit_Arel_Attributes_String :visit_Arel_Attributes_Attribute
250
+ alias :visit_Arel_Attributes_Time :visit_Arel_Attributes_Attribute
251
+ alias :visit_Arel_Attributes_Boolean :visit_Arel_Attributes_Attribute
252
+ alias :visit_Arel_Attributes_Decimal :visit_Arel_Attributes_Attribute
253
+
254
+ def visit_Arel_Nodes_Equality o, attribute
255
+ key, value = visit(o.left), visit(o.right)
256
+ # several different binding markers exist depending on the db driver used (MySQL, Postgress supported)
257
+ if value.to_s =~ /^(\?|\u0000|\$\d+)$/
258
+ # puts "bindings: #{@bindings.inspect}, key = #{key.to_s}"
259
+ value = @bindings[key.to_s] || value
260
+ end
261
+ # puts " =====> equality found: #{key.inspect}@#{key.class.name} => #{value.inspect}@#{value.class.name}"
262
+ @query.where(key, value)
263
+ end
264
+ alias :visit_Arel_Nodes_In :visit_Arel_Nodes_Equality
265
+
266
+ def visit_Arel_Nodes_And o, attribute
267
+ visit(o.children)
268
+ end
269
+
270
+ alias :visit_Arel_Nodes_Or :not_cacheable
271
+ alias :visit_Arel_Nodes_NotEqual :not_cacheable
272
+ alias :visit_Arel_Nodes_GreaterThan :not_cacheable
273
+ alias :visit_Arel_Nodes_GreaterThanOrEqual :not_cacheable
274
+ alias :visit_Arel_Nodes_Assignment :not_cacheable
275
+ alias :visit_Arel_Nodes_LessThan :not_cacheable
276
+ alias :visit_Arel_Nodes_LessThanOrEqual :not_cacheable
277
+ alias :visit_Arel_Nodes_Between :not_cacheable
278
+ alias :visit_Arel_Nodes_NotIn :not_cacheable
279
+ alias :visit_Arel_Nodes_DoesNotMatch :not_cacheable
280
+ alias :visit_Arel_Nodes_Matches :not_cacheable
281
+
282
+ def visit_Fixnum o, attribute
283
+ o.to_i
284
+ end
285
+ alias :visit_Bignum :visit_Fixnum
286
+
287
+ def visit_Symbol o, attribute
288
+ o.to_sym
289
+ end
290
+
291
+ def visit_Object o, attribute
292
+ o
293
+ end
294
+ alias :visit_Arel_Nodes_SqlLiteral :visit_Object
295
+ alias :visit_Arel_Nodes_BindParam :visit_Object
296
+ alias :visit_Arel_SqlLiteral :visit_Object # This is deprecated
297
+ alias :visit_String :visit_Object
298
+ alias :visit_NilClass :visit_Object
299
+ alias :visit_TrueClass :visit_Object
300
+ alias :visit_FalseClass :visit_Object
301
+ alias :visit_Arel_SqlLiteral :visit_Object
302
+ alias :visit_BigDecimal :visit_Object
303
+ alias :visit_Float :visit_Object
304
+ alias :visit_Time :visit_Object
305
+ alias :visit_Date :visit_Object
306
+ alias :visit_DateTime :visit_Object
307
+ alias :visit_Hash :visit_Object
308
+
309
+ def visit_Array o, attribute
310
+ o.map{ |x| visit x }
311
+ end
312
+ end
313
+ end
314
+
315
+ end
316
+
317
+ module RecordCache
318
+
319
+ # Patch ActiveRecord::Relation to make sure update_all will invalidate all referenced records
320
+ module ActiveRecord
321
+ module UpdateAll
322
+ class << self
323
+ def included(klass)
324
+ klass.extend ClassMethods
325
+ klass.send(:include, InstanceMethods)
326
+ klass.class_eval do
327
+ alias_method_chain :update_all, :record_cache
328
+ end
329
+ end
330
+ end
331
+
332
+ module ClassMethods
333
+ end
334
+
335
+ module InstanceMethods
336
+ def __find_in_clause(sub_select)
337
+ return nil unless sub_select.arel.constraints.count == 1
338
+ constraint = sub_select.arel.constraints.first
339
+ return constraint if constraint.is_a?(::Arel::Nodes::In) # directly an IN clause
340
+ return nil unless constraint.respond_to?(:children) && constraint.children.count == 1
341
+ constraint = constraint.children.first
342
+ return constraint if constraint.is_a?(::Arel::Nodes::In) # AND with IN clause
343
+ nil
344
+ end
345
+
346
+ def update_all_with_record_cache(updates)
347
+ result = update_all_without_record_cache(updates)
348
+
349
+ if record_cache?
350
+ # when this condition is met, the arel.update method will be called on the current scope, see ActiveRecord::Relation#update_all
351
+ unless @limit_value.present? != @order_values.present?
352
+ # get all attributes that contain a unique index for this model
353
+ unique_index_attributes = RecordCache::Strategy::UniqueIndexCache.attributes(self)
354
+ # go straight to SQL result (without instantiating records) for optimal performance
355
+ RecordCache::Base.version_store.multi do
356
+ sub_select = select(unique_index_attributes.map(&:to_s).join(','))
357
+ in_clause = __find_in_clause(sub_select)
358
+ if unique_index_attributes.size == 1 && in_clause &&
359
+ in_clause.left.try(:name).to_s == unique_index_attributes.first.to_s
360
+ # common case where the unique index is the (only) constraint on the query: SELECT id FROM people WHERE id in (...)
361
+ attribute = unique_index_attributes.first
362
+ in_clause.right.each do |value|
363
+ record_cache.invalidate(attribute, value)
364
+ end
365
+ else
366
+ connection.execute(sub_select.to_sql).each do |row|
367
+ # invalidate the unique index for all attributes
368
+ unique_index_attributes.each_with_index do |attribute, index|
369
+ record_cache.invalidate(attribute, (row.is_a?(Hash) ? row[attribute.to_s] : row[index]))
370
+ end
371
+ end
372
+ end
373
+ end
374
+ end
375
+ end
376
+
377
+ result
378
+ end
379
+ end
380
+ end
381
+ end
382
+
383
+ # Patch ActiveRecord::Associations::HasManyAssociation to make sure the index_cache is updated when records are
384
+ # deleted from the collection
385
+ module ActiveRecord
386
+ module HasMany
387
+ class << self
388
+ def included(klass)
389
+ klass.extend ClassMethods
390
+ klass.send(:include, InstanceMethods)
391
+ klass.class_eval do
392
+ alias_method_chain :delete_records, :record_cache
393
+ end
394
+ end
395
+ end
396
+
397
+ module ClassMethods
398
+ end
399
+
400
+ module InstanceMethods
401
+ def delete_records_with_record_cache(records, method)
402
+ records = load_target if records == :all
403
+ # invalidate :id cache for all records
404
+ records.each{ |record| record.class.record_cache.invalidate(record.id) if record.class.record_cache? unless record.new_record? }
405
+ # invalidate the referenced class for the attribute/value pair on the index cache
406
+ @reflection.klass.record_cache.invalidate(@reflection.foreign_key.to_sym, @owner.id) if @reflection.klass.record_cache?
407
+ delete_records_without_record_cache(records, method)
408
+ end
409
+ end
410
+ end
411
+
412
+ module HasOne
413
+ class << self
414
+ def included(klass)
415
+ klass.extend ClassMethods
416
+ klass.send(:include, InstanceMethods)
417
+ klass.class_eval do
418
+ alias_method_chain :delete, :record_cache
419
+ end
420
+ end
421
+ end
422
+
423
+ module ClassMethods
424
+ end
425
+
426
+ module InstanceMethods
427
+ def delete_with_record_cache(method = options[:dependent])
428
+ # invalidate :id cache for all record
429
+ if load_target
430
+ target.class.record_cache.invalidate(target.id) if target.class.record_cache? unless target.new_record?
431
+ end
432
+ # invalidate the referenced class for the attribute/value pair on the index cache
433
+ @reflection.klass.record_cache.invalidate(@reflection.foreign_key.to_sym, @owner.id) if @reflection.klass.record_cache?
434
+ delete_without_record_cache(method)
435
+ end
436
+ end
437
+ end
438
+ end
439
+
440
+ end
441
+
442
+ ActiveRecord::Base.send(:include, RecordCache::ActiveRecord::Base)
443
+ Arel::TreeManager.send(:include, RecordCache::Arel::TreeManager)
444
+ ActiveRecord::Relation.send(:include, RecordCache::ActiveRecord::UpdateAll)
445
+ ActiveRecord::Associations::HasManyAssociation.send(:include, RecordCache::ActiveRecord::HasMany)
446
+ ActiveRecord::Associations::HasOneAssociation.send(:include, RecordCache::ActiveRecord::HasOne)
@@ -7,7 +7,7 @@ module RecordCache
7
7
  def self.parse(base, record_store, options)
8
8
  return nil unless options[:full_table]
9
9
  return nil unless base.table_exists?
10
-
10
+
11
11
  FullTableCache.new(base, :full_table, record_store, options)
12
12
  end
13
13