ninjudd-record_cache 0.9.4

Sign up to get free protection for your applications and to get access to all the features.
data/README.rdoc ADDED
@@ -0,0 +1,35 @@
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
+ First, install the after_commit plugin from: http://github.com/ninjudd/after_commit
24
+
25
+ Then install the following gems:
26
+
27
+ sudo gem install ninjudd-deferrable -s http://gems.github.com
28
+ sudo gem install ninjudd-ordered_set -s http://gems.github.com
29
+ sudo gem install ninjudd-memcache -s http://gems.github.com
30
+ sudo gem install ninjudd-cache_version -s http://gems.github.com
31
+ sudo gem install ninjudd-record_cache -s http://gems.github.com
32
+
33
+ == License:
34
+
35
+ Copyright (c) 2009 Justin Balthrop, Geni.com; Published under The MIT License, see LICENSE
data/VERSION.yml ADDED
@@ -0,0 +1,4 @@
1
+ ---
2
+ :patch: 4
3
+ :major: 0
4
+ :minor: 9
@@ -0,0 +1,373 @@
1
+ module RecordCache
2
+ class Index
3
+ include Deferrable
4
+
5
+ attr_reader :model_class, :index_field, :fields, :order_by, :limit, :cache, :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 auto_name?; @auto_name; end
30
+ def write_ahead?; @write_ahead; end
31
+ def disallow_null?; @disallow_null; end
32
+
33
+ def full_record?
34
+ fields.empty?
35
+ end
36
+
37
+ def includes_id?
38
+ full_record? or fields.include?('id')
39
+ end
40
+
41
+ def set_class
42
+ @set_class.constantize
43
+ end
44
+
45
+ def namespace
46
+ "#{model_class.name}_#{model_class.version}_#{RecordCache.version}:#{name}" << ( full_record? ? '' : ":#{fields.join(',')}" )
47
+ end
48
+
49
+ def fields_hash
50
+ if @fields_hash.nil?
51
+ if full_record?
52
+ @fields_hash ||= model_class.column_names.hash
53
+ else
54
+ @fields_hash ||= fields.collect {|field| field.to_s}.hash
55
+ end
56
+ end
57
+ @fields_hash
58
+ end
59
+
60
+ def find_by_ids(ids, model_class)
61
+ expects_array = ids.first.kind_of?(Array)
62
+ ids = ids.flatten.compact.collect {|id| id.to_i}
63
+ ids = stringify(ids)
64
+
65
+ if ids.empty?
66
+ return [] if expects_array
67
+ raise ActiveRecord::RecordNotFound, "Couldn't find #{model_class} without an ID"
68
+ end
69
+
70
+ records_by_id = get_records(ids)
71
+
72
+ models = ids.collect do |id|
73
+ records = records_by_id[id]
74
+ model = records.instantiate_first(model_class, full_record?) if records
75
+
76
+ # try to get record from db again before we throw an exception
77
+ if model.nil?
78
+ invalidate(id)
79
+ records = get_records([id])[id]
80
+ model = records.instantiate_first(model_class, full_record?) if records
81
+ end
82
+
83
+ raise ActiveRecord::RecordNotFound, "Couldn't find #{model_class} with ID #{id}" unless model
84
+ model
85
+ end
86
+
87
+ if models.size == 1 and not expects_array
88
+ models.first
89
+ else
90
+ models
91
+ end
92
+ end
93
+
94
+ def find_by_field(keys, model_class, type)
95
+ keys = [keys] if not keys.kind_of?(Array)
96
+ keys = stringify(keys)
97
+ records_by_key = get_records(keys)
98
+
99
+ case type
100
+ when :first
101
+ keys.each do |key|
102
+ model = records_by_key[key].instantiate_first(model_class, full_record?)
103
+ return model if model
104
+ end
105
+ return nil
106
+ when :all
107
+ models = []
108
+ keys.each do |key|
109
+ models.concat( records_by_key[key].instantiate(model_class, full_record?) )
110
+ end
111
+ models
112
+ when :set, :ids
113
+ ids = []
114
+ keys.each do |key|
115
+ ids.concat( records_by_key[key].ids(model_class) )
116
+ end
117
+ type == :set ? set_class.new(ids) : ids
118
+ when :raw
119
+ raw_records = []
120
+ keys.each do |key|
121
+ raw_records.concat( records_by_key[key].records(model_class) )
122
+ end
123
+ raw_records
124
+ end
125
+ end
126
+
127
+ def field_lookup(keys, model_class, field, flag = nil)
128
+ keys = [*keys]
129
+ keys = stringify(keys)
130
+ field = field.to_s if field
131
+ records_by_key = get_records(keys)
132
+
133
+ field_by_index = {}
134
+ all_fields = [].to_ordered_set
135
+ keys.each do |key|
136
+ records = records_by_key[key]
137
+ fields = field ? records.fields(field, model_class) : records.all_fields(model_class, :except => index_field)
138
+ if flag == :all
139
+ all_fields.concat(fields)
140
+ elsif flag == :first
141
+ next if fields.empty?
142
+ field_by_index[index_column.type_cast(key)] = fields.first
143
+ else
144
+ field_by_index[index_column.type_cast(key)] = fields
145
+ end
146
+ end
147
+ if flag == :all
148
+ all_fields.to_a
149
+ else
150
+ field_by_index
151
+ end
152
+ end
153
+
154
+ def invalidate(*keys)
155
+ keys = stringify(keys)
156
+ cache.in_namespace(namespace) do
157
+ keys.each do |key|
158
+ cache.delete(key)
159
+ end
160
+ end
161
+ end
162
+
163
+ def invalidate_from_conditions_lambda(conditions)
164
+ sql = "SELECT #{index_field} FROM #{table_name} "
165
+ model_class.send(:add_conditions!, sql, conditions, model_class.send(:scope, :find))
166
+ ids = db.select_values(sql)
167
+ lambda { invalidate(*ids) }
168
+ end
169
+
170
+ def invalidate_from_conditions(conditions)
171
+ invalidate_from_conditions_lambda(conditions).call
172
+ end
173
+
174
+ def invalidate_model(model)
175
+ attribute = model.send(index_field)
176
+ attribute_was = model.attr_was(index_field)
177
+ if scope.match_previous?(model)
178
+ if write_ahead?
179
+ remove_from_cache(model)
180
+ else
181
+ now_and_later do
182
+ invalidate(attribute_was)
183
+ end
184
+ end
185
+ end
186
+
187
+ if scope.match_current?(model)
188
+ if write_ahead?
189
+ add_to_cache(model)
190
+ elsif not (scope.match_previous?(model) and attribute_was == attribute)
191
+ now_and_later do
192
+ invalidate(attribute)
193
+ end
194
+ end
195
+ end
196
+ end
197
+
198
+ def scope_query
199
+ @scope_query[:type] ||= model_class.to_s if sub_class?
200
+ @scope_query
201
+ end
202
+
203
+ def scope
204
+ @scope ||= Scope.new(model_class, scope_query)
205
+ end
206
+
207
+ @@disable_db = false
208
+ def self.disable_db
209
+ @@disable_db = true
210
+ end
211
+
212
+ def self.enable_db
213
+ @@disable_db = false
214
+ end
215
+
216
+ def find_method_name(type)
217
+ if name =~ /(^|_)by_/
218
+ if type == :first
219
+ "find_#{name}"
220
+ else
221
+ "find_#{type}_#{name}"
222
+ end
223
+ else
224
+ case type
225
+ when :all
226
+ "find_#{name}"
227
+ when :first
228
+ "find_#{type}_#{name.singularize}"
229
+ else
230
+ "find_#{name.singularize}_#{type}"
231
+ end
232
+ end
233
+ end
234
+
235
+ def cached_set(id)
236
+ # Used for debugging. Gives you the RecordCache::Set that is currently in the cache.
237
+ id = stringify([id]).first
238
+ cache.in_namespace(namespace) do
239
+ cache.get(id)
240
+ end
241
+ end
242
+
243
+ private
244
+
245
+ MAX_FETCH = 1000
246
+ def get_records(keys)
247
+ cache.in_namespace(namespace) do
248
+ opts = {
249
+ :expiry => expiry,
250
+ :disable_write => model_class.record_cache_config[:disable_write],
251
+ :validation => lambda {|key, record_set| record_set.fields_hash == fields_hash},
252
+ }
253
+ cache.get_some(keys, opts) do |keys_to_fetch|
254
+ raise 'db access is disabled' if @@disable_db
255
+ fetched_records = {}
256
+ keys_to_fetch.each do |key|
257
+ fetched_records[key] = RecordCache::Set.new(model_class, fields_hash)
258
+ end
259
+
260
+ keys_to_fetch.each_slice(MAX_FETCH) do |keys_batch|
261
+ sql = "SELECT #{select_fields} FROM #{table_name} WHERE (#{in_clause(keys_batch)})"
262
+ sql << " AND #{scope.conditions}" if not scope.empty?
263
+ sql << " ORDER BY #{order_by}" if order_by
264
+ sql << " LIMIT #{limit}" if limit
265
+
266
+ db.select_all(sql).each do |record|
267
+ key = record[index_field] || NULL
268
+ fetched_records[key] << record
269
+ end
270
+ end
271
+ fetched_records
272
+ end
273
+ end
274
+ end
275
+
276
+ def model_to_record(model)
277
+ sql = "SELECT #{select_fields} FROM #{table_name} WHERE id = #{model.id}"
278
+ db.select_all(sql).first
279
+ end
280
+
281
+ def in_clause(keys)
282
+ conditions = []
283
+ conditions << "#{index_field} IS NULL" if keys.delete(NULL)
284
+
285
+ if keys.any?
286
+ values = keys.collect {|value| quote_index_value(value)}.join(',')
287
+ conditions << "#{index_field} IN (#{values})"
288
+ end
289
+ conditions.join(' OR ')
290
+ end
291
+
292
+ def remove_from_cache(model)
293
+ record = model.attributes
294
+ key = model.attr_was(index_field)
295
+
296
+ cache.in_namespace(namespace) do
297
+ cache.with_lock(key) do
298
+ now_and_later do
299
+ if records = cache.get(key)
300
+ records.delete(record)
301
+ cache.set(key, records)
302
+ end
303
+ end
304
+ end
305
+ end
306
+ end
307
+
308
+ def add_to_cache(model)
309
+ record = model_to_record(model)
310
+ key = record[index_field].to_s
311
+
312
+ cache.in_namespace(namespace) do
313
+ cache.with_lock(key) do
314
+ now_and_later do
315
+ if records = cache.get(key)
316
+ records.delete(record)
317
+ records << record
318
+ records.sort!(order_by) if order_by
319
+ records.limit!(limit) if limit
320
+ cache.set(key, records)
321
+ end
322
+ end
323
+ end
324
+ end
325
+ end
326
+
327
+ def select_fields
328
+ if @select_fields.nil?
329
+ if full_record?
330
+ @select_fields = model_class.respond_to?(:default_select, true) ? model_class.send(:default_select, nil) : '*'
331
+ else
332
+ @select_fields = [index_field, 'id'] + fields
333
+ @select_fields << 'type' if base_class?
334
+ @select_fields = @select_fields.uniq.join(', ')
335
+ end
336
+ end
337
+ @select_fields
338
+ end
339
+
340
+ def base_class?
341
+ @base_class ||= single_table_inheritance? and model_class == model_class.base_class
342
+ end
343
+
344
+ def sub_class?
345
+ @sub_class ||= single_table_inheritance? and model_class != model_class.base_class
346
+ end
347
+
348
+ def single_table_inheritance?
349
+ @single_table_inheritance ||= model_class.columns_hash.has_key?(model_class.inheritance_column)
350
+ end
351
+
352
+ def quote_index_value(value)
353
+ model_class.quote_value(value, index_column)
354
+ end
355
+
356
+ def index_column
357
+ @index_column ||= model_class.columns_hash[index_field]
358
+ end
359
+
360
+ def table_name
361
+ model_class.table_name
362
+ end
363
+
364
+ def stringify(keys)
365
+ keys.compact! if disallow_null?
366
+ keys.collect {|key| key.nil? ? NULL : key.to_s}.uniq
367
+ end
368
+
369
+ def db
370
+ RecordCache.db(model_class)
371
+ end
372
+ end
373
+ 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,265 @@
1
+ require 'rubygems'
2
+ require 'active_record'
3
+ require 'ordered_set'
4
+ require 'memcache_extended'
5
+ require 'cache_version'
6
+ require 'deferrable'
7
+
8
+ $:.unshift(File.dirname(__FILE__))
9
+ require 'record_cache/index'
10
+ require 'record_cache/set'
11
+ require 'record_cache/scope'
12
+
13
+ module RecordCache
14
+ def self.config(opts = nil)
15
+ if opts
16
+ config.merge!(opts)
17
+ else
18
+ @config ||= {}
19
+ end
20
+ end
21
+
22
+ def self.db(model_class)
23
+ # Always use the master connection since we are caching.
24
+ db = model_class.connection
25
+ if defined?(DataFabric::ConnectionProxy) and db.kind_of?(DataFabric::ConnectionProxy)
26
+ model_class.record_cache_config[:use_slave] ? db.send(:connection) : db.send(:master)
27
+ else
28
+ db
29
+ end
30
+ end
31
+
32
+ module InstanceMethods
33
+ def invalidate_record_cache
34
+ self.class.each_cached_index do |index|
35
+ index.invalidate_model(self)
36
+ index.clear_deferred
37
+ end
38
+ end
39
+
40
+ def invalidate_record_cache_deferred
41
+ self.class.each_cached_index do |index|
42
+ # Have to invalidate both before and after commit.
43
+ index.invalidate_model(self)
44
+ end
45
+ end
46
+
47
+ def complete_deferred_record_cache_invalidations
48
+ self.class.each_cached_index do |index|
49
+ index.complete_deferred
50
+ end
51
+ end
52
+
53
+ def attr_was(attr)
54
+ attr = attr.to_s
55
+ ['id', 'type'].include?(attr) ? send(attr) : send(:attribute_was, attr)
56
+ end
57
+ end
58
+
59
+ module ClassMethods
60
+ def find_with_caching(*args, &block)
61
+ if args.last.is_a?(Hash)
62
+ args.last.delete_if {|k,v| v.nil?}
63
+ args.pop if args.last.empty?
64
+ end
65
+
66
+ if [:all, :first, :last].include?(args.first)
67
+ opts = args.last
68
+ if opts.is_a?(Hash) and opts.keys == [:conditions]
69
+ # Try to match the SQL.
70
+ if opts[:conditions] =~ /^"?#{table_name}"?.(\w*) = (\d*)$/
71
+ field, value = $1, $2
72
+ index = cached_index("by_#{field}")
73
+ return index.find_by_field([value], self, args.first) if index
74
+ end
75
+ end
76
+ elsif not args.last.is_a?(Hash)
77
+ # This is a find with just ids.
78
+ index = cached_index('by_id')
79
+ return index.find_by_ids(args, self) if index
80
+ end
81
+
82
+ find_without_caching(*args, &block)
83
+ end
84
+
85
+ def update_all_with_invalidate(updates, conditions = nil)
86
+ invalidate_from_conditions(conditions, :update) do |conditions|
87
+ update_all_without_invalidate(updates, conditions)
88
+ end
89
+ end
90
+
91
+ def delete_all_with_invalidate(conditions = nil)
92
+ invalidate_from_conditions(conditions) do |conditions|
93
+ delete_all_without_invalidate(conditions)
94
+ end
95
+ end
96
+
97
+ def invalidate_from_conditions(conditions, flag = nil)
98
+ if conditions.nil?
99
+ # Just invalidate all indexes.
100
+ result = yield(nil)
101
+ self.increment_version
102
+ return result
103
+ end
104
+
105
+ # Freeze ids to avoid race conditions.
106
+ sql = "SELECT id FROM #{table_name} "
107
+ self.send(:add_conditions!, sql, conditions, self.send(:scope, :find))
108
+ ids = RecordCache.db(self).select_values(sql)
109
+
110
+ return if ids.empty?
111
+ conditions = "id IN (#{ids.join(',')})"
112
+
113
+ if block_given?
114
+ # Capture the ids to invalidate in lambdas.
115
+ lambdas = []
116
+ each_cached_index do |index|
117
+ lambdas << index.invalidate_from_conditions_lambda(conditions)
118
+ end
119
+
120
+ result = yield(conditions)
121
+
122
+ # Finish invalidating with prior attributes.
123
+ lambdas.each {|l| l.call}
124
+ end
125
+
126
+ # Invalidate again afterwards if we are updating (or for the first time if no block was given).
127
+ if flag == :update or not block_given?
128
+ each_cached_index do |index|
129
+ index.invalidate_from_conditions(conditions)
130
+ end
131
+ end
132
+
133
+ result
134
+ end
135
+
136
+ def cached_indexes
137
+ @cached_indexes ||= {}
138
+ end
139
+
140
+ def cached_index(name)
141
+ name = name.to_s
142
+ index = cached_indexes[name]
143
+ index ||= base_class.cached_index(name) if base_class != self and base_class.respond_to?(:cached_index)
144
+ index
145
+ end
146
+
147
+ def add_cached_index(index)
148
+ name = index.name
149
+ count = nil
150
+ # Make sure the key is unique.
151
+ while cached_indexes["#{name}#{count}"]
152
+ count ||= 0
153
+ count += 1
154
+ end
155
+ cached_indexes["#{name}#{count}"] = index
156
+ end
157
+
158
+ def each_cached_index
159
+ cached_index_names.each do |index_name|
160
+ yield cached_index(index_name)
161
+ end
162
+ end
163
+
164
+ def cached_index_names
165
+ names = cached_indexes.keys
166
+ names.concat(base_class.cached_index_names) if base_class != self and base_class.respond_to?(:cached_index_names)
167
+ names.uniq
168
+ end
169
+
170
+ def record_cache_config(opts = nil)
171
+ if opts
172
+ record_cache_config.merge!(opts)
173
+ else
174
+ @record_cache_config ||= RecordCache.config.clone
175
+ end
176
+ end
177
+ end
178
+
179
+ module ActiveRecordExtension
180
+ def self.extended(mod)
181
+ mod.send(:class_inheritable_accessor, :cached_indexes)
182
+ end
183
+
184
+ def record_cache(*args)
185
+ extend RecordCache::ClassMethods
186
+ include RecordCache::InstanceMethods
187
+
188
+ opts = args.pop
189
+ opts[:fields] = args
190
+ opts[:class] = self
191
+ field_lookup = opts.delete(:field_lookup) || []
192
+
193
+ index = RecordCache::Index.new(opts)
194
+ add_cached_index(index)
195
+ first_index = (cached_indexes.size == 1)
196
+
197
+ (class << self; self; end).module_eval do
198
+ if index.includes_id?
199
+ [:first, :all, :set, :raw, :ids].each do |type|
200
+ next if type == :ids and index.name == 'by_id'
201
+ define_method( index.find_method_name(type) ) do |keys|
202
+ if self.send(:scope,:find) and self.send(:scope,:find).any?
203
+ self.method_missing(index.find_method_name(type), keys)
204
+ else
205
+ index.find_by_field(keys, self, type)
206
+ end
207
+ end
208
+ end
209
+ end
210
+
211
+ if not index.auto_name? and not index.full_record?
212
+ field = index.fields.first if index.fields.size == 1
213
+
214
+ define_method( "all_#{index.name.pluralize}_by_#{index.index_field}" ) do |keys|
215
+ index.field_lookup(keys, self, field, :all)
216
+ end
217
+
218
+ define_method( "#{index.name.pluralize}_by_#{index.index_field}" ) do |keys|
219
+ index.field_lookup(keys, self, field)
220
+ end
221
+
222
+ define_method( "#{index.name.singularize}_by_#{index.index_field}" ) do |keys|
223
+ index.field_lookup(keys, self, field, :first)
224
+ end
225
+ end
226
+
227
+ if index.auto_name?
228
+ (field_lookup + index.fields).each do |field|
229
+ next if field == index.index_field
230
+ plural_field = field.pluralize
231
+ prefix = index.prefix
232
+ prefix = "#{prefix}_" if prefix
233
+
234
+ define_method( "all_#{prefix}#{plural_field}_by_#{index.index_field}" ) do |keys|
235
+ index.field_lookup(keys, self, field, :all)
236
+ end
237
+
238
+ define_method( "#{prefix}#{plural_field}_by_#{index.index_field}" ) do |keys|
239
+ index.field_lookup(keys, self, field)
240
+ end
241
+
242
+ define_method( "#{prefix}#{field}_by_#{index.index_field}" ) do |keys|
243
+ index.field_lookup(keys, self, field, :first)
244
+ end
245
+ end
246
+ end
247
+
248
+ if first_index
249
+ alias_method_chain :find, :caching
250
+ alias_method_chain :update_all, :invalidate
251
+ alias_method_chain :delete_all, :invalidate
252
+ end
253
+ end
254
+
255
+ if first_index
256
+ after_save :invalidate_record_cache_deferred
257
+ after_destroy :invalidate_record_cache_deferred
258
+ after_commit :complete_deferred_record_cache_invalidations
259
+ after_rollback :complete_deferred_record_cache_invalidations
260
+ end
261
+ end
262
+ end
263
+ end
264
+
265
+ ActiveRecord::Base.send(:extend, RecordCache::ActiveRecordExtension)
@@ -0,0 +1,210 @@
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.servers = ["localhost:11211"]
64
+
65
+ CreateTables.up
66
+ CacheVersionMigration.up
67
+ end
68
+
69
+ teardown do
70
+ system('killall memcached')
71
+
72
+ CreateTables.down
73
+ CacheVersionMigration.down
74
+ RecordCache::Index.enable_db
75
+ end
76
+
77
+ should 'create field lookup functions' do
78
+ dog = Breed.new(:name => 'pitbull retriever')
79
+ cat = Breed.new(:name => 'house cat')
80
+ willy = Cat.create(:name => 'Willy', :breed => cat)
81
+ daisy = Dog.create(:name => 'Daisy', :breed => dog)
82
+
83
+ expected = {dog.id => daisy.id, cat.id => willy.id}
84
+ assert_equal expected, Pet.id_by_breed_id([dog.id, cat.id, 100, 101])
85
+ end
86
+
87
+ should 'return cached values without accessing the database' do
88
+ color = Color.new(:name => 'black & white')
89
+ dog = Breed.new(:name => 'pitbull retriever')
90
+ cat = Breed.new(:name => 'house cat')
91
+ daisy = Dog.create(:name => 'Daisy', :color => color, :breed => dog)
92
+ willy = Cat.create(:name => 'Willy', :color => color, :breed => cat)
93
+
94
+ Pet.find(daisy.id, willy.id)
95
+ Dog.find_all_by_color_id(color.id)
96
+ Dog.find_all_by_breed_id(dog.id)
97
+
98
+ RecordCache::Index.disable_db
99
+
100
+ assert_equal Dog, Dog.find(daisy.id).class
101
+ assert_equal daisy, Dog.find(daisy.id)
102
+ assert_equal Cat, Cat.find(willy.id).class
103
+ assert_equal willy, Cat.find(willy.id)
104
+ assert_equal [daisy], Dog.find_all_by_color_id(color.id)
105
+ assert_equal [willy], Cat.find_all_by_color_id(color.id)
106
+ assert_equal [daisy], Dog.find_all_by_breed_id(dog.id)
107
+
108
+ RecordCache::Index.enable_db
109
+
110
+ assert_raises(ActiveRecord::RecordNotFound) do
111
+ Dog.find(willy.id)
112
+ end
113
+
114
+ assert_raises(ActiveRecord::RecordNotFound) do
115
+ Cat.find(daisy.id)
116
+ end
117
+ end
118
+
119
+ should 'return multiple cached values without accessing the database' do
120
+ color1 = Color.new(:name => 'black & white')
121
+ color2 = Color.new(:name => 'speckled')
122
+ breed1 = Breed.new(:name => 'pitbull retriever')
123
+ breed2 = Breed.new(:name => 'pitbull terrier')
124
+ daisy = Dog.create(:name => 'Daisy', :color => color1, :breed => breed1)
125
+ sammy = Dog.create(:name => 'Sammy', :color => color1, :breed => breed2)
126
+
127
+ Dog.find(daisy.id, sammy.id)
128
+ Dog.find_all_by_color_id(color1.id)
129
+ Dog.find_all_by_breed_id([breed1.id, breed2.id])
130
+
131
+ RecordCache::Index.disable_db
132
+
133
+ assert_equal [daisy, sammy].to_set, Dog.find(daisy.id, sammy.id).to_set
134
+ assert_equal [daisy, sammy].to_set, Dog.find_all_by_color_id(color1.id).to_set
135
+ assert_equal [daisy, sammy].to_set, Dog.find_all_by_breed_id([breed1.id, breed2.id]).to_set
136
+ assert_equal [sammy, daisy].to_set, Dog.find_all_by_breed_id([breed2.id, breed1.id]).to_set
137
+ assert_equal [daisy].to_set, Dog.find_all_by_breed_id(breed1.id).to_set
138
+
139
+ # Alternate find methods.
140
+ #assert_equal [sammy.id, daisy.id], Dog.find_set_by_breed_id([breed2.id, breed1.id]).ids
141
+ assert_equal [sammy.id, daisy.id].to_set, Dog.find_ids_by_breed_id([breed2.id, breed1.id]).to_set
142
+
143
+ assert_equal daisy, Dog.find_by_color_id(color1.id)
144
+ assert_equal daisy, Dog.find_by_breed_id([breed1.id, breed2.id])
145
+ assert_equal sammy, Dog.find_by_breed_id([breed2.id, breed1.id])
146
+
147
+ baseball = Dog.create(:name => 'Baseball', :color => color2, :breed => breed1)
148
+
149
+ RecordCache::Index.enable_db
150
+
151
+ assert_equal [daisy, baseball], Dog.find_all_by_breed_id(breed1.id)
152
+ end
153
+
154
+ should 'create raw find methods' do
155
+ daisy = Dog.create(:name => 'Daisy')
156
+ sammy = Dog.create(:name => 'Sammy')
157
+
158
+ Dog.find(daisy.id, sammy.id)
159
+ RecordCache::Index.disable_db
160
+
161
+ raw_records = Dog.find_raw_by_id([sammy.id, daisy.id])
162
+ assert_equal ['Sammy', 'Daisy'], raw_records.collect {|r| r['name']}
163
+ end
164
+
165
+ should 'cache indexes using scope' do
166
+ color = Color.new(:name => 'black & white')
167
+ breed1 = Breed.new(:name => 'pitbull retriever')
168
+ breed2 = Breed.new(:name => 'pitbull terrier')
169
+ daisy = Dog.create(:name => 'Daisy', :color => color, :breed => breed1, :sex => 'f')
170
+ sammy = Dog.create(:name => 'Sammy', :color => color, :breed => breed2, :sex => 'm')
171
+
172
+ assert_equal [sammy], Dog.find_all_male_by_color_id(color.id)
173
+ assert_equal [daisy], Dog.find_all_female_by_color_id(color.id)
174
+ assert_equal [daisy, sammy], Dog.find_all_colors(color.id)
175
+
176
+ cousin = Dog.create(:name => 'Cousin', :color => color, :breed => breed2, :sex => 'm')
177
+
178
+ assert_equal [sammy, cousin], Dog.find_all_male_by_color_id(color.id)
179
+ assert_equal [daisy, sammy, cousin], Dog.find_all_colors(color.id)
180
+ end
181
+
182
+ should 'yield cached indexes' do
183
+ count = 0
184
+ Dog.each_cached_index do |index|
185
+ count += 1
186
+ end
187
+ assert_equal 6, count
188
+ end
189
+
190
+ should 'invalidate indexes on save' do
191
+ b_w = Color.new(:name => 'black & white')
192
+ brown = Color.new(:name => 'brown')
193
+ breed = Breed.new(:name => 'mutt')
194
+ daisy = Dog.create(:name => 'Daisy', :color => b_w, :breed => breed, :sex => 'f')
195
+
196
+ assert_equal daisy, Dog.find_by_color_id(b_w.id)
197
+
198
+ daisy.name = 'Molly'
199
+ daisy.color = brown
200
+ daisy.save
201
+
202
+ assert_equal 'Molly', daisy.name
203
+ assert_equal brown.id, daisy.color_id
204
+
205
+ assert_equal daisy, Dog.find_by_color_id(brown.id)
206
+ assert_equal nil, Dog.find_by_color_id(b_w.id)
207
+ end
208
+
209
+ end
210
+ end
@@ -0,0 +1,38 @@
1
+ require 'test/unit'
2
+ require 'rubygems'
3
+ require 'shoulda'
4
+ require 'mocha'
5
+
6
+ require 'active_record'
7
+
8
+ $LOAD_PATH.unshift File.dirname(__FILE__) + "/../lib"
9
+ ['deep_clonable', 'ordered_set', 'cache_version', 'model_set', 'memcache', 'deferrable'].each do |dir|
10
+ $LOAD_PATH.unshift File.dirname(__FILE__) + "/../../#{dir}/lib"
11
+ end
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
+ require 'record_cache'
18
+
19
+ class Test::Unit::TestCase
20
+ end
21
+
22
+ CACHE = MemCache.new(
23
+ :ttl=>1800,
24
+ :compression=>false,
25
+ :readonly=>false,
26
+ :debug=>false,
27
+ :c_threshold=>10000,
28
+ :urlencode=>false
29
+ )
30
+ ActiveRecord::Base.establish_connection(
31
+ :adapter => "postgresql",
32
+ :host => "localhost",
33
+ :username => "postgres",
34
+ :password => "",
35
+ :database => "record_cache_test"
36
+ )
37
+ ActiveRecord::Migration.verbose = false
38
+ ActiveRecord::Base.connection.client_min_messages = 'panic'
metadata ADDED
@@ -0,0 +1,62 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ninjudd-record_cache
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.9.4
5
+ platform: ruby
6
+ authors:
7
+ - Justin Balthrop
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-05-15 00:00:00 -07:00
13
+ default_executable:
14
+ dependencies: []
15
+
16
+ description: Active Record caching and indexing in memcache
17
+ email: code@justinbalthrop.com
18
+ executables: []
19
+
20
+ extensions: []
21
+
22
+ extra_rdoc_files: []
23
+
24
+ files:
25
+ - README.rdoc
26
+ - VERSION.yml
27
+ - lib/record_cache
28
+ - lib/record_cache/index.rb
29
+ - lib/record_cache/scope.rb
30
+ - lib/record_cache/set.rb
31
+ - lib/record_cache.rb
32
+ - test/record_cache_test.rb
33
+ - test/test_helper.rb
34
+ has_rdoc: true
35
+ homepage: http://github.com/ninjudd/record_cache
36
+ post_install_message:
37
+ rdoc_options:
38
+ - --inline-source
39
+ - --charset=UTF-8
40
+ require_paths:
41
+ - lib
42
+ required_ruby_version: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: "0"
47
+ version:
48
+ required_rubygems_version: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: "0"
53
+ version:
54
+ requirements: []
55
+
56
+ rubyforge_project:
57
+ rubygems_version: 1.2.0
58
+ signing_key:
59
+ specification_version: 2
60
+ summary: Active Record caching and indexing in memcache. An alternative to cache_fu
61
+ test_files: []
62
+