record_cache 0.9.6

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,2 @@
1
+ record_cache.gemspec
2
+ pkg
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2008 Justin Balthrop, Geni.com
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.rdoc ADDED
@@ -0,0 +1,34 @@
1
+ = RecordCache
2
+
3
+ RecordCache is a simple yet powerful extension to ActiveRecord that caches indexes
4
+ and ActiveRecord models using MemCache. If you use it correctly, it will drastically
5
+ reduce your database load.
6
+
7
+ == Usage:
8
+
9
+ class Foo < ActiveRecord
10
+ record_cache :by => :id
11
+ record_cache :id, :by => :owner_id
12
+ end
13
+
14
+ # These will use the cache now.
15
+ Foo.find(1)
16
+ Foo.find_by_id(2)
17
+ Foo.find_all_by_owner_id(3)
18
+
19
+ Invalidation is handled for you using callbacks.
20
+
21
+ == Install:
22
+
23
+ sudo gem install record_cache -s http://gemcutter.org
24
+
25
+ == Dependencies:
26
+
27
+ * {after_commit}[http://github.com/freelancing-god/after_commit]
28
+ * {deferrable}[http://github.com/ninjudd/deferrable]
29
+ * {memcache}[http://github.com/ninjudd/memcache]
30
+ * {cache_version}[http://github.com/ninjudd/cache_version]
31
+
32
+ == License:
33
+
34
+ Copyright (c) 2009 Justin Balthrop, Geni.com; Published under The MIT License, see LICENSE
data/Rakefile ADDED
@@ -0,0 +1,47 @@
1
+ require 'rake'
2
+ require 'rake/testtask'
3
+ require 'rake/rdoctask'
4
+
5
+ begin
6
+ require 'jeweler'
7
+ Jeweler::Tasks.new do |s|
8
+ s.name = "record_cache"
9
+ s.summary = %Q{Active Record caching and indexing in memcache. An alternative to cache_fu}
10
+ s.email = "code@justinbalthrop.com"
11
+ s.homepage = "http://github.com/ninjudd/record_cache"
12
+ s.description = "Active Record caching and indexing in memcache"
13
+ s.authors = ["Justin Balthrop"]
14
+ s.add_dependency('after_commit', '> 1.0.0')
15
+ s.add_dependency('deferrable', '> 0.1.0')
16
+ s.add_dependency('memcache', '> 1.0.0')
17
+ s.add_dependency('cache_version', '> 0.9.4')
18
+ end
19
+ rescue LoadError
20
+ puts "Jeweler not available. Install it with: sudo gem install technicalpickles-jeweler -s http://gems.github.com"
21
+ end
22
+
23
+ Rake::TestTask.new do |t|
24
+ t.libs << 'lib'
25
+ t.pattern = 'test/**/*_test.rb'
26
+ t.verbose = false
27
+ end
28
+
29
+ Rake::RDocTask.new do |rdoc|
30
+ rdoc.rdoc_dir = 'rdoc'
31
+ rdoc.title = 'record_cache'
32
+ rdoc.options << '--line-numbers' << '--inline-source'
33
+ rdoc.rdoc_files.include('README*')
34
+ rdoc.rdoc_files.include('lib/**/*.rb')
35
+ end
36
+
37
+ begin
38
+ require 'rcov/rcovtask'
39
+ Rcov::RcovTask.new do |t|
40
+ t.libs << 'test'
41
+ t.test_files = FileList['test/**/*_test.rb']
42
+ t.verbose = true
43
+ end
44
+ rescue LoadError
45
+ end
46
+
47
+ task :default => :test
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.9.6
@@ -0,0 +1,382 @@
1
+ module RecordCache
2
+ class Index
3
+ include Deferrable
4
+
5
+ attr_reader :model_class, :index_field, :fields, :order_by, :limit, :expiry, :name, :prefix
6
+
7
+ NULL = 'NULL'
8
+
9
+ def initialize(opts)
10
+ raise ':by => index_field required for cache' if opts[:by].nil?
11
+ raise 'explicit name or prefix required with scope' if opts[:scope] and opts[:name].nil? and opts[:prefix].nil?
12
+
13
+ @auto_name = opts[:name].nil?
14
+ @write_ahead = opts[:write_ahead]
15
+ @cache = opts[:cache] || CACHE
16
+ @expiry = opts[:expiry]
17
+ @model_class = opts[:class]
18
+ @set_class = opts[:set_class] || "#{@model_class}Set"
19
+ @index_field = opts[:by].to_s
20
+ @fields = opts[:fields].collect {|field| field.to_s}
21
+ @prefix = opts[:prefix]
22
+ @name = ( opts[:name] || [prefix, 'by', index_field].compact.join('_') ).to_s
23
+ @order_by = opts[:order_by]
24
+ @limit = opts[:limit]
25
+ @disallow_null = opts[:null] == false
26
+ @scope_query = opts[:scope] || {}
27
+ end
28
+
29
+ def cache
30
+ if RecordCache.config[:thread_safe]
31
+ Thread.current[:record_cache] ||= @cache.clone
32
+ else
33
+ @cache
34
+ end
35
+ end
36
+
37
+ def auto_name?; @auto_name; end
38
+ def write_ahead?; @write_ahead; end
39
+ def disallow_null?; @disallow_null; end
40
+
41
+ def full_record?
42
+ fields.empty?
43
+ end
44
+
45
+ def includes_id?
46
+ full_record? or fields.include?('id')
47
+ end
48
+
49
+ def set_class
50
+ @set_class.constantize
51
+ end
52
+
53
+ def namespace
54
+ "#{model_class.name}_#{model_class.version}_#{RecordCache.version}:#{name}" << ( full_record? ? '' : ":#{fields.join(',')}" )
55
+ end
56
+
57
+ def fields_hash
58
+ if @fields_hash.nil?
59
+ if full_record?
60
+ @fields_hash ||= model_class.column_names.hash
61
+ else
62
+ @fields_hash ||= fields.collect {|field| field.to_s}.hash
63
+ end
64
+ end
65
+ @fields_hash
66
+ end
67
+
68
+ def find_by_ids(ids, model_class)
69
+ expects_array = ids.first.kind_of?(Array)
70
+ ids = ids.flatten.compact.collect {|id| id.to_i}
71
+ ids = stringify(ids)
72
+
73
+ if ids.empty?
74
+ return [] if expects_array
75
+ raise ActiveRecord::RecordNotFound, "Couldn't find #{model_class} without an ID"
76
+ end
77
+
78
+ records_by_id = get_records(ids)
79
+
80
+ models = ids.collect do |id|
81
+ records = records_by_id[id]
82
+ model = records.instantiate_first(model_class, full_record?) if records
83
+
84
+ # try to get record from db again before we throw an exception
85
+ if model.nil?
86
+ invalidate(id)
87
+ records = get_records([id])[id]
88
+ model = records.instantiate_first(model_class, full_record?) if records
89
+ end
90
+
91
+ raise ActiveRecord::RecordNotFound, "Couldn't find #{model_class} with ID #{id}" unless model
92
+ model
93
+ end
94
+
95
+ if models.size == 1 and not expects_array
96
+ models.first
97
+ else
98
+ models
99
+ end
100
+ end
101
+
102
+ def find_by_field(keys, model_class, type)
103
+ keys = [keys] if not keys.kind_of?(Array)
104
+ keys = stringify(keys)
105
+ records_by_key = get_records(keys)
106
+
107
+ case type
108
+ when :first
109
+ keys.each do |key|
110
+ model = records_by_key[key].instantiate_first(model_class, full_record?)
111
+ return model if model
112
+ end
113
+ return nil
114
+ when :all
115
+ models = []
116
+ keys.each do |key|
117
+ models.concat( records_by_key[key].instantiate(model_class, full_record?) )
118
+ end
119
+ models
120
+ when :set, :ids
121
+ ids = []
122
+ keys.each do |key|
123
+ ids.concat( records_by_key[key].ids(model_class) )
124
+ end
125
+ type == :set ? set_class.new(ids) : ids
126
+ when :raw
127
+ raw_records = []
128
+ keys.each do |key|
129
+ raw_records.concat( records_by_key[key].records(model_class) )
130
+ end
131
+ raw_records
132
+ end
133
+ end
134
+
135
+ def field_lookup(keys, model_class, field, flag = nil)
136
+ keys = [*keys]
137
+ keys = stringify(keys)
138
+ field = field.to_s if field
139
+ records_by_key = get_records(keys)
140
+
141
+ field_by_index = {}
142
+ all_fields = []
143
+ keys.each do |key|
144
+ records = records_by_key[key]
145
+ fields = field ? records.fields(field, model_class) : records.all_fields(model_class, :except => index_field)
146
+ if flag == :all
147
+ all_fields.concat(fields)
148
+ elsif flag == :first
149
+ next if fields.empty?
150
+ field_by_index[index_column.type_cast(key)] = fields.first
151
+ else
152
+ field_by_index[index_column.type_cast(key)] = fields
153
+ end
154
+ end
155
+ if flag == :all
156
+ all_fields.uniq
157
+ else
158
+ field_by_index
159
+ end
160
+ end
161
+
162
+ def invalidate(*keys)
163
+ keys = stringify(keys)
164
+ cache.in_namespace(namespace) do
165
+ keys.each do |key|
166
+ cache.delete(key)
167
+ end
168
+ end
169
+ end
170
+
171
+ def invalidate_from_conditions_lambda(conditions)
172
+ sql = "SELECT #{index_field} FROM #{table_name} "
173
+ model_class.send(:add_conditions!, sql, conditions, model_class.send(:scope, :find))
174
+ ids = db.select_values(sql)
175
+ lambda { invalidate(*ids) }
176
+ end
177
+
178
+ def invalidate_from_conditions(conditions)
179
+ invalidate_from_conditions_lambda(conditions).call
180
+ end
181
+
182
+ def invalidate_model(model)
183
+ attribute = model.send(index_field)
184
+ attribute_was = model.attr_was(index_field)
185
+ if scope.match_previous?(model)
186
+ if write_ahead?
187
+ remove_from_cache(model)
188
+ else
189
+ now_and_later do
190
+ invalidate(attribute_was)
191
+ end
192
+ end
193
+ end
194
+
195
+ if scope.match_current?(model)
196
+ if write_ahead?
197
+ add_to_cache(model)
198
+ elsif not (scope.match_previous?(model) and attribute_was == attribute)
199
+ now_and_later do
200
+ invalidate(attribute)
201
+ end
202
+ end
203
+ end
204
+ end
205
+
206
+ def scope_query
207
+ @scope_query[:type] ||= model_class.to_s if sub_class?
208
+ @scope_query
209
+ end
210
+
211
+ def scope
212
+ @scope ||= Scope.new(model_class, scope_query)
213
+ end
214
+
215
+ @@disable_db = false
216
+ def self.disable_db
217
+ @@disable_db = true
218
+ end
219
+
220
+ def self.enable_db
221
+ @@disable_db = false
222
+ end
223
+
224
+ def find_method_name(type)
225
+ if name =~ /(^|_)by_/
226
+ if type == :first
227
+ "find_#{name}"
228
+ else
229
+ "find_#{type}_#{name}"
230
+ end
231
+ else
232
+ case type
233
+ when :all
234
+ "find_#{name}"
235
+ when :first
236
+ "find_#{type}_#{name.singularize}"
237
+ else
238
+ "find_#{name.singularize}_#{type}"
239
+ end
240
+ end
241
+ end
242
+
243
+ def cached_set(id)
244
+ # Used for debugging. Gives you the RecordCache::Set that is currently in the cache.
245
+ id = stringify([id]).first
246
+ cache.in_namespace(namespace) do
247
+ cache.get(id)
248
+ end
249
+ end
250
+
251
+ private
252
+
253
+ MAX_FETCH = 1000
254
+ def get_records(keys)
255
+ cache.in_namespace(namespace) do
256
+ opts = {
257
+ :expiry => expiry,
258
+ :disable_write => model_class.record_cache_config[:disable_write],
259
+ :validation => lambda {|key, record_set| record_set and record_set.fields_hash == fields_hash},
260
+ }
261
+ cache.get_some(keys, opts) do |keys_to_fetch|
262
+ raise 'db access is disabled' if @@disable_db
263
+ fetched_records = {}
264
+ keys_to_fetch.each do |key|
265
+ fetched_records[key] = RecordCache::Set.new(model_class, fields_hash)
266
+ end
267
+
268
+ keys_to_fetch.each_slice(MAX_FETCH) do |keys_batch|
269
+ sql = "SELECT #{select_fields} FROM #{table_name} WHERE (#{in_clause(keys_batch)})"
270
+ sql << " AND #{scope.conditions}" if not scope.empty?
271
+ sql << " ORDER BY #{order_by}" if order_by
272
+ sql << " LIMIT #{limit}" if limit
273
+
274
+ db.select_all(sql).each do |record|
275
+ key = record[index_field] || NULL
276
+ fetched_records[key] << record
277
+ end
278
+ end
279
+ fetched_records
280
+ end
281
+ end
282
+ end
283
+
284
+ def model_to_record(model)
285
+ sql = "SELECT #{select_fields} FROM #{table_name} WHERE id = #{model.id}"
286
+ db.select_all(sql).first
287
+ end
288
+
289
+ def in_clause(keys)
290
+ conditions = []
291
+ conditions << "#{index_field} IS NULL" if keys.delete(NULL)
292
+
293
+ if keys.any?
294
+ values = keys.collect {|value| quote_index_value(value)}.join(',')
295
+ conditions << "#{index_field} IN (#{values})"
296
+ end
297
+ conditions.join(' OR ')
298
+ end
299
+
300
+ def remove_from_cache(model)
301
+ record = model.attributes
302
+ key = model.attr_was(index_field)
303
+
304
+ now_and_later do
305
+ cache.in_namespace(namespace) do
306
+ cache.with_lock(key) do
307
+ if records = cache.get(key)
308
+ records.delete(record)
309
+ cache.set(key, records)
310
+ end
311
+ end
312
+ end
313
+ end
314
+ end
315
+
316
+ def add_to_cache(model)
317
+ record = model_to_record(model)
318
+ return unless record
319
+ key = record[index_field].to_s
320
+
321
+ now_and_later do
322
+ cache.in_namespace(namespace) do
323
+ cache.with_lock(key) do
324
+ if records = cache.get(key)
325
+ records.delete(record)
326
+ records << record
327
+ records.sort!(order_by) if order_by
328
+ records.limit!(limit) if limit
329
+ cache.set(key, records)
330
+ end
331
+ end
332
+ end
333
+ end
334
+ end
335
+
336
+ def select_fields
337
+ if @select_fields.nil?
338
+ if full_record?
339
+ @select_fields = model_class.respond_to?(:default_select, true) ? model_class.send(:default_select, nil) : '*'
340
+ else
341
+ @select_fields = [index_field, 'id'] + fields
342
+ @select_fields << 'type' if base_class?
343
+ @select_fields = @select_fields.uniq.join(', ')
344
+ end
345
+ end
346
+ @select_fields
347
+ end
348
+
349
+ def base_class?
350
+ @base_class ||= single_table_inheritance? and model_class == model_class.base_class
351
+ end
352
+
353
+ def sub_class?
354
+ @sub_class ||= single_table_inheritance? and model_class != model_class.base_class
355
+ end
356
+
357
+ def single_table_inheritance?
358
+ @single_table_inheritance ||= model_class.columns_hash.has_key?(model_class.inheritance_column)
359
+ end
360
+
361
+ def quote_index_value(value)
362
+ model_class.quote_value(value, index_column)
363
+ end
364
+
365
+ def index_column
366
+ @index_column ||= model_class.columns_hash[index_field]
367
+ end
368
+
369
+ def table_name
370
+ model_class.table_name
371
+ end
372
+
373
+ def stringify(keys)
374
+ keys.compact! if disallow_null?
375
+ keys.collect {|key| key.nil? ? NULL : key.to_s}.uniq
376
+ end
377
+
378
+ def db
379
+ RecordCache.db(model_class)
380
+ end
381
+ end
382
+ end
@@ -0,0 +1,64 @@
1
+ module RecordCache
2
+ class Scope
3
+ attr_reader :model_class, :query
4
+
5
+ def initialize(model_class, query)
6
+ @model_class = model_class
7
+ @query = query
8
+ end
9
+
10
+ def empty?
11
+ query.empty?
12
+ end
13
+
14
+ def fields
15
+ query.keys
16
+ end
17
+
18
+ def match_current?(model)
19
+ fields.all? do |field|
20
+ match?( field, model.send(field) )
21
+ end
22
+ end
23
+
24
+ def match_previous?(model)
25
+ fields.all? do |field|
26
+ match?( field, model.attr_was(field) )
27
+ end
28
+ end
29
+
30
+ def match?(field, value)
31
+ scope = query[field]
32
+ if defined?(AntiObject) and scope.kind_of?(AntiObject)
33
+ scope = ~scope
34
+ invert = true
35
+ end
36
+
37
+ match = [*scope].include?(value)
38
+ invert ? !match : match
39
+ end
40
+
41
+ def conditions
42
+ @conditions ||= begin
43
+ query.collect do |field, scope|
44
+ if defined?(AntiObject) and scope.kind_of?(AntiObject)
45
+ scope = ~scope
46
+ invert = true
47
+ end
48
+
49
+ if scope.nil?
50
+ op = invert ? 'IS NOT' : 'IS'
51
+ "#{field} #{op} NULL"
52
+ elsif scope.is_a?(Array)
53
+ op = invert ? 'NOT IN' : 'IN'
54
+ model_class.send(:sanitize_sql, ["#{field} #{op} (?)", scope])
55
+ else
56
+ op = invert ? '!=' : '='
57
+ model_class.send(:sanitize_sql, ["#{field} #{op} ?", scope])
58
+ end
59
+ end.join(' AND ')
60
+ end
61
+ @conditions
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,141 @@
1
+ module RecordCache
2
+ class Set
3
+ attr_reader :model_class, :fields_hash, :created_at, :updated_at, :hostname, :dbhost
4
+
5
+ def self.source_tracking?
6
+ @source_tracking
7
+ end
8
+
9
+ def self.source_tracking=(value)
10
+ @source_tracking = value
11
+ end
12
+
13
+ def self.hostname
14
+ @hostname ||= Socket.gethostname
15
+ end
16
+
17
+ def initialize(model_class, fields_hash = nil)
18
+ raise 'valid model class required' unless model_class
19
+ @model_class = model_class
20
+ @fields_hash = fields_hash
21
+ @records_by_type = {}
22
+
23
+ if self.class.source_tracking?
24
+ @created_at = Time.now
25
+ @hostname = self.class.hostname
26
+ @dbhost = RecordCache.db(model_class).instance_variable_get(:@config)[:host]
27
+ end
28
+ end
29
+
30
+ def sort!(order_by)
31
+ field, order = order_by.strip.squeeze.split
32
+ descending = (order == 'DESC')
33
+ @records_by_type.values.each do |records|
34
+ sorted_records = records.sort_by do |record|
35
+ type_cast(field, record[field])
36
+ end
37
+ sorted_records.reverse! if descending
38
+ records.replace(sorted_records)
39
+ end
40
+ end
41
+
42
+ def limit!(limit)
43
+ all_records = records
44
+ if all_records.length > limit
45
+ removed_records = all_records.slice!(limit..-1)
46
+ removed_records.each do |record|
47
+ type = record['type']
48
+ records_by_type(type).delete(record) if type
49
+ end
50
+ end
51
+ end
52
+
53
+ def <<(record)
54
+ mark_updated!
55
+ record_type = record['type']
56
+ record['id'] = record['id'].to_i if record.has_key?('id')
57
+
58
+ [record_type, model_class.to_s].uniq.each do |type|
59
+ records_by_type(type) << record
60
+ end
61
+ end
62
+
63
+ def delete(record)
64
+ raise 'cannot delete record without id' unless record.has_key?('id')
65
+ mark_updated!
66
+ record_type = record['type']
67
+ id = record['id'].to_i
68
+
69
+ [record_type, model_class.to_s].uniq.each do |type|
70
+ records_by_type(type).reject! {|r| r['id'] == id}
71
+ end
72
+ end
73
+
74
+ def records_by_type(type)
75
+ @records_by_type[type.to_s] ||= []
76
+ end
77
+
78
+ def records(type = model_class)
79
+ records_by_type(type)
80
+ end
81
+
82
+ def size
83
+ records.size
84
+ end
85
+
86
+ def empty?
87
+ records.empty?
88
+ end
89
+
90
+ def ids(type = model_class)
91
+ records(type).collect {|r| r['id']}
92
+ end
93
+
94
+ def fields(field, type)
95
+ records(type).collect {|r| type_cast(field, r[field])}
96
+ end
97
+
98
+ def all_fields(type = model_class, opts = {})
99
+ records(type).collect do |r|
100
+ record = {}
101
+ r.each do |field, value|
102
+ next if field == opts[:except]
103
+ record[field.to_sym] = type_cast(field, value)
104
+ end
105
+ record
106
+ end
107
+ end
108
+
109
+ def instantiate_first(type = model_class, full_record = false)
110
+ if full_record
111
+ record = records(type).first
112
+ type.send(:instantiate, record) if record
113
+ else
114
+ id = ids(type).first
115
+ type.find_by_id(id) if id
116
+ end
117
+ end
118
+
119
+ def instantiate(type = model_class, full_record = false)
120
+ if full_record
121
+ records(type).collect do |record|
122
+ type.send(:instantiate, record)
123
+ end
124
+ else
125
+ type.find_all_by_id(ids(type))
126
+ end
127
+ end
128
+
129
+ private
130
+
131
+ def mark_updated!
132
+ @updated_at = Time.now if self.class.source_tracking?
133
+ end
134
+
135
+ def type_cast(field, value)
136
+ column = model_class.columns_hash[field.to_s]
137
+ raise 'column not found in #{model_class} for field #{field}' unless column
138
+ column.type_cast(value)
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,264 @@
1
+ require 'rubygems'
2
+ require 'memcache'
3
+ require 'active_record'
4
+ require 'cache_version'
5
+ require 'deferrable'
6
+
7
+ $:.unshift(File.dirname(__FILE__))
8
+ require 'record_cache/index'
9
+ require 'record_cache/set'
10
+ require 'record_cache/scope'
11
+
12
+ module RecordCache
13
+ def self.config(opts = nil)
14
+ if opts
15
+ config.merge!(opts)
16
+ else
17
+ @config ||= {}
18
+ end
19
+ end
20
+
21
+ def self.db(model_class)
22
+ # Always use the master connection since we are caching.
23
+ db = model_class.connection
24
+ if defined?(DataFabric::ConnectionProxy) and db.kind_of?(DataFabric::ConnectionProxy)
25
+ model_class.record_cache_config[:use_slave] ? db.send(:connection) : db.send(:master)
26
+ else
27
+ db
28
+ end
29
+ end
30
+
31
+ module InstanceMethods
32
+ def invalidate_record_cache
33
+ self.class.each_cached_index do |index|
34
+ index.invalidate_model(self)
35
+ index.clear_deferred
36
+ end
37
+ end
38
+
39
+ def invalidate_record_cache_deferred
40
+ self.class.each_cached_index do |index|
41
+ # Have to invalidate both before and after commit.
42
+ index.invalidate_model(self)
43
+ end
44
+ end
45
+
46
+ def complete_deferred_record_cache_invalidations
47
+ self.class.each_cached_index do |index|
48
+ index.complete_deferred
49
+ end
50
+ end
51
+
52
+ def attr_was(attr)
53
+ attr = attr.to_s
54
+ ['id', 'type'].include?(attr) ? send(attr) : send(:attribute_was, attr)
55
+ end
56
+ end
57
+
58
+ module ClassMethods
59
+ def find_with_caching(*args, &block)
60
+ if args.last.is_a?(Hash)
61
+ args.last.delete_if {|k,v| v.nil?}
62
+ args.pop if args.last.empty?
63
+ end
64
+
65
+ if [:all, :first, :last].include?(args.first)
66
+ opts = args.last
67
+ if opts.is_a?(Hash) and opts.keys == [:conditions]
68
+ # Try to match the SQL.
69
+ if opts[:conditions] =~ /^"?#{table_name}"?.(\w*) = (\d*)$/
70
+ field, value = $1, $2
71
+ index = cached_index("by_#{field}")
72
+ return index.find_by_field([value], self, args.first) if index
73
+ end
74
+ end
75
+ elsif not args.last.is_a?(Hash)
76
+ # This is a find with just ids.
77
+ index = cached_index('by_id')
78
+ return index.find_by_ids(args, self) if index
79
+ end
80
+
81
+ find_without_caching(*args, &block)
82
+ end
83
+
84
+ def update_all_with_invalidate(updates, conditions = nil)
85
+ invalidate_from_conditions(conditions, :update) do |conditions|
86
+ update_all_without_invalidate(updates, conditions)
87
+ end
88
+ end
89
+
90
+ def delete_all_with_invalidate(conditions = nil)
91
+ invalidate_from_conditions(conditions) do |conditions|
92
+ delete_all_without_invalidate(conditions)
93
+ end
94
+ end
95
+
96
+ def invalidate_from_conditions(conditions, flag = nil)
97
+ if conditions.nil?
98
+ # Just invalidate all indexes.
99
+ result = yield(nil)
100
+ self.increment_version
101
+ return result
102
+ end
103
+
104
+ # Freeze ids to avoid race conditions.
105
+ sql = "SELECT id FROM #{table_name} "
106
+ self.send(:add_conditions!, sql, conditions, self.send(:scope, :find))
107
+ ids = RecordCache.db(self).select_values(sql)
108
+
109
+ return if ids.empty?
110
+ conditions = "id IN (#{ids.join(',')})"
111
+
112
+ if block_given?
113
+ # Capture the ids to invalidate in lambdas.
114
+ lambdas = []
115
+ each_cached_index do |index|
116
+ lambdas << index.invalidate_from_conditions_lambda(conditions)
117
+ end
118
+
119
+ result = yield(conditions)
120
+
121
+ # Finish invalidating with prior attributes.
122
+ lambdas.each {|l| l.call}
123
+ end
124
+
125
+ # Invalidate again afterwards if we are updating (or for the first time if no block was given).
126
+ if flag == :update or not block_given?
127
+ each_cached_index do |index|
128
+ index.invalidate_from_conditions(conditions)
129
+ end
130
+ end
131
+
132
+ result
133
+ end
134
+
135
+ def cached_indexes
136
+ @cached_indexes ||= {}
137
+ end
138
+
139
+ def cached_index(name)
140
+ name = name.to_s
141
+ index = cached_indexes[name]
142
+ index ||= base_class.cached_index(name) if base_class != self and base_class.respond_to?(:cached_index)
143
+ index
144
+ end
145
+
146
+ def add_cached_index(index)
147
+ name = index.name
148
+ count = nil
149
+ # Make sure the key is unique.
150
+ while cached_indexes["#{name}#{count}"]
151
+ count ||= 0
152
+ count += 1
153
+ end
154
+ cached_indexes["#{name}#{count}"] = index
155
+ end
156
+
157
+ def each_cached_index
158
+ cached_index_names.each do |index_name|
159
+ yield cached_index(index_name)
160
+ end
161
+ end
162
+
163
+ def cached_index_names
164
+ names = cached_indexes.keys
165
+ names.concat(base_class.cached_index_names) if base_class != self and base_class.respond_to?(:cached_index_names)
166
+ names.uniq
167
+ end
168
+
169
+ def record_cache_config(opts = nil)
170
+ if opts
171
+ record_cache_config.merge!(opts)
172
+ else
173
+ @record_cache_config ||= RecordCache.config.clone
174
+ end
175
+ end
176
+ end
177
+
178
+ module ActiveRecordExtension
179
+ def self.extended(mod)
180
+ mod.send(:class_inheritable_accessor, :cached_indexes)
181
+ end
182
+
183
+ def record_cache(*args)
184
+ extend RecordCache::ClassMethods
185
+ include RecordCache::InstanceMethods
186
+
187
+ opts = args.pop
188
+ opts[:fields] = args
189
+ opts[:class] = self
190
+ field_lookup = opts.delete(:field_lookup) || []
191
+
192
+ index = RecordCache::Index.new(opts)
193
+ add_cached_index(index)
194
+ first_index = (cached_indexes.size == 1)
195
+
196
+ (class << self; self; end).module_eval do
197
+ if index.includes_id?
198
+ [:first, :all, :set, :raw, :ids].each do |type|
199
+ next if type == :ids and index.name == 'by_id'
200
+ define_method( index.find_method_name(type) ) do |keys|
201
+ if self.send(:scope,:find) and self.send(:scope,:find).any?
202
+ self.method_missing(index.find_method_name(type), keys)
203
+ else
204
+ index.find_by_field(keys, self, type)
205
+ end
206
+ end
207
+ end
208
+ end
209
+
210
+ if not index.auto_name? and not index.full_record?
211
+ field = index.fields.first if index.fields.size == 1
212
+
213
+ define_method( "all_#{index.name.pluralize}_by_#{index.index_field}" ) do |keys|
214
+ index.field_lookup(keys, self, field, :all)
215
+ end
216
+
217
+ define_method( "#{index.name.pluralize}_by_#{index.index_field}" ) do |keys|
218
+ index.field_lookup(keys, self, field)
219
+ end
220
+
221
+ define_method( "#{index.name.singularize}_by_#{index.index_field}" ) do |keys|
222
+ index.field_lookup(keys, self, field, :first)
223
+ end
224
+ end
225
+
226
+ if index.auto_name?
227
+ (field_lookup + index.fields).each do |field|
228
+ next if field == index.index_field
229
+ plural_field = field.pluralize
230
+ prefix = index.prefix
231
+ prefix = "#{prefix}_" if prefix
232
+
233
+ define_method( "all_#{prefix}#{plural_field}_by_#{index.index_field}" ) do |keys|
234
+ index.field_lookup(keys, self, field, :all)
235
+ end
236
+
237
+ define_method( "#{prefix}#{plural_field}_by_#{index.index_field}" ) do |keys|
238
+ index.field_lookup(keys, self, field)
239
+ end
240
+
241
+ define_method( "#{prefix}#{field}_by_#{index.index_field}" ) do |keys|
242
+ index.field_lookup(keys, self, field, :first)
243
+ end
244
+ end
245
+ end
246
+
247
+ if first_index
248
+ alias_method_chain :find, :caching
249
+ alias_method_chain :update_all, :invalidate
250
+ alias_method_chain :delete_all, :invalidate
251
+ end
252
+ end
253
+
254
+ if first_index
255
+ after_save :invalidate_record_cache_deferred
256
+ after_destroy :invalidate_record_cache_deferred
257
+ after_commit :complete_deferred_record_cache_invalidations
258
+ after_rollback :complete_deferred_record_cache_invalidations
259
+ end
260
+ end
261
+ end
262
+ end
263
+
264
+ ActiveRecord::Base.send(:extend, RecordCache::ActiveRecordExtension)
@@ -0,0 +1,208 @@
1
+ require File.dirname(__FILE__) + '/test_helper.rb'
2
+
3
+ RecordCache::Set.source_tracking = true
4
+
5
+ class CreateTables < ActiveRecord::Migration
6
+ def self.up
7
+ down
8
+ create_table :pets do |t|
9
+ t.column :breed_id, :bigint
10
+ t.column :name, :string
11
+ t.column :color_id, :bigint
12
+ t.column :sex, :char
13
+ t.column :type, :string
14
+ end
15
+
16
+ create_table :breeds do |t|
17
+ t.column :name, :string
18
+ end
19
+
20
+ create_table :colors do |t|
21
+ t.column :name, :string
22
+ end
23
+ end
24
+
25
+ def self.down
26
+ drop_table :pets rescue nil
27
+ drop_table :breeds rescue nil
28
+ drop_table :colors rescue nil
29
+ end
30
+ end
31
+
32
+ CreateTables.up
33
+
34
+ class Pet < ActiveRecord::Base
35
+ belongs_to :breed
36
+ belongs_to :color
37
+
38
+ record_cache :by => :id
39
+ record_cache :id, :by => :breed_id
40
+ record_cache :id, :by => :color_id, :write_ahead => true
41
+
42
+ record_cache :id, :by => :color_id, :scope => {:sex => 'm'}, :prefix => 'male'
43
+ record_cache :id, :by => :color_id, :scope => {:sex => 'f'}, :prefix => 'female'
44
+ record_cache :id, :by => :color_id, :scope => {:sex => ['m','f']}, :name => 'all_colors'
45
+ end
46
+
47
+ class Dog < Pet
48
+ end
49
+
50
+ class Cat < Pet
51
+ end
52
+
53
+ class Breed < ActiveRecord::Base
54
+ end
55
+
56
+ class Color < ActiveRecord::Base
57
+ end
58
+
59
+ class RecordCacheTest < Test::Unit::TestCase
60
+ context "With a memcache and db connection" do
61
+ setup do
62
+ system('memcached -d')
63
+ CACHE.reset
64
+ CreateTables.up
65
+ CacheVersionMigration.up
66
+ end
67
+
68
+ teardown do
69
+ system('killall memcached')
70
+ CreateTables.down
71
+ CacheVersionMigration.down
72
+ RecordCache::Index.enable_db
73
+ end
74
+
75
+ should 'create field lookup functions' do
76
+ dog = Breed.new(:name => 'pitbull retriever')
77
+ cat = Breed.new(:name => 'house cat')
78
+ willy = Cat.create(:name => 'Willy', :breed => cat)
79
+ daisy = Dog.create(:name => 'Daisy', :breed => dog)
80
+
81
+ expected = {dog.id => daisy.id, cat.id => willy.id}
82
+ assert_equal expected, Pet.id_by_breed_id([dog.id, cat.id, 100, 101])
83
+ end
84
+
85
+ should 'return cached values without accessing the database' do
86
+ color = Color.new(:name => 'black & white')
87
+ dog = Breed.new(:name => 'pitbull retriever')
88
+ cat = Breed.new(:name => 'house cat')
89
+ daisy = Dog.create(:name => 'Daisy', :color => color, :breed => dog)
90
+ willy = Cat.create(:name => 'Willy', :color => color, :breed => cat)
91
+
92
+ Pet.find(daisy.id, willy.id)
93
+ Dog.find_all_by_color_id(color.id)
94
+ Dog.find_all_by_breed_id(dog.id)
95
+
96
+ RecordCache::Index.disable_db
97
+
98
+ assert_equal Dog, Dog.find(daisy.id).class
99
+ assert_equal daisy, Dog.find(daisy.id)
100
+ assert_equal Cat, Cat.find(willy.id).class
101
+ assert_equal willy, Cat.find(willy.id)
102
+ assert_equal [daisy], Dog.find_all_by_color_id(color.id)
103
+ assert_equal [willy], Cat.find_all_by_color_id(color.id)
104
+ assert_equal [daisy], Dog.find_all_by_breed_id(dog.id)
105
+
106
+ RecordCache::Index.enable_db
107
+
108
+ assert_raises(ActiveRecord::RecordNotFound) do
109
+ Dog.find(willy.id)
110
+ end
111
+
112
+ assert_raises(ActiveRecord::RecordNotFound) do
113
+ Cat.find(daisy.id)
114
+ end
115
+ end
116
+
117
+ should 'return multiple cached values without accessing the database' do
118
+ color1 = Color.new(:name => 'black & white')
119
+ color2 = Color.new(:name => 'speckled')
120
+ breed1 = Breed.new(:name => 'pitbull retriever')
121
+ breed2 = Breed.new(:name => 'pitbull terrier')
122
+ daisy = Dog.create(:name => 'Daisy', :color => color1, :breed => breed1)
123
+ sammy = Dog.create(:name => 'Sammy', :color => color1, :breed => breed2)
124
+
125
+ Dog.find(daisy.id, sammy.id)
126
+ Dog.find_all_by_color_id(color1.id)
127
+ Dog.find_all_by_breed_id([breed1.id, breed2.id])
128
+
129
+ RecordCache::Index.disable_db
130
+
131
+ assert_equal [daisy, sammy].to_set, Dog.find(daisy.id, sammy.id).to_set
132
+ assert_equal [daisy, sammy].to_set, Dog.find_all_by_color_id(color1.id).to_set
133
+ assert_equal [daisy, sammy].to_set, Dog.find_all_by_breed_id([breed1.id, breed2.id]).to_set
134
+ assert_equal [sammy, daisy].to_set, Dog.find_all_by_breed_id([breed2.id, breed1.id]).to_set
135
+ assert_equal [daisy].to_set, Dog.find_all_by_breed_id(breed1.id).to_set
136
+
137
+ # Alternate find methods.
138
+ #assert_equal [sammy.id, daisy.id], Dog.find_set_by_breed_id([breed2.id, breed1.id]).ids
139
+ assert_equal [sammy.id, daisy.id].to_set, Dog.find_ids_by_breed_id([breed2.id, breed1.id]).to_set
140
+
141
+ assert_equal daisy, Dog.find_by_color_id(color1.id)
142
+ assert_equal daisy, Dog.find_by_breed_id([breed1.id, breed2.id])
143
+ assert_equal sammy, Dog.find_by_breed_id([breed2.id, breed1.id])
144
+
145
+ baseball = Dog.create(:name => 'Baseball', :color => color2, :breed => breed1)
146
+
147
+ RecordCache::Index.enable_db
148
+
149
+ assert_equal [daisy, baseball], Dog.find_all_by_breed_id(breed1.id)
150
+ end
151
+
152
+ should 'create raw find methods' do
153
+ daisy = Dog.create(:name => 'Daisy')
154
+ sammy = Dog.create(:name => 'Sammy')
155
+
156
+ Dog.find(daisy.id, sammy.id)
157
+ RecordCache::Index.disable_db
158
+
159
+ raw_records = Dog.find_raw_by_id([sammy.id, daisy.id])
160
+ assert_equal ['Sammy', 'Daisy'], raw_records.collect {|r| r['name']}
161
+ end
162
+
163
+ should 'cache indexes using scope' do
164
+ color = Color.new(:name => 'black & white')
165
+ breed1 = Breed.new(:name => 'pitbull retriever')
166
+ breed2 = Breed.new(:name => 'pitbull terrier')
167
+ daisy = Dog.create(:name => 'Daisy', :color => color, :breed => breed1, :sex => 'f')
168
+ sammy = Dog.create(:name => 'Sammy', :color => color, :breed => breed2, :sex => 'm')
169
+
170
+ assert_equal [sammy], Dog.find_all_male_by_color_id(color.id)
171
+ assert_equal [daisy], Dog.find_all_female_by_color_id(color.id)
172
+ assert_equal [daisy, sammy], Dog.find_all_colors(color.id)
173
+
174
+ cousin = Dog.create(:name => 'Cousin', :color => color, :breed => breed2, :sex => 'm')
175
+
176
+ assert_equal [sammy, cousin], Dog.find_all_male_by_color_id(color.id)
177
+ assert_equal [daisy, sammy, cousin], Dog.find_all_colors(color.id)
178
+ end
179
+
180
+ should 'yield cached indexes' do
181
+ count = 0
182
+ Dog.each_cached_index do |index|
183
+ count += 1
184
+ end
185
+ assert_equal 6, count
186
+ end
187
+
188
+ should 'invalidate indexes on save' do
189
+ b_w = Color.new(:name => 'black & white')
190
+ brown = Color.new(:name => 'brown')
191
+ breed = Breed.new(:name => 'mutt')
192
+ daisy = Dog.create(:name => 'Daisy', :color => b_w, :breed => breed, :sex => 'f')
193
+
194
+ assert_equal daisy, Dog.find_by_color_id(b_w.id)
195
+
196
+ daisy.name = 'Molly'
197
+ daisy.color = brown
198
+ daisy.save
199
+
200
+ assert_equal 'Molly', daisy.name
201
+ assert_equal brown.id, daisy.color_id
202
+
203
+ assert_equal daisy, Dog.find_by_color_id(brown.id)
204
+ assert_equal nil, Dog.find_by_color_id(b_w.id)
205
+ end
206
+
207
+ end
208
+ end
@@ -0,0 +1,29 @@
1
+ require 'test/unit'
2
+ require 'rubygems'
3
+ require 'shoulda'
4
+ require 'mocha'
5
+
6
+ $LOAD_PATH.unshift File.dirname(__FILE__) + "/../lib"
7
+ ['deep_clonable', 'ordered_set', 'cache_version', 'model_set', 'memcache', 'deferrable'].each do |dir|
8
+ $LOAD_PATH.unshift File.dirname(__FILE__) + "/../../#{dir}/lib"
9
+ end
10
+
11
+ require 'record_cache'
12
+
13
+ ['lib/after_commit', 'lib/after_commit/active_record', 'lib/after_commit/connection_adapters', 'init'].each do |file|
14
+ require File.dirname(__FILE__) + "/../../after_commit/#{file}"
15
+ end
16
+
17
+ class Test::Unit::TestCase
18
+ end
19
+
20
+ CACHE = Memcache.new(:servers => 'localhost')
21
+ ActiveRecord::Base.establish_connection(
22
+ :adapter => "postgresql",
23
+ :host => "localhost",
24
+ :username => "postgres",
25
+ :password => "",
26
+ :database => "test"
27
+ )
28
+ ActiveRecord::Migration.verbose = false
29
+ ActiveRecord::Base.connection.client_min_messages = 'panic'
metadata ADDED
@@ -0,0 +1,106 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: record_cache
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.9.6
5
+ platform: ruby
6
+ authors:
7
+ - Justin Balthrop
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-11-20 00:00:00 -08:00
13
+ default_executable:
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: after_commit
17
+ type: :runtime
18
+ version_requirement:
19
+ version_requirements: !ruby/object:Gem::Requirement
20
+ requirements:
21
+ - - ">"
22
+ - !ruby/object:Gem::Version
23
+ version: 1.0.0
24
+ version:
25
+ - !ruby/object:Gem::Dependency
26
+ name: deferrable
27
+ type: :runtime
28
+ version_requirement:
29
+ version_requirements: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">"
32
+ - !ruby/object:Gem::Version
33
+ version: 0.1.0
34
+ version:
35
+ - !ruby/object:Gem::Dependency
36
+ name: memcache
37
+ type: :runtime
38
+ version_requirement:
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">"
42
+ - !ruby/object:Gem::Version
43
+ version: 1.0.0
44
+ version:
45
+ - !ruby/object:Gem::Dependency
46
+ name: cache_version
47
+ type: :runtime
48
+ version_requirement:
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">"
52
+ - !ruby/object:Gem::Version
53
+ version: 0.9.4
54
+ version:
55
+ description: Active Record caching and indexing in memcache
56
+ email: code@justinbalthrop.com
57
+ executables: []
58
+
59
+ extensions: []
60
+
61
+ extra_rdoc_files:
62
+ - LICENSE
63
+ - README.rdoc
64
+ files:
65
+ - .gitignore
66
+ - LICENSE
67
+ - README.rdoc
68
+ - Rakefile
69
+ - VERSION
70
+ - lib/record_cache.rb
71
+ - lib/record_cache/index.rb
72
+ - lib/record_cache/scope.rb
73
+ - lib/record_cache/set.rb
74
+ - test/record_cache_test.rb
75
+ - test/test_helper.rb
76
+ has_rdoc: true
77
+ homepage: http://github.com/ninjudd/record_cache
78
+ licenses: []
79
+
80
+ post_install_message:
81
+ rdoc_options:
82
+ - --charset=UTF-8
83
+ require_paths:
84
+ - lib
85
+ required_ruby_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: "0"
90
+ version:
91
+ required_rubygems_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: "0"
96
+ version:
97
+ requirements: []
98
+
99
+ rubyforge_project:
100
+ rubygems_version: 1.3.5
101
+ signing_key:
102
+ specification_version: 3
103
+ summary: Active Record caching and indexing in memcache. An alternative to cache_fu
104
+ test_files:
105
+ - test/record_cache_test.rb
106
+ - test/test_helper.rb