kashmir 0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +49 -0
- data/LICENSE.txt +22 -0
- data/README.md +563 -0
- data/Rakefile +11 -0
- data/kashmir.gemspec +35 -0
- data/lib/kashmir.rb +42 -0
- data/lib/kashmir/caching.rb +50 -0
- data/lib/kashmir/dsl.rb +41 -0
- data/lib/kashmir/extensions.rb +18 -0
- data/lib/kashmir/inline_dsl.rb +13 -0
- data/lib/kashmir/patches/active_record.rb +68 -0
- data/lib/kashmir/plugins/active_record_representation.rb +14 -0
- data/lib/kashmir/plugins/ar.rb +49 -0
- data/lib/kashmir/plugins/ar_relation.rb +35 -0
- data/lib/kashmir/plugins/memcached_caching.rb +83 -0
- data/lib/kashmir/plugins/memory_caching.rb +66 -0
- data/lib/kashmir/plugins/null_caching.rb +42 -0
- data/lib/kashmir/representable.rb +88 -0
- data/lib/kashmir/representation.rb +117 -0
- data/lib/kashmir/version.rb +3 -0
- data/test/activerecord_test.rb +127 -0
- data/test/activerecord_tricks_test.rb +54 -0
- data/test/ar_test_helper.rb +34 -0
- data/test/caching_test.rb +197 -0
- data/test/dsl_test.rb +162 -0
- data/test/inline_dsl_test.rb +108 -0
- data/test/kashmir_test.rb +216 -0
- data/test/support/ar_models.rb +69 -0
- data/test/support/factories.rb +33 -0
- data/test/support/schema.rb +32 -0
- data/test/test_helper.rb +9 -0
- metadata +217 -0
@@ -0,0 +1,54 @@
|
|
1
|
+
require 'ar_test_helper'
|
2
|
+
|
3
|
+
describe 'ActiveRecord performance tricks' do
|
4
|
+
|
5
|
+
before(:all) do
|
6
|
+
TestData.create_tom
|
7
|
+
TestData.create_netto
|
8
|
+
end
|
9
|
+
|
10
|
+
describe "Query preload to avoid N+1 queries" do
|
11
|
+
|
12
|
+
it 'tries to preload records whenever possible' do
|
13
|
+
selects = track_queries do
|
14
|
+
AR::Chef.all.each do |chef|
|
15
|
+
chef.recipes.to_a
|
16
|
+
end
|
17
|
+
end
|
18
|
+
# SELECT * FROM chefs
|
19
|
+
# SELECT "recipes".* FROM "recipes" WHERE "recipes"."chef_id" = ?
|
20
|
+
# SELECT "recipes".* FROM "recipes" WHERE "recipes"."chef_id" = ?
|
21
|
+
assert_equal 3, selects.size
|
22
|
+
|
23
|
+
selects = track_queries do
|
24
|
+
AR::Chef.all.represent([:recipes])
|
25
|
+
end
|
26
|
+
# SELECT "chefs".* FROM "chefs"
|
27
|
+
# SELECT "recipes".* FROM "recipes" WHERE "recipes"."chef_id" IN (1, 2)
|
28
|
+
assert_equal 2, selects.size
|
29
|
+
end
|
30
|
+
|
31
|
+
it 'preloads queries per each level in the tree' do
|
32
|
+
selects = track_queries do
|
33
|
+
AR::Chef.all.each do |chef|
|
34
|
+
chef.recipes.each do |recipe|
|
35
|
+
recipe.ingredients.to_a
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
# SELECT "chefs".* FROM "chefs"
|
40
|
+
# (2x) SELECT "recipes".* FROM "recipes" WHERE "recipes"."chef_id" = ?
|
41
|
+
# (4x) SELECT "ingredients".* FROM "ingredients" INNER JOIN "recipes_ingredients" ...
|
42
|
+
assert_equal 7, selects.size
|
43
|
+
|
44
|
+
selects = track_queries do
|
45
|
+
AR::Chef.all.represent([ :recipes => [ :ingredients => [:name] ] ])
|
46
|
+
end
|
47
|
+
# SELECT "chefs".* FROM "chefs"
|
48
|
+
# SELECT "recipes".* FROM "recipes" WHERE "recipes"."chef_id" IN (1, 2)
|
49
|
+
# SELECT "recipes_ingredients".* FROM "recipes_ingredients" WHERE "recipes_ingredients"."recipe_id" IN (1, 2, 3, 4)
|
50
|
+
# SELECT "ingredients".* FROM "ingredients" WHERE "ingredients"."id" IN (1, 2, 3, 4, 5, 6, 7, 8)
|
51
|
+
assert_equal 4, selects.size
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
require 'minitest/around'
|
3
|
+
|
4
|
+
require 'active_record'
|
5
|
+
|
6
|
+
ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
|
7
|
+
|
8
|
+
require 'support/schema'
|
9
|
+
require 'support/ar_models'
|
10
|
+
require 'support/factories'
|
11
|
+
|
12
|
+
|
13
|
+
class Minitest::Test
|
14
|
+
def around(&block)
|
15
|
+
ActiveRecord::Base.transaction do
|
16
|
+
block.call
|
17
|
+
raise ActiveRecord::Rollback
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def track_queries
|
23
|
+
selects = []
|
24
|
+
queries_collector = lambda do |name, start, finish, id, payload|
|
25
|
+
selects << payload
|
26
|
+
end
|
27
|
+
|
28
|
+
ActiveRecord::Base.connection.clear_query_cache
|
29
|
+
ActiveSupport::Notifications.subscribed(queries_collector, 'sql.active_record') do
|
30
|
+
yield
|
31
|
+
end
|
32
|
+
|
33
|
+
selects.map { |sel| sel[:sql] }
|
34
|
+
end
|
@@ -0,0 +1,197 @@
|
|
1
|
+
require 'ar_test_helper'
|
2
|
+
|
3
|
+
describe 'Caching' do
|
4
|
+
|
5
|
+
before(:all) do
|
6
|
+
|
7
|
+
Kashmir.init(
|
8
|
+
cache_client: Kashmir::Caching::Memory.new
|
9
|
+
)
|
10
|
+
|
11
|
+
TestData.create_tom
|
12
|
+
@restaurant = AR::Restaurant.find_by_name('Chef Tom Belly Burgers')
|
13
|
+
end
|
14
|
+
|
15
|
+
before(:each) do
|
16
|
+
Kashmir.caching.flush!
|
17
|
+
end
|
18
|
+
|
19
|
+
def from_cache(definition, model)
|
20
|
+
Kashmir.caching.from_cache(definition, model)
|
21
|
+
end
|
22
|
+
|
23
|
+
describe 'flat data' do
|
24
|
+
it 'loads the same exact data from cache' do
|
25
|
+
representation = @restaurant.represent([:name])
|
26
|
+
@restaurant.reload
|
27
|
+
cached_representation = @restaurant.represent([:name])
|
28
|
+
assert_equal representation, cached_representation
|
29
|
+
end
|
30
|
+
|
31
|
+
it 'stores the data in cache' do
|
32
|
+
representation = @restaurant.represent([:name])
|
33
|
+
cached_representation = from_cache([:name], @restaurant)
|
34
|
+
assert_equal representation, cached_representation
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
describe 'references to other representations' do
|
39
|
+
it 'does not perform the same query twice' do
|
40
|
+
selects = track_queries do
|
41
|
+
@restaurant.represent([:name, :owner])
|
42
|
+
end
|
43
|
+
assert_equal 1, selects.size
|
44
|
+
|
45
|
+
# clear active record cache for this instance
|
46
|
+
@restaurant.reload
|
47
|
+
selects = track_queries do
|
48
|
+
@restaurant.represent([:name, :owner])
|
49
|
+
end
|
50
|
+
assert_equal 0, selects.size
|
51
|
+
end
|
52
|
+
|
53
|
+
it 'loads the same exact data from cache' do
|
54
|
+
representation = @restaurant.represent([:name, :owner])
|
55
|
+
@restaurant.reload
|
56
|
+
cached_representation = @restaurant.represent([:name, :owner])
|
57
|
+
assert_equal representation, cached_representation
|
58
|
+
end
|
59
|
+
|
60
|
+
it 'stores the data in cache at every level' do
|
61
|
+
representation = @restaurant.represent([:name, owner: [:name] ])
|
62
|
+
cached_restaurant_with_chef = from_cache([:name, owner: [ :name ]], @restaurant)
|
63
|
+
|
64
|
+
assert_equal representation, cached_restaurant_with_chef
|
65
|
+
|
66
|
+
chef = @restaurant.owner
|
67
|
+
cached_chef = from_cache([:name], chef)
|
68
|
+
|
69
|
+
assert_equal cached_chef, representation[:owner]
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
describe 'nesting' do
|
74
|
+
before(:all) do
|
75
|
+
@chef = @restaurant.owner
|
76
|
+
@chef.reload
|
77
|
+
end
|
78
|
+
|
79
|
+
it 'caches at every level' do
|
80
|
+
representation = @chef.represent([:name, :restaurant =>[ :name, :rating =>[ :value ]]])
|
81
|
+
fully_cached_chef = from_cache([:name, :restaurant =>[ :name, :rating =>[ :value ]]], @chef)
|
82
|
+
assert_equal representation, fully_cached_chef
|
83
|
+
|
84
|
+
fully_cached_restaurant = from_cache([ :name, :rating => [:value] ], @restaurant)
|
85
|
+
assert_equal representation[:restaurant], fully_cached_restaurant
|
86
|
+
|
87
|
+
cached_rating = from_cache([:value], @restaurant.rating)
|
88
|
+
assert_equal representation[:restaurant][:rating], cached_rating
|
89
|
+
|
90
|
+
assert_equal 3, Kashmir.caching.keys.size
|
91
|
+
end
|
92
|
+
|
93
|
+
it 'tries to hit the cache at every level' do
|
94
|
+
selects = track_queries do
|
95
|
+
representation = @chef.represent([:name, :restaurant =>[ :name, :rating =>[ :value ]]])
|
96
|
+
end
|
97
|
+
# SELECT "restaurants".* FROM "restaurants" WHERE "restaurants"."owner_id" = ? LIMIT 1
|
98
|
+
# SELECT "ratings".* FROM "ratings" WHERE "ratings"."restaurant_id" = ? LIMIT 1
|
99
|
+
assert_equal selects.size, 2
|
100
|
+
|
101
|
+
@chef.reload
|
102
|
+
selects = track_queries do
|
103
|
+
representation = @chef.represent([:name, :restaurant =>[ :name, :rating =>[ :value ]]])
|
104
|
+
end
|
105
|
+
assert_equal selects.size, 0
|
106
|
+
end
|
107
|
+
|
108
|
+
it 'tries to fill holes in the cache graph' do
|
109
|
+
definition = [:name, :restaurant =>[ :name, :rating =>[ :value ]]]
|
110
|
+
representation = @chef.represent(definition)
|
111
|
+
Kashmir.caching.clear(definition, @chef)
|
112
|
+
|
113
|
+
assert_equal 2, Kashmir.caching.keys.size
|
114
|
+
|
115
|
+
@chef.reload
|
116
|
+
selects = track_queries do
|
117
|
+
@chef.represent(definition)
|
118
|
+
end
|
119
|
+
# ratings is still cached
|
120
|
+
# SELECT "restaurants".* FROM "restaurants" WHERE "restaurants"."owner_id" = ? LIMIT 1
|
121
|
+
assert_equal selects.size, 1
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
describe 'collections' do
|
126
|
+
it 'caches every item' do
|
127
|
+
presented_recipes = AR::Recipe.all.represent([:title])
|
128
|
+
|
129
|
+
cached_keys = %w(
|
130
|
+
presenter:AR::Recipe:1:[:title]
|
131
|
+
presenter:AR::Recipe:2:[:title]
|
132
|
+
)
|
133
|
+
|
134
|
+
assert_equal cached_keys, Kashmir.caching.keys
|
135
|
+
end
|
136
|
+
|
137
|
+
it 'presents from cache' do
|
138
|
+
selects = track_queries do
|
139
|
+
AR::Recipe.all.represent([:title, :ingredients => [:name]])
|
140
|
+
end
|
141
|
+
# SELECT "recipes_ingredients".* FROM "recipes_ingredients" WHERE "recipes_ingredients"."recipe_id" IN (1, 2)
|
142
|
+
# SELECT "ingredients".* FROM "ingredients" WHERE "ingredients"."id" IN (1, 2, 3, 4)
|
143
|
+
assert_equal 3, selects.size
|
144
|
+
|
145
|
+
selects = track_queries do
|
146
|
+
AR::Recipe.all.represent([:title, :ingredients => [:name]])
|
147
|
+
end
|
148
|
+
# SELECT "recipes".* FROM "recipes"
|
149
|
+
assert_equal 1, selects.size
|
150
|
+
|
151
|
+
cache_keys = [
|
152
|
+
"presenter:AR::Ingredient:1:[:name]",
|
153
|
+
"presenter:AR::Ingredient:2:[:name]",
|
154
|
+
"presenter:AR::Recipe:1:[:title, {:ingredients=>[:name]}]",
|
155
|
+
"presenter:AR::Ingredient:3:[:name]",
|
156
|
+
"presenter:AR::Ingredient:4:[:name]",
|
157
|
+
"presenter:AR::Recipe:2:[:title, {:ingredients=>[:name]}]"
|
158
|
+
]
|
159
|
+
|
160
|
+
assert_equal cache_keys, Kashmir.caching.keys
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
describe 'misc' do
|
165
|
+
|
166
|
+
it 'does not cache already cached results' do
|
167
|
+
@restaurant.represent([:name])
|
168
|
+
assert_equal 1, Kashmir.caching.keys.size
|
169
|
+
|
170
|
+
Kashmir.caching.expects(:store_presenter).never
|
171
|
+
@restaurant.represent([:name])
|
172
|
+
end
|
173
|
+
|
174
|
+
describe 'skipping cache' do
|
175
|
+
# see current_customer_count in test/support/ar_models.rb
|
176
|
+
|
177
|
+
before(:each) do
|
178
|
+
@restaurant.update_attributes(current_customer_count: 10)
|
179
|
+
@representation = @restaurant.represent([:name, :current_customer_count])
|
180
|
+
end
|
181
|
+
|
182
|
+
it 'still renders everything normally' do
|
183
|
+
assert_equal @representation, { name: 'Chef Tom Belly Burgers', current_customer_count: 10 }
|
184
|
+
end
|
185
|
+
|
186
|
+
it 'does not include that field in the key' do
|
187
|
+
assert_equal %w(presenter:AR::Restaurant:1:[:name]), Kashmir.caching.keys
|
188
|
+
end
|
189
|
+
|
190
|
+
it 'does not insert value in the cached results' do
|
191
|
+
assert_nil Kashmir.caching.from_cache([:name, :current_customar_count], @restaurant)
|
192
|
+
assert_equal Kashmir.caching.from_cache([:name], @restaurant), { name: 'Chef Tom Belly Burgers' }
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
196
|
+
end
|
197
|
+
end
|
data/test/dsl_test.rb
ADDED
@@ -0,0 +1,162 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
describe Kashmir do
|
4
|
+
|
5
|
+
module DSLTesting
|
6
|
+
class Recipe < OpenStruct
|
7
|
+
include Kashmir
|
8
|
+
|
9
|
+
representations do
|
10
|
+
rep(:num_steps)
|
11
|
+
rep(:title)
|
12
|
+
rep(:chef)
|
13
|
+
rep(:ingredients)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
class Chef < OpenStruct
|
18
|
+
include Kashmir
|
19
|
+
|
20
|
+
representations do
|
21
|
+
rep(:full_name)
|
22
|
+
end
|
23
|
+
|
24
|
+
def full_name
|
25
|
+
"#{first_name} #{last_name}"
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
class SimpleRecipeRepresenter
|
30
|
+
include Kashmir::Dsl
|
31
|
+
|
32
|
+
prop :num_steps
|
33
|
+
prop :title
|
34
|
+
end
|
35
|
+
|
36
|
+
class ChefRepresenter
|
37
|
+
include Kashmir::Dsl
|
38
|
+
|
39
|
+
prop :full_name
|
40
|
+
end
|
41
|
+
|
42
|
+
class RecipeWithChefRepresenter
|
43
|
+
include Kashmir::Dsl
|
44
|
+
|
45
|
+
prop :title
|
46
|
+
embed :chef, ChefRepresenter
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
before do
|
51
|
+
@chef = DSLTesting::Chef.new(first_name: 'Netto', last_name: 'Farah')
|
52
|
+
@brisket = DSLTesting::Recipe.new(title: 'BBQ Brisket', num_steps: 2, chef: @chef)
|
53
|
+
end
|
54
|
+
|
55
|
+
it 'translates to representation definitions' do
|
56
|
+
definitions = DSLTesting::SimpleRecipeRepresenter.definitions
|
57
|
+
assert_equal definitions, [:num_steps, :title]
|
58
|
+
end
|
59
|
+
|
60
|
+
it 'generates the same representations as hardcoded definitions' do
|
61
|
+
cooked_brisket = @brisket.represent([:num_steps, :title])
|
62
|
+
assert_equal cooked_brisket, { title: 'BBQ Brisket', num_steps: 2 }
|
63
|
+
assert_equal cooked_brisket, @brisket.represent(DSLTesting::SimpleRecipeRepresenter.definitions)
|
64
|
+
end
|
65
|
+
|
66
|
+
it 'generates nested representations' do
|
67
|
+
brisket_with_chef = @brisket.represent([:title, { :chef => [:full_name] }])
|
68
|
+
assert_equal brisket_with_chef, {
|
69
|
+
title: 'BBQ Brisket',
|
70
|
+
chef: {
|
71
|
+
full_name: 'Netto Farah'
|
72
|
+
}
|
73
|
+
}
|
74
|
+
|
75
|
+
assert_equal brisket_with_chef, @brisket.represent(DSLTesting::RecipeWithChefRepresenter.definitions)
|
76
|
+
end
|
77
|
+
|
78
|
+
describe 'Nested inline representations' do
|
79
|
+
|
80
|
+
module DSLTesting
|
81
|
+
class RecipeWithInlineChefRepresenter
|
82
|
+
include Kashmir::Dsl
|
83
|
+
|
84
|
+
prop :title
|
85
|
+
|
86
|
+
inline :chef do
|
87
|
+
prop :full_name
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
class Ingredient < OpenStruct
|
92
|
+
include Kashmir
|
93
|
+
|
94
|
+
representations do
|
95
|
+
rep(:name)
|
96
|
+
rep(:quantity)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
it 'generates nested representations' do
|
102
|
+
brisket_with_chef = @brisket.represent([:title, { :chef => [:full_name] }])
|
103
|
+
assert_equal brisket_with_chef, {
|
104
|
+
title: 'BBQ Brisket',
|
105
|
+
chef: {
|
106
|
+
full_name: 'Netto Farah'
|
107
|
+
}
|
108
|
+
}
|
109
|
+
|
110
|
+
assert_equal brisket_with_chef, @brisket.represent(DSLTesting::RecipeWithInlineChefRepresenter.definitions)
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
describe 'Collections' do
|
115
|
+
module DSLTesting
|
116
|
+
class RecipeWithIngredientsRepresenter
|
117
|
+
include Kashmir::Dsl
|
118
|
+
|
119
|
+
prop :title
|
120
|
+
|
121
|
+
inline :ingredients do
|
122
|
+
prop :name
|
123
|
+
prop :quantity
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
it 'generates nested collections' do
|
129
|
+
@brisket.ingredients = [
|
130
|
+
DSLTesting::Ingredient.new(name: 'Beef', quantity: '8oz'),
|
131
|
+
DSLTesting::Ingredient.new(name: 'BBQ Sauce', quantity: 'plenty!'),
|
132
|
+
]
|
133
|
+
|
134
|
+
cooked_brisket = {
|
135
|
+
title: 'BBQ Brisket',
|
136
|
+
ingredients: [
|
137
|
+
{ name: 'Beef', quantity: '8oz' },
|
138
|
+
{ name: 'BBQ Sauce', quantity: 'plenty!' }
|
139
|
+
]
|
140
|
+
}
|
141
|
+
|
142
|
+
assert_equal cooked_brisket, @brisket.represent(DSLTesting::RecipeWithIngredientsRepresenter.definitions)
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
describe "Kashmir.props" do
|
147
|
+
|
148
|
+
module DSLTesting
|
149
|
+
|
150
|
+
class MultiPropRecipeRepresenter
|
151
|
+
include Kashmir::Dsl
|
152
|
+
|
153
|
+
props :num_steps, :title
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
it 'allows the client to specify many props at once' do
|
158
|
+
definitions = DSLTesting::MultiPropRecipeRepresenter.definitions
|
159
|
+
assert_equal definitions, [:num_steps, :title]
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|