kasket 0.5.5 → 0.6.0

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/Rakefile CHANGED
@@ -6,10 +6,10 @@ begin
6
6
  Jeweler::Tasks.new do |gem|
7
7
  gem.name = "kasket"
8
8
  gem.summary = %Q{A write back caching layer on active record}
9
- gem.description = %Q{A rewrite of cache money}
9
+ gem.description = %Q{put's a cap on your queries}
10
10
  gem.email = "mick@staugaard.com"
11
11
  gem.homepage = "http://github.com/staugaard/kasket"
12
- gem.authors = ["Mick Staugaard"]
12
+ gem.authors = ["Mick Staugaard", "Eric Chapweske"]
13
13
  gem.add_dependency('activerecord', '>= 2.3.4')
14
14
  gem.add_dependency('activesupport', '>= 2.3.4')
15
15
  gem.add_development_dependency "thoughtbot-shoulda", ">= 0"
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.5.5
1
+ 0.6.0
@@ -9,6 +9,7 @@ module Kasket
9
9
  autoload :ConfigurationMixin, 'kasket/configuration_mixin'
10
10
  autoload :ReloadAssociationMixin, 'kasket/reload_association_mixin'
11
11
  autoload :RackMiddleware, 'kasket/rack_middleware'
12
+ autoload :Query, 'kasket/query'
12
13
 
13
14
  CONFIGURATION = {:max_collection_size => 100}
14
15
 
@@ -26,13 +27,6 @@ module Kasket
26
27
  ActiveRecord::Associations::BelongsToPolymorphicAssociation.send(:include, Kasket::ReloadAssociationMixin)
27
28
  ActiveRecord::Associations::HasOneThroughAssociation.send(:include, Kasket::ReloadAssociationMixin)
28
29
 
29
- #sets up local cache clearing on rack
30
- begin
31
- ActionController::Dispatcher.middleware.use(Kasket::RackMiddleware)
32
- rescue NameError => e
33
- puts('WARNING: The kasket rack middleware is not in your rack stack')
34
- end
35
-
36
30
  #sets up local cache clearing before each request.
37
31
  #this is done to make it work for non rack rails and for functional tests
38
32
  begin
@@ -42,6 +36,13 @@ module Kasket
42
36
  rescue NameError => e
43
37
  end
44
38
 
39
+ #sets up local cache clearing on rack
40
+ begin
41
+ ActionController::Dispatcher.middleware.use(Kasket::RackMiddleware)
42
+ rescue NameError => e
43
+ puts('WARNING: The kasket rack middleware is not in your rack stack')
44
+ end
45
+
45
46
  #sets up local cache clearing after each test case
46
47
  begin
47
48
  ActiveSupport::TestCase.class_eval do
@@ -53,4 +53,4 @@ module Kasket
53
53
  end
54
54
  end
55
55
 
56
- ActiveRecord::Base.extend Kasket::FixForAssociationAccessorMethods
56
+ ActiveRecord::Base.extend Kasket::FixForAssociationAccessorMethods
@@ -6,11 +6,9 @@ module Kasket
6
6
 
7
7
  def read(*args)
8
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)
9
+ if result.is_a?(Array) && result.first.is_a?(String)
12
10
  models = get_multi(result)
13
- result = result.map { |key| models[key]}
11
+ result = result.map { |key| models[key] }
14
12
  end
15
13
 
16
14
  @local_cache[args[0]] = result if result
@@ -25,7 +23,6 @@ module Kasket
25
23
  if Rails.cache.respond_to?(:read_multi)
26
24
  missing_map = Rails.cache.read_multi(missing_keys)
27
25
  missing_map.each do |key, value|
28
- value = value.instanciate_model if value.is_a?(CachedModel)
29
26
  missing_map[key] = @local_cache[key] = value
30
27
  end
31
28
  map.merge!(missing_map)
@@ -39,14 +36,12 @@ module Kasket
39
36
  map
40
37
  end
41
38
 
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])
39
+ def write(key, value)
40
+ if storable?(value)
41
+ @local_cache[key] = value.duplicable? ? value.dup : value
42
+ Rails.cache.write(key, value.duplicable? ? value.dup : value) # Fix due to Rails.cache freezing values in 2.3.4
47
43
  end
48
-
49
- Rails.cache.write(*args)
44
+ value
50
45
  end
51
46
 
52
47
  def delete(*args)
@@ -68,15 +63,15 @@ module Kasket
68
63
  @local_cache = {}
69
64
  end
70
65
 
71
- class CachedModel
72
- def initialize(model)
73
- @name = model.class.name
74
- @attributes = model.instance_variable_get(:@attributes)
75
- end
66
+ def local
67
+ @local_cache
68
+ end
76
69
 
77
- def instanciate_model
78
- @name.constantize.send(:instantiate, @attributes)
70
+ protected
71
+
72
+ def storable?(value)
73
+ !value.is_a?(Array) || value.size <= Kasket::CONFIGURATION[:max_collection_size]
79
74
  end
80
- end
75
+
81
76
  end
82
77
  end
@@ -4,14 +4,50 @@ module Kasket
4
4
  autoload :ReadMixin, 'kasket/read_mixin'
5
5
  autoload :WriteMixin, 'kasket/write_mixin'
6
6
  autoload :DirtyMixin, 'kasket/dirty_mixin'
7
+ autoload :QueryParser, 'kasket/query_parser'
7
8
 
8
9
  module ConfigurationMixin
10
+
11
+ def without_kasket(&block)
12
+ old_value = @use_kasket || true
13
+ @use_kasket = false
14
+ yield
15
+ ensure
16
+ @use_kasket = old_value
17
+ end
18
+
19
+ def use_kasket?
20
+ @use_kasket != false
21
+ end
22
+
23
+ def kasket_parser
24
+ @kasket_parser ||= QueryParser.new(self)
25
+ end
26
+
9
27
  def kasket_key_prefix
10
28
  @kasket_key_prefix ||= "kasket/#{table_name}/version=#{column_names.join.sum}/"
11
29
  end
12
30
 
13
31
  def kasket_key_for(attribute_value_pairs)
14
- kasket_key_prefix + attribute_value_pairs.map {|attribute, value| attribute.to_s + '=' + value.to_s}.join('/')
32
+ kasket_key_prefix + attribute_value_pairs.map do |attribute, value|
33
+ if (column = columns_hash[attribute.to_s]) && column.number?
34
+ attribute.to_s + '=' + convert_number_column_value(value.to_s)
35
+ else
36
+ attribute.to_s + '=' + connection.quote(value.to_s)
37
+ end
38
+ end.join('/')
39
+ end
40
+
41
+ def convert_number_column_value(value)
42
+ if value == false
43
+ 0
44
+ elsif value == true
45
+ 1
46
+ elsif value.is_a?(String) && value.blank?
47
+ nil
48
+ else
49
+ value
50
+ end
15
51
  end
16
52
 
17
53
  def kasket_key_for_id(id)
@@ -42,8 +78,8 @@ module Kasket
42
78
  @kasket_indices << attributes unless @kasket_indices.include?(attributes)
43
79
 
44
80
  include WriteMixin unless instance_methods.include?('store_in_kasket')
45
- extend ReadMixin unless methods.include?('without_kasket')
46
81
  extend DirtyMixin unless methods.include?('kasket_dirty_methods')
82
+ extend ReadMixin unless methods.include?('find_by_sql_with_kasket')
47
83
  end
48
84
  end
49
- end
85
+ end
@@ -15,4 +15,4 @@ module Kasket
15
15
 
16
16
  alias_method :kasket_dirty_method, :kasket_dirty_methods
17
17
  end
18
- end
18
+ end
@@ -0,0 +1,53 @@
1
+ module Kasket
2
+ class QueryParser
3
+ # Examples:
4
+ # SELECT * FROM `users` WHERE (`users`.`id` = 2)
5
+ # SELECT * FROM `users` WHERE (`users`.`id` = 2) LIMIT 1
6
+ # 'SELECT * FROM \'posts\' WHERE (\'posts\'.\'id\' = 574019247) '
7
+
8
+ AND = /\s+AND\s+/i
9
+ VALUE = /'?(\d+|\?|(?:(?:[^']|''|\\')*))'?/ # Matches: 123, ?, '123', '12''3'
10
+
11
+ def initialize(model_class)
12
+ @model_class = model_class
13
+ @supported_query_pattern = /^select \* from (?:`|")#{@model_class.table_name}(?:`|") where \((.*)\)(|\s+limit 1)\s*$/i
14
+ @table_and_column_pattern = /(?:(?:`|")?#{@model_class.table_name}(?:`|")?\.)?(?:`|")?(\w+)(?:`|")?/ # Matches: `users`.id, `users`.`id`, users.id, id
15
+ @key_eq_value_pattern = /^[\(\s]*#{@table_and_column_pattern}\s+=\s+#{VALUE}[\)\s]*$/ # Matches: KEY = VALUE, (KEY = VALUE), ()(KEY = VALUE))
16
+ end
17
+
18
+ def parse(sql)
19
+ if match = @supported_query_pattern.match(sql)
20
+ query = Hash.new
21
+ query[:attributes] = sorted_attribute_value_pairs(match[1])
22
+ return nil if query[:attributes].nil?
23
+ query[:index] = query[:attributes].map(&:first)
24
+ query[:limit] = match[2].blank? ? nil : 1
25
+ query[:key] = @model_class.kasket_key_for(query[:attributes])
26
+ query[:key] << '/first' if query[:limit] == 1 && !query[:index].include?(:id)
27
+ query
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def sorted_attribute_value_pairs(conditions)
34
+ if attributes = parse_condition(conditions)
35
+ attributes.sort { |pair1, pair2| pair1[0].to_s <=> pair2[0].to_s }
36
+ end
37
+ end
38
+
39
+ def parse_condition(conditions = '', *values)
40
+ values = values.dup
41
+ conditions.split(AND).inject([]) do |pairs, condition|
42
+ matched, column_name, sql_value = *(@key_eq_value_pattern.match(condition))
43
+ if matched
44
+ value = sql_value == '?' ? values.shift : sql_value
45
+ pairs << [column_name.to_sym, value.gsub(/''|\\'/, "'")]
46
+ else
47
+ return nil
48
+ end
49
+ end
50
+ end
51
+
52
+ end
53
+ end
@@ -10,4 +10,4 @@ module Kasket
10
10
  Kasket.cache.clear_local
11
11
  end
12
12
  end
13
- end
13
+ end
@@ -1,92 +1,40 @@
1
- require 'kasket/conditions_parser'
2
-
3
1
  module Kasket
4
2
  module ReadMixin
5
3
 
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')
4
+ def self.extended(base)
5
+ class << base
6
+ alias_method_chain :find_by_sql, :kasket
10
7
  end
11
8
  end
12
9
 
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
10
+ def find_by_sql_with_kasket(sql)
11
+ query = kasket_parser.parse(sql) if use_kasket?
12
+ if query && has_kasket_index_on?(query[:index])
13
+ if value = Kasket.cache.read(query[:key])
14
+ Array.wrap(value).collect! { |record| instantiate(record.clone) }
15
+ else
16
+ store_in_kasket(query[:key], find_by_sql_without_kasket(sql))
40
17
  end
41
-
42
- Array(records)
43
18
  else
44
- without_kasket do
45
- find_every_without_kasket(options)
46
- end
19
+ find_by_sql_without_kasket(sql)
47
20
  end
48
21
  end
49
22
 
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)] }.flatten]
58
- cached_record_map = Kasket.cache.get_multi(id_to_key_map.values)
59
-
60
- missing_keys = cached_record_map.select { |key, record| record.nil? }.map(&:first)
23
+ protected
61
24
 
62
- return cached_record_map.values if missing_keys.empty?
63
-
64
- missing_ids = id_to_key_map.invert.select { |key, id| missing_keys.include?(key) }.map(&:last)
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)
25
+ def store_in_kasket(key, records)
26
+ if records.size == 1
27
+ Kasket.cache.write(key, records.first.instance_variable_get(:@attributes))
28
+ else
29
+ keys = records.map do |record|
30
+ key = kasket_key_for_id(record.id)
31
+ Kasket.cache.write(key, record.instance_variable_get(:@attributes))
32
+ key
33
+ end
34
+ Kasket.cache.write(key, keys)
85
35
  end
36
+ records
86
37
  end
87
38
 
88
- def kasket_conditions_parser
89
- @kasket_conditions_parser ||= Kasket::ConditionsParser.new(self)
90
- end
91
39
  end
92
40
  end
@@ -15,4 +15,4 @@ module Kasket
15
15
  base.alias_method_chain :reload, :kasket_clearing
16
16
  end
17
17
  end
18
- end
18
+ end
@@ -1,5 +1,6 @@
1
1
  module Kasket
2
2
  module WriteMixin
3
+
3
4
  module ClassMethods
4
5
  def remove_from_kasket(ids)
5
6
  Array(ids).each do |id|
@@ -20,7 +21,7 @@ module Kasket
20
21
 
21
22
  def store_in_kasket
22
23
  if !readonly? && kasket_key
23
- Kasket.cache.write(kasket_key, self)
24
+ Kasket.cache.write(kasket_key, @attributes)
24
25
  end
25
26
  end
26
27
 
@@ -35,13 +36,14 @@ module Kasket
35
36
  keys = []
36
37
  self.class.kasket_indices.each do |index|
37
38
  keys += attribute_sets.map do |attribute_set|
38
- self.class.kasket_key_for(index.map { |attribute| [attribute, attribute_set[attribute]]})
39
+ key = self.class.kasket_key_for(index.map { |attribute| [attribute, attribute_set[attribute]]})
40
+ index.include?(:id) ? key : [key, key + '/first']
39
41
  end
40
42
  end
41
43
 
42
- keys.uniq!
43
- keys.map! {|key| [key, "#{key}/first"]}
44
44
  keys.flatten!
45
+ keys.uniq!
46
+ keys
45
47
  end
46
48
 
47
49
  def clear_kasket_indices
@@ -3,14 +3,11 @@ require File.dirname(__FILE__) + '/helper'
3
3
  class CacheExpiryTest < ActiveSupport::TestCase
4
4
  fixtures :blogs, :posts
5
5
 
6
- Post.has_kasket
7
- Post.has_kasket_on :title
8
- Post.has_kasket_on :blog_id
9
-
10
6
  context "a cached object" do
11
7
  setup do
12
8
  post = Post.first
13
9
  @post = Post.find(post.id)
10
+
14
11
  assert(Rails.cache.read(@post.kasket_key))
15
12
  end
16
13
 
@@ -21,11 +18,9 @@ class CacheExpiryTest < ActiveSupport::TestCase
21
18
 
22
19
  should "clear all indices for instance when deleted" do
23
20
  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")
21
+ Kasket.cache.expects(:delete).with(Post.kasket_key_prefix + "title='#{@post.title}'")
22
+ Kasket.cache.expects(:delete).with(Post.kasket_key_prefix + "title='#{@post.title}'/first")
23
+ Kasket.cache.expects(:delete).with(Post.kasket_key_prefix + "blog_id=#{@post.blog_id}/id=#{@post.id}")
29
24
  Kasket.cache.expects(:delete).never
30
25
 
31
26
  @post.destroy
@@ -39,17 +34,16 @@ class CacheExpiryTest < ActiveSupport::TestCase
39
34
 
40
35
  should "clear all indices for instance when updated" do
41
36
  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")
37
+ Kasket.cache.expects(:delete).with(Post.kasket_key_prefix + "title='#{@post.title}'")
38
+ Kasket.cache.expects(:delete).with(Post.kasket_key_prefix + "title='#{@post.title}'/first")
39
+ Kasket.cache.expects(:delete).with(Post.kasket_key_prefix + "title='new title'")
40
+ Kasket.cache.expects(:delete).with(Post.kasket_key_prefix + "title='new title'/first")
41
+ Kasket.cache.expects(:delete).with(Post.kasket_key_prefix + "blog_id=#{@post.blog_id}/id=#{@post.id}")
49
42
  Kasket.cache.expects(:delete).never
50
43
 
51
44
  @post.title = "new title"
52
45
  @post.save
53
46
  end
47
+
54
48
  end
55
49
  end
@@ -0,0 +1,102 @@
1
+ require File.dirname(__FILE__) + '/helper'
2
+
3
+ class CacheTest < ActiveSupport::TestCase
4
+
5
+ context "Cache" do
6
+ setup do
7
+ @cache = Kasket::Cache.new
8
+ end
9
+
10
+ context "reading" do
11
+
12
+ should "work with non collection values" do
13
+ @cache.write('key', 'value')
14
+ assert_equal 'value', @cache.read('key')
15
+ end
16
+
17
+ should "fetch original results of stored collections" do
18
+ @cache.write('key1', 'value1')
19
+ @cache.write('key2', 'value2')
20
+ @cache.write('key3', 'value3')
21
+ @cache.write('collection_key', [ 'key1', 'key2', 'key3'])
22
+
23
+ assert_equal [ 'value1', 'value2', 'value3'], @cache.read('collection_key')
24
+ end
25
+
26
+ should "not impact original object" do
27
+ record = { 'id' => 1, 'color' => 'red' }
28
+ @cache.write('key', record)
29
+ record['id'] = 2
30
+
31
+ assert_not_equal record, @cache.read('key')
32
+ end
33
+
34
+ end
35
+
36
+ context "writing" do
37
+ setup do
38
+ @cache.write('key', 'value')
39
+ end
40
+
41
+ should "store the object locally" do
42
+ assert_equal 'value', @cache.local['key']
43
+ end
44
+
45
+ should "persist the object" do
46
+ @cache.clear_local
47
+ assert_equal 'value', @cache.read('key')
48
+ end
49
+
50
+ should "respect max collection size" do
51
+ original_max = Kasket::CONFIGURATION[:max_collection_size]
52
+ Kasket::CONFIGURATION[:max_collection_size] = 2
53
+
54
+ @cache.write('key', [ 'a', 'b'])
55
+ assert_equal 2, @cache.read('key').size
56
+
57
+ @cache.write('key2', ['a', 'b', 'c'])
58
+ assert_equal nil, @cache.read('key2')
59
+
60
+ Kasket::CONFIGURATION[:max_collection_size] = original_max
61
+ end
62
+
63
+ end
64
+
65
+ should "delete" do
66
+ @cache.write('key', 'value')
67
+ @cache.delete('key')
68
+
69
+ assert_equal nil, @cache.local['key']
70
+ assert_equal nil, @cache.read('key')
71
+ end
72
+
73
+ should "delete matched local" do
74
+ @cache.write('key1', 'value1')
75
+ @cache.write('key2', 'value2')
76
+ @cache.delete_matched_local(/2/)
77
+
78
+ assert_equal nil, @cache.local['key2']
79
+ assert_equal 'value1', @cache.local['key1']
80
+ assert_equal 'value2', @cache.read('key2')
81
+ end
82
+
83
+ should "delete local" do
84
+ @cache.write('key1', 'value1')
85
+ @cache.write('key2', 'value2')
86
+ @cache.delete_local('key1', 'key2')
87
+
88
+ assert_equal nil, @cache.local['key1']
89
+ assert_equal nil, @cache.local['key2']
90
+ assert_equal 'value1', @cache.read('key1')
91
+ assert_equal 'value2', @cache.read('key2')
92
+ end
93
+
94
+ should "clear local" do
95
+ @cache.write('key1', 'value1')
96
+ @cache.clear_local
97
+
98
+ assert @cache.local.blank?
99
+ end
100
+
101
+ end
102
+ end
@@ -5,3 +5,5 @@ test:
5
5
  username: root
6
6
  password:
7
7
  socket: /tmp/mysql.sock
8
+
9
+
@@ -3,13 +3,10 @@ require File.dirname(__FILE__) + '/helper'
3
3
  class DirtyTest < ActiveSupport::TestCase
4
4
  fixtures :blogs, :posts
5
5
 
6
- Post.has_kasket
7
- Post.kasket_dirty_methods :make_dirty!
8
-
9
6
  should "clear the indices when a dirty method is called" do
10
7
  post = Post.first
11
8
 
12
- pots = Post.find(post.id)
9
+ Post.cache { pots = Post.find(post.id) }
13
10
  assert(Rails.cache.read(post.kasket_key))
14
11
 
15
12
  post.make_dirty!
@@ -3,17 +3,24 @@ require File.dirname(__FILE__) + '/helper'
3
3
  class FindOneTest < ActiveSupport::TestCase
4
4
  fixtures :blogs, :posts
5
5
 
6
- Post.has_kasket
7
-
8
6
  should "cache find(id) calls" do
9
7
  post = Post.first
10
- assert_nil(Rails.cache.read(post.kasket_key))
8
+ Rails.cache.write(post.kasket_key, nil)
11
9
  assert_equal(post, Post.find(post.id))
12
10
  assert(Rails.cache.read(post.kasket_key))
13
11
  Post.connection.expects(:select_all).never
14
12
  assert_equal(post, Post.find(post.id))
15
13
  end
16
14
 
15
+ should "only cache on indexed attributes" do
16
+ Kasket.cache.expects(:read).twice
17
+ Post.find_by_id(1)
18
+ Post.find_by_id(1, :conditions => {:blog_id => 2})
19
+
20
+ Kasket.cache.expects(:read).never
21
+ Post.first :conditions => {:blog_id => 2}
22
+ end
23
+
17
24
  should "not use cache when using the :select option" do
18
25
  post = Post.first
19
26
  assert_nil(Rails.cache.read(post.kasket_key))
@@ -3,10 +3,7 @@ require File.dirname(__FILE__) + '/helper'
3
3
  class FindSomeTest < ActiveSupport::TestCase
4
4
  fixtures :blogs, :posts
5
5
 
6
- Post.has_kasket
7
- Post.has_kasket_on :blog_id
8
-
9
- should "cache find(id, id) calls" do
6
+ should_eventually "cache find(id, id) calls" do
10
7
  post1 = Post.first
11
8
  post2 = Post.last
12
9
 
@@ -17,32 +14,31 @@ class FindSomeTest < ActiveSupport::TestCase
17
14
 
18
15
  assert(Rails.cache.read(post1.kasket_key))
19
16
  assert(Rails.cache.read(post2.kasket_key))
20
-
21
17
  Post.connection.expects(:select_all).never
22
18
  Post.find(post1.id, post2.id)
23
19
  end
24
20
 
25
- should "only lookup the records that are not in the cache" do
21
+ should_eventually "only lookup the records that are not in the cache" do
26
22
  post1 = Post.first
27
23
  post2 = Post.last
28
24
  assert_equal(post1, Post.find(post1.id))
29
25
  assert(Rails.cache.read(post1.kasket_key))
30
26
  assert_nil(Rails.cache.read(post2.kasket_key))
31
27
 
32
- Post.expects(:find_some_without_kasket).with([post2.id], {}).returns([post2])
28
+ Post.expects(:find_by_sql_without_kasket).with("SELECT * FROM \'posts\' WHERE (\'posts\'.\'id\' IN (#{post1.id},#{post2.id})) ").returns([post2])
33
29
  found_posts = Post.find(post1.id, post2.id)
34
30
  assert_equal([post1, post2].map(&:id).sort, found_posts.map(&:id).sort)
35
31
 
36
- Post.expects(:find_some_without_kasket).never
32
+ Post.expects(:find_by_sql_without_kasket).never
37
33
  found_posts = Post.find(post1.id, post2.id)
38
34
  assert_equal([post1, post2].map(&:id).sort, found_posts.map(&:id).sort)
39
35
  end
40
36
 
41
- should "cache on index other than primary key" do
37
+ should_eventually "cache on index other than primary key" do
42
38
  blog = blogs(:a_blog)
43
39
  posts = Post.find_all_by_blog_id(blog.id)
44
40
 
45
- Post.expects(:find_every_without_kasket).never
41
+ Post.expects(:find_by_sql_without_kasket).never
46
42
 
47
43
  assert_equal(posts, Post.find_all_by_blog_id(blog.id))
48
44
  end
@@ -56,10 +56,15 @@ class Post < ActiveRecord::Base
56
56
  belongs_to :blog
57
57
  has_many :comments
58
58
 
59
+ has_kasket
60
+ has_kasket_on :title
61
+ has_kasket_on :blog_id, :id
62
+
59
63
  def make_dirty!
60
64
  self.updated_at = Time.now
61
65
  self.connection.execute("UPDATE posts SET updated_at = '#{updated_at.utc.to_s(:db)}' WHERE id = #{id}")
62
66
  end
67
+ kasket_dirty_methods :make_dirty!
63
68
  end
64
69
 
65
70
  class Blog < ActiveRecord::Base
@@ -0,0 +1,98 @@
1
+ require File.dirname(__FILE__) + '/helper'
2
+ require 'kasket/query_parser'
3
+
4
+ class ParserTest < ActiveSupport::TestCase
5
+
6
+ context "Parsing" do
7
+ setup do
8
+ @parser = Kasket::QueryParser.new(Post)
9
+ end
10
+
11
+ should "extract conditions" do
12
+ assert_equal [[:color, "red"], [:size, "big"]], @parser.parse('SELECT * FROM `posts` WHERE (`posts`.`color` = red AND `posts`.`size` = big)')[:attributes]
13
+ end
14
+
15
+ should "extract required index" do
16
+ assert_equal [:color, :size], @parser.parse('SELECT * FROM `posts` WHERE (`posts`.`color` = red AND `posts`.`size` = big)')[:index]
17
+ end
18
+
19
+ should "only support queries against its model's table" do
20
+ assert !@parser.parse('SELECT * FROM `apples` WHERE (`users`.`id` = 2) ')
21
+ end
22
+
23
+ should "support cachable queries" do
24
+ assert @parser.parse('SELECT * FROM `posts` WHERE (`posts`.`id` = 2) ')
25
+ assert @parser.parse('SELECT * FROM `posts` WHERE (`posts`.`id` = 2) LIMIT 1')
26
+ end
27
+
28
+ should "support vaguely formatted queries" do
29
+ assert @parser.parse('SELECT * FROM "posts" WHERE (color = red AND size = big)')
30
+ end
31
+
32
+ context "extract options" do
33
+
34
+ should "provide the limit" do
35
+ sql = 'SELECT * FROM `posts` WHERE (`posts`.`id` = 2)'
36
+ assert_equal nil, @parser.parse(sql)[:limit]
37
+
38
+ sql << ' LIMIT 1'
39
+ assert_equal 1, @parser.parse(sql)[:limit]
40
+ end
41
+
42
+ end
43
+
44
+ context "unsupported queries" do
45
+
46
+ should "include advanced limits" do
47
+ assert !@parser.parse('SELECT * FROM `posts` WHERE (color = red AND size = big) LIMIT 2')
48
+ end
49
+
50
+ should "include joins" do
51
+ assert !@parser.parse('SELECT * FROM `posts`, `trees` JOIN ON apple.tree_id = tree.id WHERE (color = red)')
52
+ end
53
+
54
+ should "include specific selects" do
55
+ assert !@parser.parse('SELECT id FROM `posts` WHERE (color = red)')
56
+ end
57
+
58
+ should "include offset" do
59
+ assert !@parser.parse('SELECT * FROM `posts` WHERE (color = red) LIMIT 1 OFFSET 2')
60
+ end
61
+
62
+ should "include order" do
63
+ assert !@parser.parse('SELECT * FROM `posts` WHERE (color = red) ORDER DESC')
64
+ end
65
+
66
+ should "include the OR operator" do
67
+ assert !@parser.parse('SELECT * FROM `posts` WHERE (color = red OR size = big) LIMIT 2')
68
+ end
69
+
70
+ should "include the IN operator" do
71
+ assert !@parser.parse('SELECT * FROM `posts` WHERE (id IN (1,2,3))')
72
+ end
73
+
74
+ end
75
+
76
+ context "key generation" do
77
+ should "include the table name and version" do
78
+ assert_match(/^kasket\/posts\/version=3558\//, @parser.parse('SELECT * FROM `posts` WHERE (id = 1)')[:key])
79
+ end
80
+
81
+ should "include all indexed attributes" do
82
+ assert_match(/id=1$/, @parser.parse('SELECT * FROM `posts` WHERE (id = 1)')[:key])
83
+ assert_match(/blog_id=2\/id=1$/, @parser.parse('SELECT * FROM `posts` WHERE (id = 1 AND blog_id = 2)')[:key])
84
+ assert_match(/id=1\/title='world\\'s best title'$/, @parser.parse("SELECT * FROM `posts` WHERE (id = 1 AND title = 'world\\'s best title')")[:key])
85
+ end
86
+
87
+ context "when limit 1" do
88
+ should "add /first to the key if the index does not include id" do
89
+ assert_match(/title='a'\/first$/, @parser.parse("SELECT * FROM `posts` WHERE (title = 'a') LIMIT 1")[:key])
90
+ end
91
+ should "not add /first to the key when the index includes id" do
92
+ assert_match(/id=1$/, @parser.parse("SELECT * FROM `posts` WHERE (id = 1) LIMIT 1")[:key])
93
+ end
94
+ end
95
+ end
96
+ end
97
+
98
+ end
@@ -0,0 +1,45 @@
1
+ require File.dirname(__FILE__) + '/helper'
2
+
3
+ class ReadMixinTest < ActiveSupport::TestCase
4
+
5
+ context "find by sql with kasket" do
6
+ setup do
7
+ @database_results = [ { 'id' => 1, 'title' => 'Hello' }, { 'id' => 2, 'title' => 'World' } ]
8
+ @records = @database_results.map { |r| Post.send(:instantiate, r) }
9
+ Post.stubs(:find_by_sql_without_kasket).returns(@records)
10
+ end
11
+
12
+ should "handle unsupported sql" do
13
+ assert_equal @records, Post.find_by_sql_with_kasket('select unsupported sql statement')
14
+ assert Kasket.cache.local.empty?
15
+ end
16
+
17
+ should "read results" do
18
+ Kasket.cache.write('kasket/posts/version=3558/id=1', @database_results.first)
19
+ assert_equal [ @records.first ], Post.find_by_sql('SELECT * FROM `posts` WHERE (id = 1)'), Kasket.cache.inspect
20
+ end
21
+
22
+ should "store results in kasket" do
23
+ Post.find_by_sql('SELECT * FROM `posts` WHERE (id = 1)')
24
+
25
+ assert_equal @database_results.first, Kasket.cache.read('kasket/posts/version=3558/id=1'), Kasket.cache.inspect
26
+ end
27
+
28
+ context "modifying results" do
29
+ setup do
30
+ Kasket.cache.write('kasket/posts/version=3558/id=1', @database_results.first)
31
+ @record = Post.find_by_sql('SELECT * FROM `posts` WHERE (id = 1)').first
32
+ @record.instance_variable_get(:@attributes)['id'] = 3
33
+ end
34
+
35
+ should "not impact other queries" do
36
+ same_record = Post.find_by_sql('SELECT * FROM `posts` WHERE (id = 1)').first
37
+
38
+ assert_not_equal @record, same_record
39
+ end
40
+
41
+ end
42
+
43
+ end
44
+
45
+ end
@@ -1,4 +1,4 @@
1
- # This file is auto-generated from the current state of the database. Instead of editing this file,
1
+ # This file is auto-generated from the current state of the database. Instead of editing this file,
2
2
  # please use the migrations feature of Active Record to incrementally modify your database, and
3
3
  # then regenerate this schema definition.
4
4
  #
metadata CHANGED
@@ -1,15 +1,16 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kasket
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.5
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mick Staugaard
8
+ - Eric Chapweske
8
9
  autorequire:
9
10
  bindir: bin
10
11
  cert_chain: []
11
12
 
12
- date: 2009-12-02 00:00:00 -08:00
13
+ date: 2009-12-05 00:00:00 -08:00
13
14
  default_executable:
14
15
  dependencies:
15
16
  - !ruby/object:Gem::Dependency
@@ -52,7 +53,7 @@ dependencies:
52
53
  - !ruby/object:Gem::Version
53
54
  version: "0"
54
55
  version:
55
- description: A rewrite of cache money
56
+ description: put's a cap on your queries
56
57
  email: mick@staugaard.com
57
58
  executables: []
58
59
 
@@ -71,14 +72,15 @@ files:
71
72
  - lib/kasket.rb
72
73
  - lib/kasket/active_record_patches.rb
73
74
  - lib/kasket/cache.rb
74
- - lib/kasket/conditions_parser.rb
75
75
  - lib/kasket/configuration_mixin.rb
76
76
  - lib/kasket/dirty_mixin.rb
77
+ - lib/kasket/query_parser.rb
77
78
  - lib/kasket/rack_middleware.rb
78
79
  - lib/kasket/read_mixin.rb
79
80
  - lib/kasket/reload_association_mixin.rb
80
81
  - lib/kasket/write_mixin.rb
81
82
  - test/cache_expiry_test.rb
83
+ - test/cache_test.rb
82
84
  - test/database.yml
83
85
  - test/dirty_test.rb
84
86
  - test/find_one_test.rb
@@ -87,8 +89,9 @@ files:
87
89
  - test/fixtures/comments.yml
88
90
  - test/fixtures/posts.yml
89
91
  - test/helper.rb
92
+ - test/parser_test.rb
93
+ - test/read_mixin_test.rb
90
94
  - test/schema.rb
91
- - test/serialization_test.rb
92
95
  has_rdoc: true
93
96
  homepage: http://github.com/staugaard/kasket
94
97
  licenses: []
@@ -119,9 +122,11 @@ specification_version: 3
119
122
  summary: A write back caching layer on active record
120
123
  test_files:
121
124
  - test/cache_expiry_test.rb
125
+ - test/cache_test.rb
122
126
  - test/dirty_test.rb
123
127
  - test/find_one_test.rb
124
128
  - test/find_some_test.rb
125
129
  - test/helper.rb
130
+ - test/parser_test.rb
131
+ - test/read_mixin_test.rb
126
132
  - test/schema.rb
127
- - test/serialization_test.rb
@@ -1,79 +0,0 @@
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
@@ -1,21 +0,0 @@
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