kasket 0.5.2
Sign up to get free protection for your applications and to get access to all the features.
- data/.document +5 -0
- data/.gitignore +23 -0
- data/LICENSE +20 -0
- data/README.rdoc +17 -0
- data/Rakefile +55 -0
- data/VERSION +1 -0
- data/lib/kasket.rb +47 -0
- data/lib/kasket/cache.rb +82 -0
- data/lib/kasket/conditions_parser.rb +79 -0
- data/lib/kasket/configuration_mixin.rb +49 -0
- data/lib/kasket/dirty_mixin.rb +18 -0
- data/lib/kasket/read_mixin.rb +92 -0
- data/lib/kasket/reload_association_mixin.rb +19 -0
- data/lib/kasket/write_mixin.rb +79 -0
- data/test/cache_expiry_test.rb +55 -0
- data/test/database.yml +7 -0
- data/test/dirty_test.rb +19 -0
- data/test/find_one_test.rb +44 -0
- data/test/find_some_test.rb +49 -0
- data/test/fixtures/blogs.yml +5 -0
- data/test/fixtures/comments.yml +14 -0
- data/test/fixtures/posts.yml +15 -0
- data/test/helper.rb +67 -0
- data/test/schema.rb +34 -0
- data/test/serialization_test.rb +21 -0
- metadata +105 -0
data/.document
ADDED
data/.gitignore
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2009 Mick Staugaard
|
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,17 @@
|
|
1
|
+
= kasket
|
2
|
+
|
3
|
+
Description goes here.
|
4
|
+
|
5
|
+
== Note on Patches/Pull Requests
|
6
|
+
|
7
|
+
* Fork the project.
|
8
|
+
* Make your feature addition or bug fix.
|
9
|
+
* Add tests for it. This is important so I don't break it in a
|
10
|
+
future version unintentionally.
|
11
|
+
* Commit, do not mess with rakefile, version, or history.
|
12
|
+
(if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
|
13
|
+
* Send me a pull request. Bonus points for topic branches.
|
14
|
+
|
15
|
+
== Copyright
|
16
|
+
|
17
|
+
Copyright (c) 2009 Mick Staugaard. See LICENSE for details.
|
data/Rakefile
ADDED
@@ -0,0 +1,55 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rake'
|
3
|
+
|
4
|
+
begin
|
5
|
+
require 'jeweler'
|
6
|
+
Jeweler::Tasks.new do |gem|
|
7
|
+
gem.name = "kasket"
|
8
|
+
gem.summary = %Q{A write back caching layer on active record}
|
9
|
+
gem.description = %Q{A rewrite of cache money}
|
10
|
+
gem.email = "mick@staugaard.com"
|
11
|
+
gem.homepage = "http://github.com/staugaard/kasket"
|
12
|
+
gem.authors = ["Mick Staugaard"]
|
13
|
+
gem.add_development_dependency "thoughtbot-shoulda", ">= 0"
|
14
|
+
gem.add_development_dependency "mocha", ">= 0"
|
15
|
+
# gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
|
16
|
+
end
|
17
|
+
Jeweler::GemcutterTasks.new
|
18
|
+
rescue LoadError
|
19
|
+
puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler"
|
20
|
+
end
|
21
|
+
|
22
|
+
require 'rake/testtask'
|
23
|
+
Rake::TestTask.new(:test) do |test|
|
24
|
+
test.libs << 'lib' << 'test'
|
25
|
+
test.pattern = 'test/**/*_test.rb'
|
26
|
+
test.verbose = true
|
27
|
+
end
|
28
|
+
|
29
|
+
begin
|
30
|
+
require 'rcov/rcovtask'
|
31
|
+
Rcov::RcovTask.new do |test|
|
32
|
+
test.libs << 'test'
|
33
|
+
test.pattern = 'test/**/*_test.rb'
|
34
|
+
test.rcov_opts << "--exclude \"test/*,gems/*,/Library/Ruby/*,config/*\" --rails"
|
35
|
+
test.verbose = true
|
36
|
+
end
|
37
|
+
rescue LoadError
|
38
|
+
task :rcov do
|
39
|
+
abort "RCov is not available. In order to run rcov, you must: sudo gem install spicycode-rcov"
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
task :test => :check_dependencies
|
44
|
+
|
45
|
+
task :default => :test
|
46
|
+
|
47
|
+
require 'rake/rdoctask'
|
48
|
+
Rake::RDocTask.new do |rdoc|
|
49
|
+
version = File.exist?('VERSION') ? File.read('VERSION') : ""
|
50
|
+
|
51
|
+
rdoc.rdoc_dir = 'rdoc'
|
52
|
+
rdoc.title = "kasket #{version}"
|
53
|
+
rdoc.rdoc_files.include('README*')
|
54
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
55
|
+
end
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
0.5.2
|
data/lib/kasket.rb
ADDED
@@ -0,0 +1,47 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'active_record'
|
3
|
+
require 'active_support'
|
4
|
+
|
5
|
+
require 'kasket/configuration_mixin'
|
6
|
+
require 'kasket/reload_association_mixin'
|
7
|
+
require 'kasket/cache'
|
8
|
+
|
9
|
+
module Kasket
|
10
|
+
CONFIGURATION = {:max_collection_size => 100}
|
11
|
+
|
12
|
+
module_function
|
13
|
+
|
14
|
+
def cache
|
15
|
+
Thread.current["kasket_cache"] ||= Cache.new
|
16
|
+
end
|
17
|
+
|
18
|
+
def setup(options = {})
|
19
|
+
CONFIGURATION[:max_collection_size] = options[:max_collection_size] if options[:max_collection_size]
|
20
|
+
|
21
|
+
ActiveRecord::Base.extend(Kasket::ConfigurationMixin)
|
22
|
+
ActiveRecord::Associations::BelongsToAssociation.send(:include, Kasket::ReloadAssociationMixin)
|
23
|
+
ActiveRecord::Associations::BelongsToPolymorphicAssociation.send(:include, Kasket::ReloadAssociationMixin)
|
24
|
+
ActiveRecord::Associations::HasOneThroughAssociation.send(:include, Kasket::ReloadAssociationMixin)
|
25
|
+
|
26
|
+
#sets up local cache clearing after each request
|
27
|
+
begin
|
28
|
+
ApplicationController.after_filter do
|
29
|
+
Kasket.cache.clear_local
|
30
|
+
end
|
31
|
+
rescue NameError => e
|
32
|
+
|
33
|
+
end
|
34
|
+
|
35
|
+
#sets up local cache clearing after each test case
|
36
|
+
begin
|
37
|
+
ActiveSupport::TestCase.class_eval do
|
38
|
+
setup :clear_cache
|
39
|
+
def clear_cache
|
40
|
+
Kasket.cache.clear_local
|
41
|
+
Rails.cache.clear if Rails.cache.respond_to?(:clear)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
rescue NameError => e
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
data/lib/kasket/cache.rb
ADDED
@@ -0,0 +1,82 @@
|
|
1
|
+
module Kasket
|
2
|
+
class Cache
|
3
|
+
def initialize
|
4
|
+
clear_local
|
5
|
+
end
|
6
|
+
|
7
|
+
def read(*args)
|
8
|
+
result = @local_cache[args[0]] || Rails.cache.read(*args)
|
9
|
+
if result.is_a?(CachedModel)
|
10
|
+
result = result.instanciate_model
|
11
|
+
elsif result.is_a?(Array)
|
12
|
+
models = get_multi(result)
|
13
|
+
result = result.map { |key| models[key]}
|
14
|
+
end
|
15
|
+
|
16
|
+
@local_cache[args[0]] = result if result
|
17
|
+
result
|
18
|
+
end
|
19
|
+
|
20
|
+
def get_multi(keys)
|
21
|
+
map = Hash[keys.zip(keys.map { |key| @local_cache[key] })]
|
22
|
+
missing_keys = map.select { |key, value| value.nil? }.map(&:first)
|
23
|
+
|
24
|
+
unless missing_keys.empty?
|
25
|
+
if Rails.cache.respond_to?(:read_multi)
|
26
|
+
missing_map = Rails.cache.read_multi(missing_keys)
|
27
|
+
missing_map.each do |key, value|
|
28
|
+
value = value.instanciate_model if value.is_a?(CachedModel)
|
29
|
+
missing_map[key] = @local_cache[key] = value
|
30
|
+
end
|
31
|
+
map.merge!(missing_map)
|
32
|
+
else
|
33
|
+
missing_keys.each do |key|
|
34
|
+
map[key] = read(key)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
map
|
40
|
+
end
|
41
|
+
|
42
|
+
def write(*args)
|
43
|
+
@local_cache[args[0]] = args[1]
|
44
|
+
|
45
|
+
if args[1].is_a?(ActiveRecord::Base)
|
46
|
+
args[1] = CachedModel.new(args[1])
|
47
|
+
end
|
48
|
+
|
49
|
+
Rails.cache.write(*args)
|
50
|
+
end
|
51
|
+
|
52
|
+
def delete(*args)
|
53
|
+
@local_cache.delete(args[0])
|
54
|
+
Rails.cache.delete(*args)
|
55
|
+
end
|
56
|
+
|
57
|
+
def delete_local(*keys)
|
58
|
+
keys.each do |key|
|
59
|
+
@local_cache.delete(key)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def delete_matched_local(matcher)
|
64
|
+
@local_cache.delete_if { |k,v| k =~ matcher }
|
65
|
+
end
|
66
|
+
|
67
|
+
def clear_local
|
68
|
+
@local_cache = {}
|
69
|
+
end
|
70
|
+
|
71
|
+
class CachedModel
|
72
|
+
def initialize(model)
|
73
|
+
@name = model.class.name
|
74
|
+
@attributes = model.instance_variable_get(:@attributes)
|
75
|
+
end
|
76
|
+
|
77
|
+
def instanciate_model
|
78
|
+
@name.constantize.send(:instantiate, @attributes)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
module Kasket
|
2
|
+
class ConditionsParser
|
3
|
+
def initialize(model_class)
|
4
|
+
@model_class = model_class
|
5
|
+
end
|
6
|
+
|
7
|
+
def attribute_value_pairs(options)
|
8
|
+
#pulls out the conditions from each hash
|
9
|
+
condition_fragments = [options[:conditions]]
|
10
|
+
|
11
|
+
#add the scope to the mix
|
12
|
+
if scope = @model_class.send(:scope, :find)
|
13
|
+
condition_fragments << scope[:conditions]
|
14
|
+
end
|
15
|
+
|
16
|
+
#add the type if we are on STI
|
17
|
+
condition_fragments << {:type => @model_class.name} if @model_class.finder_needs_type_condition?
|
18
|
+
|
19
|
+
condition_fragments.compact!
|
20
|
+
|
21
|
+
#parses each conditions fragment but bails if one of the did not parse
|
22
|
+
attributes_fragments = condition_fragments.map do |condition_fragment|
|
23
|
+
attributes_fragment = attributes_for_conditions(condition_fragment)
|
24
|
+
return nil unless attributes_fragment
|
25
|
+
attributes_fragment
|
26
|
+
end
|
27
|
+
|
28
|
+
#merges the hashes but bails if there is an overlap
|
29
|
+
attributes = attributes_fragments.inject({}) do |memo, attributes_fragment|
|
30
|
+
attributes_fragment.each do |attribute, value|
|
31
|
+
return nil if memo.has_key?(attribute)
|
32
|
+
memo[attribute] = value
|
33
|
+
end
|
34
|
+
memo
|
35
|
+
end
|
36
|
+
|
37
|
+
attributes.keys.sort.map { |attribute| [attribute.to_sym, attributes[attribute]] }
|
38
|
+
end
|
39
|
+
|
40
|
+
def attributes_for_conditions(conditions)
|
41
|
+
pairs = case conditions
|
42
|
+
when Hash
|
43
|
+
return conditions.stringify_keys
|
44
|
+
when String
|
45
|
+
parse_indices_from_condition(conditions)
|
46
|
+
when Array
|
47
|
+
parse_indices_from_condition(*conditions)
|
48
|
+
when NilClass
|
49
|
+
[]
|
50
|
+
end
|
51
|
+
|
52
|
+
return nil unless pairs
|
53
|
+
|
54
|
+
pairs.inject({}) do |memo, pair|
|
55
|
+
return nil if memo.has_key?(pair[0])
|
56
|
+
memo[pair[0]] = pair[1]
|
57
|
+
memo
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
AND = /\s+AND\s+/i
|
62
|
+
TABLE_AND_COLUMN = /(?:(?:`|")?(\w+)(?:`|")?\.)?(?:`|")?(\w+)(?:`|")?/ # Matches: `users`.id, `users`.`id`, users.id, id
|
63
|
+
VALUE = /'?(\d+|\?|(?:(?:[^']|'')*))'?/ # Matches: 123, ?, '123', '12''3'
|
64
|
+
KEY_EQ_VALUE = /^[\(\s]*#{TABLE_AND_COLUMN}\s+=\s+#{VALUE}[\)\s]*$/ # Matches: KEY = VALUE, (KEY = VALUE), ()(KEY = VALUE))
|
65
|
+
|
66
|
+
def parse_indices_from_condition(conditions = '', *values)
|
67
|
+
values = values.dup
|
68
|
+
conditions.split(AND).inject([]) do |indices, condition|
|
69
|
+
matched, table_name, column_name, sql_value = *(KEY_EQ_VALUE.match(condition))
|
70
|
+
if matched
|
71
|
+
value = sql_value == '?' ? values.shift : @model_class.columns_hash[column_name].type_cast(sql_value)
|
72
|
+
indices << [column_name, value]
|
73
|
+
else
|
74
|
+
return nil
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
require 'active_support'
|
2
|
+
|
3
|
+
module Kasket
|
4
|
+
autoload :ReadMixin, 'kasket/read_mixin'
|
5
|
+
autoload :WriteMixin, 'kasket/write_mixin'
|
6
|
+
autoload :DirtyMixin, 'kasket/dirty_mixin'
|
7
|
+
|
8
|
+
module ConfigurationMixin
|
9
|
+
def kasket_key_prefix
|
10
|
+
@kasket_key_prefix ||= "kasket/#{table_name}/version=#{column_names.join.sum}/"
|
11
|
+
end
|
12
|
+
|
13
|
+
def kasket_key_for(attribute_value_pairs)
|
14
|
+
kasket_key_prefix + attribute_value_pairs.map {|attribute, value| attribute.to_s + '=' + value.to_s}.join('/')
|
15
|
+
end
|
16
|
+
|
17
|
+
def kasket_key_for_id(id)
|
18
|
+
kasket_key_for([['id', id]])
|
19
|
+
end
|
20
|
+
|
21
|
+
def kasket_indices
|
22
|
+
result = @kasket_indices || []
|
23
|
+
result += superclass.kasket_indices unless self == ActiveRecord::Base
|
24
|
+
result.uniq
|
25
|
+
end
|
26
|
+
|
27
|
+
def has_kasket_index_on?(sorted_attributes)
|
28
|
+
kasket_indices.include?(sorted_attributes)
|
29
|
+
end
|
30
|
+
|
31
|
+
def has_kasket(options = {})
|
32
|
+
has_kasket_on :id
|
33
|
+
end
|
34
|
+
|
35
|
+
def has_kasket_on(*args)
|
36
|
+
attributes = args.sort! { |x, y| x.to_s <=> y.to_s }
|
37
|
+
if attributes != [:id] && !kasket_indices.include?([:id])
|
38
|
+
has_kasket_on(:id)
|
39
|
+
end
|
40
|
+
|
41
|
+
@kasket_indices ||= []
|
42
|
+
@kasket_indices << attributes unless @kasket_indices.include?(attributes)
|
43
|
+
|
44
|
+
include WriteMixin unless instance_methods.include?('store_in_kasket')
|
45
|
+
extend ReadMixin unless methods.include?('without_kasket')
|
46
|
+
extend DirtyMixin unless methods.include?('kasket_dirty_methods')
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module Kasket
|
2
|
+
module DirtyMixin
|
3
|
+
def kasket_dirty_methods(*method_names)
|
4
|
+
method_names.each do |method|
|
5
|
+
unless method_defined?("without_kasket_update_#{method}")
|
6
|
+
alias_method("without_kasket_update_#{method}", method)
|
7
|
+
define_method(method) do |*args|
|
8
|
+
result = send("without_kasket_update_#{method}", *args)
|
9
|
+
clear_kasket_indices
|
10
|
+
result
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
alias_method :kasket_dirty_method, :kasket_dirty_methods
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
require 'kasket/conditions_parser'
|
2
|
+
|
3
|
+
module Kasket
|
4
|
+
module ReadMixin
|
5
|
+
|
6
|
+
def self.extended(model_class)
|
7
|
+
class << model_class
|
8
|
+
alias_method_chain :find_some, :kasket unless methods.include?('find_some_without_kasket')
|
9
|
+
alias_method_chain :find_every, :kasket unless methods.include?('find_every_without_kasket')
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
def without_kasket(&block)
|
14
|
+
old_value = @use_kasket || true
|
15
|
+
@use_kasket = false
|
16
|
+
yield
|
17
|
+
ensure
|
18
|
+
@use_kasket = old_value
|
19
|
+
end
|
20
|
+
|
21
|
+
def find_every_with_kasket(options)
|
22
|
+
attribute_value_pairs = kasket_conditions_parser.attribute_value_pairs(options) if cache_safe?(options)
|
23
|
+
|
24
|
+
limit = (options[:limit] || (scope(:find) || {})[:limit])
|
25
|
+
|
26
|
+
if (limit.nil? || limit == 1) && attribute_value_pairs && has_kasket_index_on?(attribute_value_pairs.map(&:first))
|
27
|
+
collection_key = kasket_key_for(attribute_value_pairs)
|
28
|
+
collection_key << '/first' if limit == 1
|
29
|
+
unless records = Kasket.cache.read(collection_key)
|
30
|
+
records = without_kasket do
|
31
|
+
find_every_without_kasket(options)
|
32
|
+
end
|
33
|
+
|
34
|
+
if records.size == 1
|
35
|
+
Kasket.cache.write(collection_key, records[0])
|
36
|
+
elsif records.size <= Kasket::CONFIGURATION[:max_collection_size]
|
37
|
+
records.each { |record| record.store_in_kasket if record }
|
38
|
+
Kasket.cache.write(collection_key, records.map(&:kasket_key))
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
Array(records)
|
43
|
+
else
|
44
|
+
without_kasket do
|
45
|
+
find_every_without_kasket(options)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def find_some_with_kasket(ids, options)
|
51
|
+
attribute_value_pairs = kasket_conditions_parser.attribute_value_pairs(options) if cache_safe?(options)
|
52
|
+
attribute_value_pairs << [:id, ids] if attribute_value_pairs
|
53
|
+
|
54
|
+
limit = (options[:limit] || (scope(:find) || {})[:limit])
|
55
|
+
|
56
|
+
if limit.nil? && attribute_value_pairs && has_kasket_index_on?(attribute_value_pairs.map(&:first))
|
57
|
+
id_to_key_map = Hash[ids.uniq.map { |id| [id, kasket_key_for_id(id)] }]
|
58
|
+
cached_record_map = Kasket.cache.get_multi(id_to_key_map.values)
|
59
|
+
|
60
|
+
missing_keys = Hash[cached_record_map.select { |key, record| record.nil? }].keys
|
61
|
+
|
62
|
+
return cached_record_map.values if missing_keys.empty?
|
63
|
+
|
64
|
+
missing_ids = Hash[id_to_key_map.invert.select { |key, id| missing_keys.include?(key) }].values
|
65
|
+
|
66
|
+
db_records = without_kasket do
|
67
|
+
find_some_without_kasket(missing_ids, options)
|
68
|
+
end
|
69
|
+
db_records.each { |record| record.store_in_kasket if record }
|
70
|
+
|
71
|
+
(cached_record_map.values + db_records).compact.uniq.sort { |x, y| x.id <=> y.id}
|
72
|
+
else
|
73
|
+
without_kasket do
|
74
|
+
find_some_without_kasket(ids, options)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
private
|
80
|
+
|
81
|
+
def cache_safe?(options)
|
82
|
+
@use_kasket != false && [options, scope(:find) || {}].all? do |hash|
|
83
|
+
result = hash[:select].nil? && hash[:joins].nil? && hash[:order].nil? && hash[:offset].nil?
|
84
|
+
result && (options[:limit].nil? || options[:limit] == 1)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
def kasket_conditions_parser
|
89
|
+
@kasket_conditions_parser ||= Kasket::ConditionsParser.new(self)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module Kasket
|
2
|
+
module ReloadAssociationMixin
|
3
|
+
def reload_with_kasket_clearing(*args)
|
4
|
+
if loaded?
|
5
|
+
clear_local_kasket_indices if respond_to?(:clear_local_kasket_indices)
|
6
|
+
end
|
7
|
+
|
8
|
+
# or maybe something like this?
|
9
|
+
#target_class = proxy_reflection.options[:polymorphic] ? association_class : proxy_reflection.klass
|
10
|
+
#Kasket.cache.delete_matched_local(/^#{target_class.kasket_key_prefix}/) if target_class.respond_to?(:kasket_key_prefix)
|
11
|
+
|
12
|
+
reload_without_kasket_clearing(*args)
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.included(base)
|
16
|
+
base.alias_method_chain :reload, :kasket_clearing
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
module Kasket
|
2
|
+
module WriteMixin
|
3
|
+
module ClassMethods
|
4
|
+
def remove_from_kasket(ids)
|
5
|
+
Array(ids).each do |id|
|
6
|
+
Kasket.cache.delete(kasket_key_for_id(id))
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
def update_counters_with_kasket_clearing(*args)
|
11
|
+
remove_from_kasket(args[0])
|
12
|
+
update_counters_without_kasket_clearing(*args)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
module InstanceMethods
|
17
|
+
def kasket_key
|
18
|
+
@kasket_key ||= new_record? ? nil : self.class.kasket_key_for_id(id)
|
19
|
+
end
|
20
|
+
|
21
|
+
def store_in_kasket
|
22
|
+
if !readonly? && kasket_key
|
23
|
+
Kasket.cache.write(kasket_key, self)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def kasket_keys
|
28
|
+
attribute_sets = [attributes.symbolize_keys]
|
29
|
+
|
30
|
+
if changed?
|
31
|
+
old_attributes = Hash[changes.map {|attribute, values| [attribute, values[0]]}].symbolize_keys
|
32
|
+
attribute_sets << old_attributes.reverse_merge(attribute_sets[0])
|
33
|
+
end
|
34
|
+
|
35
|
+
keys = []
|
36
|
+
self.class.kasket_indices.each do |index|
|
37
|
+
keys += attribute_sets.map do |attribute_set|
|
38
|
+
self.class.kasket_key_for(index.map { |attribute| [attribute, attribute_set[attribute]]})
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
keys.uniq!
|
43
|
+
keys.map! {|key| [key, "#{key}/first"]}
|
44
|
+
keys.flatten!
|
45
|
+
end
|
46
|
+
|
47
|
+
def clear_kasket_indices
|
48
|
+
kasket_keys.each do |key|
|
49
|
+
Kasket.cache.delete(key)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def clear_local_kasket_indices
|
54
|
+
kasket_keys.each do |key|
|
55
|
+
Kasket.cache.delete_local(key)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def reload_with_kasket_clearing(*args)
|
60
|
+
clear_local_kasket_indices
|
61
|
+
reload_without_kasket_clearing(*args)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def self.included(model_class)
|
66
|
+
model_class.extend ClassMethods
|
67
|
+
model_class.send :include, InstanceMethods
|
68
|
+
|
69
|
+
model_class.after_save :clear_kasket_indices
|
70
|
+
model_class.after_destroy :clear_kasket_indices
|
71
|
+
|
72
|
+
model_class.alias_method_chain :reload, :kasket_clearing
|
73
|
+
|
74
|
+
class << model_class
|
75
|
+
alias_method_chain :update_counters, :kasket_clearing
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/helper'
|
2
|
+
|
3
|
+
class CacheExpiryTest < ActiveSupport::TestCase
|
4
|
+
fixtures :blogs, :posts
|
5
|
+
|
6
|
+
Post.has_kasket
|
7
|
+
Post.has_kasket_on :title
|
8
|
+
Post.has_kasket_on :blog_id
|
9
|
+
|
10
|
+
context "a cached object" do
|
11
|
+
setup do
|
12
|
+
post = Post.first
|
13
|
+
@post = Post.find(post.id)
|
14
|
+
assert(Rails.cache.read(@post.kasket_key))
|
15
|
+
end
|
16
|
+
|
17
|
+
should "be removed from cache when deleted" do
|
18
|
+
@post.destroy
|
19
|
+
assert_nil(Rails.cache.read(@post.kasket_key))
|
20
|
+
end
|
21
|
+
|
22
|
+
should "clear all indices for instance when deleted" do
|
23
|
+
Kasket.cache.expects(:delete).with(Post.kasket_key_prefix + "id=#{@post.id}")
|
24
|
+
Kasket.cache.expects(:delete).with(Post.kasket_key_prefix + "id=#{@post.id}/first")
|
25
|
+
Kasket.cache.expects(:delete).with(Post.kasket_key_prefix + "title=#{@post.title}")
|
26
|
+
Kasket.cache.expects(:delete).with(Post.kasket_key_prefix + "title=#{@post.title}/first")
|
27
|
+
Kasket.cache.expects(:delete).with(Post.kasket_key_prefix + "blog_id=#{@post.blog_id}")
|
28
|
+
Kasket.cache.expects(:delete).with(Post.kasket_key_prefix + "blog_id=#{@post.blog_id}/first")
|
29
|
+
Kasket.cache.expects(:delete).never
|
30
|
+
|
31
|
+
@post.destroy
|
32
|
+
end
|
33
|
+
|
34
|
+
should "be removed from cache when updated" do
|
35
|
+
@post.title = "new title"
|
36
|
+
@post.save
|
37
|
+
assert_nil(Rails.cache.read(@post.kasket_key))
|
38
|
+
end
|
39
|
+
|
40
|
+
should "clear all indices for instance when updated" do
|
41
|
+
Kasket.cache.expects(:delete).with(Post.kasket_key_prefix + "id=#{@post.id}")
|
42
|
+
Kasket.cache.expects(:delete).with(Post.kasket_key_prefix + "id=#{@post.id}/first")
|
43
|
+
Kasket.cache.expects(:delete).with(Post.kasket_key_prefix + "title=#{@post.title}")
|
44
|
+
Kasket.cache.expects(:delete).with(Post.kasket_key_prefix + "title=#{@post.title}/first")
|
45
|
+
Kasket.cache.expects(:delete).with(Post.kasket_key_prefix + "title=new title")
|
46
|
+
Kasket.cache.expects(:delete).with(Post.kasket_key_prefix + "title=new title/first")
|
47
|
+
Kasket.cache.expects(:delete).with(Post.kasket_key_prefix + "blog_id=#{@post.blog_id}")
|
48
|
+
Kasket.cache.expects(:delete).with(Post.kasket_key_prefix + "blog_id=#{@post.blog_id}/first")
|
49
|
+
Kasket.cache.expects(:delete).never
|
50
|
+
|
51
|
+
@post.title = "new title"
|
52
|
+
@post.save
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
data/test/database.yml
ADDED
data/test/dirty_test.rb
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/helper'
|
2
|
+
|
3
|
+
class DirtyTest < ActiveSupport::TestCase
|
4
|
+
fixtures :blogs, :posts
|
5
|
+
|
6
|
+
Post.has_kasket
|
7
|
+
Post.kasket_dirty_methods :make_dirty!
|
8
|
+
|
9
|
+
should "clear the indices when a dirty method is called" do
|
10
|
+
post = Post.first
|
11
|
+
|
12
|
+
pots = Post.find(post.id)
|
13
|
+
assert(Rails.cache.read(post.kasket_key))
|
14
|
+
|
15
|
+
post.make_dirty!
|
16
|
+
|
17
|
+
assert_nil(Rails.cache.read(post.kasket_key))
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/helper'
|
2
|
+
|
3
|
+
class FindOneTest < ActiveSupport::TestCase
|
4
|
+
fixtures :blogs, :posts
|
5
|
+
|
6
|
+
Post.has_kasket
|
7
|
+
|
8
|
+
should "cache find(id) calls" do
|
9
|
+
post = Post.first
|
10
|
+
assert_nil(Rails.cache.read(post.kasket_key))
|
11
|
+
assert_equal(post, Post.find(post.id))
|
12
|
+
assert(Rails.cache.read(post.kasket_key))
|
13
|
+
Post.connection.expects(:select_all).never
|
14
|
+
assert_equal(post, Post.find(post.id))
|
15
|
+
end
|
16
|
+
|
17
|
+
should "not use cache when using the :select option" do
|
18
|
+
post = Post.first
|
19
|
+
assert_nil(Rails.cache.read(post.kasket_key))
|
20
|
+
|
21
|
+
Post.find(post.id, :select => 'title')
|
22
|
+
assert_nil(Rails.cache.read(post.kasket_key))
|
23
|
+
|
24
|
+
Post.find(post.id)
|
25
|
+
assert(Rails.cache.read(post.kasket_key))
|
26
|
+
|
27
|
+
Kasket.cache.expects(:read)
|
28
|
+
Post.find(post.id, :select => nil)
|
29
|
+
|
30
|
+
Kasket.cache.expects(:read).never
|
31
|
+
Post.find(post.id, :select => 'title')
|
32
|
+
end
|
33
|
+
|
34
|
+
should "respect scope" do
|
35
|
+
post = Post.find(Post.first.id)
|
36
|
+
other_blog = Blog.first(:conditions => "id != #{post.blog_id}")
|
37
|
+
|
38
|
+
assert(Rails.cache.read(post.kasket_key))
|
39
|
+
|
40
|
+
assert_raise(ActiveRecord::RecordNotFound) do
|
41
|
+
other_blog.posts.find(post.id)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/helper'
|
2
|
+
|
3
|
+
class FindSomeTest < ActiveSupport::TestCase
|
4
|
+
fixtures :blogs, :posts
|
5
|
+
|
6
|
+
Post.has_kasket
|
7
|
+
Post.has_kasket_on :blog_id
|
8
|
+
|
9
|
+
should "cache find(id, id) calls" do
|
10
|
+
post1 = Post.first
|
11
|
+
post2 = Post.last
|
12
|
+
|
13
|
+
assert_nil(Rails.cache.read(post1.kasket_key))
|
14
|
+
assert_nil(Rails.cache.read(post2.kasket_key))
|
15
|
+
|
16
|
+
Post.find(post1.id, post2.id)
|
17
|
+
|
18
|
+
assert(Rails.cache.read(post1.kasket_key))
|
19
|
+
assert(Rails.cache.read(post2.kasket_key))
|
20
|
+
|
21
|
+
Post.connection.expects(:select_all).never
|
22
|
+
Post.find(post1.id, post2.id)
|
23
|
+
end
|
24
|
+
|
25
|
+
should "only lookup the records that are not in the cache" do
|
26
|
+
post1 = Post.first
|
27
|
+
post2 = Post.last
|
28
|
+
assert_equal(post1, Post.find(post1.id))
|
29
|
+
assert(Rails.cache.read(post1.kasket_key))
|
30
|
+
assert_nil(Rails.cache.read(post2.kasket_key))
|
31
|
+
|
32
|
+
Post.expects(:find_some_without_kasket).with([post2.id], {}).returns([post2])
|
33
|
+
found_posts = Post.find(post1.id, post2.id)
|
34
|
+
assert_equal([post1, post2].map(&:id).sort, found_posts.map(&:id).sort)
|
35
|
+
|
36
|
+
Post.expects(:find_some_without_kasket).never
|
37
|
+
found_posts = Post.find(post1.id, post2.id)
|
38
|
+
assert_equal([post1, post2].map(&:id).sort, found_posts.map(&:id).sort)
|
39
|
+
end
|
40
|
+
|
41
|
+
should "cache on index other than primary key" do
|
42
|
+
blog = blogs(:a_blog)
|
43
|
+
posts = Post.find_all_by_blog_id(blog.id)
|
44
|
+
|
45
|
+
Post.expects(:find_every_without_kasket).never
|
46
|
+
|
47
|
+
assert_equal(posts, Post.find_all_by_blog_id(blog.id))
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
few_comments_1:
|
2
|
+
post: has_two_comments
|
3
|
+
body: what ever body 1
|
4
|
+
|
5
|
+
few_comments_2:
|
6
|
+
post: has_two_comments
|
7
|
+
body: what ever body 2
|
8
|
+
|
9
|
+
<% (1..10000).each do |i| %>
|
10
|
+
many_comments_<%= i %>:
|
11
|
+
post: has_many_comments
|
12
|
+
body: what ever body <%= i %>
|
13
|
+
<% end %>
|
14
|
+
|
@@ -0,0 +1,15 @@
|
|
1
|
+
no_comments:
|
2
|
+
blog: a_blog
|
3
|
+
title: This post has no comments
|
4
|
+
|
5
|
+
has_two_comments:
|
6
|
+
blog: a_blog
|
7
|
+
title: This post has a few comments
|
8
|
+
|
9
|
+
on_other_blog:
|
10
|
+
blog: other_blog
|
11
|
+
title: This post has no comments
|
12
|
+
|
13
|
+
has_many_comments:
|
14
|
+
blog: a_blog
|
15
|
+
title: A shit load of comments on this one
|
data/test/helper.rb
ADDED
@@ -0,0 +1,67 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'test/unit'
|
3
|
+
require 'mocha'
|
4
|
+
require 'shoulda'
|
5
|
+
require 'active_support'
|
6
|
+
require 'active_record'
|
7
|
+
require 'active_record/fixtures'
|
8
|
+
|
9
|
+
ActiveRecord::Base.configurations = YAML::load(IO.read(File.dirname(__FILE__) + '/database.yml'))
|
10
|
+
ActiveRecord::Base.establish_connection('test')
|
11
|
+
ActiveRecord::Base.logger = Logger.new(File.dirname(__FILE__) + "/debug.log")
|
12
|
+
|
13
|
+
load(File.dirname(__FILE__) + "/schema.rb")
|
14
|
+
|
15
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
|
16
|
+
$LOAD_PATH.unshift(File.dirname(__FILE__))
|
17
|
+
require 'kasket'
|
18
|
+
|
19
|
+
Kasket.setup
|
20
|
+
|
21
|
+
class ActiveSupport::TestCase
|
22
|
+
include ActiveRecord::TestFixtures
|
23
|
+
|
24
|
+
fixtures :all
|
25
|
+
|
26
|
+
def create_fixtures(*table_names)
|
27
|
+
if block_given?
|
28
|
+
Fixtures.create_fixtures(Test::Unit::TestCase.fixture_path, table_names) { yield }
|
29
|
+
else
|
30
|
+
Fixtures.create_fixtures(Test::Unit::TestCase.fixture_path, table_names)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
self.use_transactional_fixtures = true
|
35
|
+
|
36
|
+
self.use_instantiated_fixtures = false
|
37
|
+
end
|
38
|
+
|
39
|
+
ActiveSupport::TestCase.fixture_path = File.dirname(__FILE__) + "/fixtures/"
|
40
|
+
$LOAD_PATH.unshift(ActiveSupport::TestCase.fixture_path)
|
41
|
+
|
42
|
+
module Rails
|
43
|
+
module_function
|
44
|
+
CACHE = ActiveSupport::Cache::MemoryStore.new
|
45
|
+
|
46
|
+
def cache
|
47
|
+
CACHE
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
class Comment < ActiveRecord::Base
|
52
|
+
belongs_to :post
|
53
|
+
end
|
54
|
+
|
55
|
+
class Post < ActiveRecord::Base
|
56
|
+
belongs_to :blog
|
57
|
+
has_many :comments
|
58
|
+
|
59
|
+
def make_dirty!
|
60
|
+
self.updated_at = Time.now
|
61
|
+
self.connection.execute("UPDATE posts SET updated_at = '#{updated_at.utc.to_s(:db)}' WHERE id = #{id}")
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
class Blog < ActiveRecord::Base
|
66
|
+
has_many :posts
|
67
|
+
end
|
data/test/schema.rb
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
# This file is auto-generated from the current state of the database. Instead of editing this file,
|
2
|
+
# please use the migrations feature of Active Record to incrementally modify your database, and
|
3
|
+
# then regenerate this schema definition.
|
4
|
+
#
|
5
|
+
# Note that this schema.rb definition is the authoritative source for your database schema. If you need
|
6
|
+
# to create the application database on another system, you should be using db:schema:load, not running
|
7
|
+
# all the migrations from scratch. The latter is a flawed and unsustainable approach (the more migrations
|
8
|
+
# you'll amass, the slower it'll run and the greater likelihood for issues).
|
9
|
+
#
|
10
|
+
# It's strongly recommended to check this file into your version control system.
|
11
|
+
|
12
|
+
ActiveRecord::Schema.define(:version => 1) do
|
13
|
+
|
14
|
+
create_table "comments", :force => true do |t|
|
15
|
+
t.text "body"
|
16
|
+
t.integer "post_id"
|
17
|
+
t.datetime "created_at"
|
18
|
+
t.datetime "updated_at"
|
19
|
+
end
|
20
|
+
|
21
|
+
create_table "posts", :force => true do |t|
|
22
|
+
t.string "title"
|
23
|
+
t.integer "blog_id"
|
24
|
+
t.datetime "created_at"
|
25
|
+
t.datetime "updated_at"
|
26
|
+
end
|
27
|
+
|
28
|
+
create_table "blogs", :force => true do |t|
|
29
|
+
t.string "name"
|
30
|
+
t.datetime "created_at"
|
31
|
+
t.datetime "updated_at"
|
32
|
+
end
|
33
|
+
|
34
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/helper'
|
2
|
+
|
3
|
+
class SerializationTest < ActiveSupport::TestCase
|
4
|
+
fixtures :blogs, :posts
|
5
|
+
|
6
|
+
Post.has_kasket
|
7
|
+
|
8
|
+
should "store a CachedModel" do
|
9
|
+
post = Post.first
|
10
|
+
post.store_in_kasket
|
11
|
+
assert_instance_of(Kasket::Cache::CachedModel, Rails.cache.read(post.kasket_key))
|
12
|
+
end
|
13
|
+
|
14
|
+
should "bring convert CachedModel to model instances" do
|
15
|
+
post = Post.first
|
16
|
+
post.store_in_kasket
|
17
|
+
|
18
|
+
post = Kasket.cache.read(post.kasket_key)
|
19
|
+
assert_instance_of(Post, post)
|
20
|
+
end
|
21
|
+
end
|
metadata
ADDED
@@ -0,0 +1,105 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: kasket
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.5.2
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Mick Staugaard
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
|
12
|
+
date: 2009-12-01 00:00:00 -08:00
|
13
|
+
default_executable:
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: thoughtbot-shoulda
|
17
|
+
type: :development
|
18
|
+
version_requirement:
|
19
|
+
version_requirements: !ruby/object:Gem::Requirement
|
20
|
+
requirements:
|
21
|
+
- - ">="
|
22
|
+
- !ruby/object:Gem::Version
|
23
|
+
version: "0"
|
24
|
+
version:
|
25
|
+
- !ruby/object:Gem::Dependency
|
26
|
+
name: mocha
|
27
|
+
type: :development
|
28
|
+
version_requirement:
|
29
|
+
version_requirements: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: "0"
|
34
|
+
version:
|
35
|
+
description: A rewrite of cache money
|
36
|
+
email: mick@staugaard.com
|
37
|
+
executables: []
|
38
|
+
|
39
|
+
extensions: []
|
40
|
+
|
41
|
+
extra_rdoc_files:
|
42
|
+
- LICENSE
|
43
|
+
- README.rdoc
|
44
|
+
files:
|
45
|
+
- .document
|
46
|
+
- .gitignore
|
47
|
+
- LICENSE
|
48
|
+
- README.rdoc
|
49
|
+
- Rakefile
|
50
|
+
- VERSION
|
51
|
+
- lib/kasket.rb
|
52
|
+
- lib/kasket/cache.rb
|
53
|
+
- lib/kasket/conditions_parser.rb
|
54
|
+
- lib/kasket/configuration_mixin.rb
|
55
|
+
- lib/kasket/dirty_mixin.rb
|
56
|
+
- lib/kasket/read_mixin.rb
|
57
|
+
- lib/kasket/reload_association_mixin.rb
|
58
|
+
- lib/kasket/write_mixin.rb
|
59
|
+
- test/cache_expiry_test.rb
|
60
|
+
- test/database.yml
|
61
|
+
- test/dirty_test.rb
|
62
|
+
- test/find_one_test.rb
|
63
|
+
- test/find_some_test.rb
|
64
|
+
- test/fixtures/blogs.yml
|
65
|
+
- test/fixtures/comments.yml
|
66
|
+
- test/fixtures/posts.yml
|
67
|
+
- test/helper.rb
|
68
|
+
- test/schema.rb
|
69
|
+
- test/serialization_test.rb
|
70
|
+
has_rdoc: true
|
71
|
+
homepage: http://github.com/staugaard/kasket
|
72
|
+
licenses: []
|
73
|
+
|
74
|
+
post_install_message:
|
75
|
+
rdoc_options:
|
76
|
+
- --charset=UTF-8
|
77
|
+
require_paths:
|
78
|
+
- lib
|
79
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
80
|
+
requirements:
|
81
|
+
- - ">="
|
82
|
+
- !ruby/object:Gem::Version
|
83
|
+
version: "0"
|
84
|
+
version:
|
85
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: "0"
|
90
|
+
version:
|
91
|
+
requirements: []
|
92
|
+
|
93
|
+
rubyforge_project:
|
94
|
+
rubygems_version: 1.3.5
|
95
|
+
signing_key:
|
96
|
+
specification_version: 3
|
97
|
+
summary: A write back caching layer on active record
|
98
|
+
test_files:
|
99
|
+
- test/cache_expiry_test.rb
|
100
|
+
- test/dirty_test.rb
|
101
|
+
- test/find_one_test.rb
|
102
|
+
- test/find_some_test.rb
|
103
|
+
- test/helper.rb
|
104
|
+
- test/schema.rb
|
105
|
+
- test/serialization_test.rb
|