ninjudd-record_cache 0.9.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.
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
+