kasket 0.5.5 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
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