kasket 0.5.2
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/.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
|