simple_cacheable 1.3.1 → 1.3.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/.travis.yml +0 -1
- data/Gemfile +2 -0
- data/README.md +15 -2
- data/cacheable.gemspec +2 -2
- data/lib/cacheable/version.rb +1 -1
- data/lib/cacheable.rb +84 -14
- data/spec/cacheable_spec.rb +94 -1
- data/spec/models/account.rb +2 -0
- data/spec/models/group.rb +4 -0
- data/spec/models/post.rb +9 -2
- data/spec/models/tag.rb +5 -0
- data/spec/models/user.rb +3 -1
- data/spec/spec_helper.rb +33 -7
- metadata +8 -8
data/.travis.yml
CHANGED
data/Gemfile
CHANGED
data/README.md
CHANGED
@@ -43,8 +43,13 @@ Usage
|
|
43
43
|
has_many :comments, :as => :commentable
|
44
44
|
|
45
45
|
model_cache do
|
46
|
-
with_key
|
47
|
-
|
46
|
+
with_key # post.find_cached(1)
|
47
|
+
with_class_method :default_post # Post.default_post
|
48
|
+
with_association :user, :comments # post.cached_user, post.cached_comments
|
49
|
+
end
|
50
|
+
|
51
|
+
def self.default_post
|
52
|
+
Post.first
|
48
53
|
end
|
49
54
|
end
|
50
55
|
|
@@ -66,7 +71,15 @@ add the following code to your Gemfile
|
|
66
71
|
gem "simple_cacheable", :require => "cacheable"
|
67
72
|
|
68
73
|
|
74
|
+
Gotchas
|
75
|
+
-------
|
76
|
+
|
77
|
+
Caching, and caching invalidation specifically, can be hard and confusing. Simple Cacheable methods should
|
78
|
+
expire correctly in most cases. Be careful using `with_method` and `with_class_method`, they should
|
79
|
+
specifically not be used to return collections. This is demonstrated well Tobias Lutke's presentation: [Rockstar Memcaching][2].
|
80
|
+
|
69
81
|
Copyright © 2011 Richard Huang (flyerhzm@gmail.com), released under the MIT license
|
70
82
|
|
71
83
|
|
72
84
|
[1]:https://github.com/flyerhzm/rails-bestpractices.com
|
85
|
+
[2]:http://www.infoq.com/presentations/lutke-rockstar-memcaching
|
data/cacheable.gemspec
CHANGED
@@ -6,8 +6,8 @@ Gem::Specification.new do |s|
|
|
6
6
|
s.name = "simple_cacheable"
|
7
7
|
s.version = Cacheable::VERSION
|
8
8
|
s.platform = Gem::Platform::RUBY
|
9
|
-
s.authors = ["Richard Huang"]
|
10
|
-
s.email = ["flyerhzm@gmail.com"]
|
9
|
+
s.authors = ["Richard Huang", "Scott Carleton"]
|
10
|
+
s.email = ["flyerhzm@gmail.com", "scott@artsicle.com"]
|
11
11
|
s.homepage = "https://github.com/flyerhzm/simple-cacheable"
|
12
12
|
s.summary = %q{a simple cache implementation based on activerecord}
|
13
13
|
s.description = %q{a simple cache implementation based on activerecord}
|
data/lib/cacheable/version.rb
CHANGED
data/lib/cacheable.rb
CHANGED
@@ -3,9 +3,13 @@ require 'uri'
|
|
3
3
|
module Cacheable
|
4
4
|
def self.included(base)
|
5
5
|
base.class_eval do
|
6
|
-
class <<self
|
6
|
+
class << self
|
7
7
|
def model_cache(&block)
|
8
|
-
class_attribute :cached_key,
|
8
|
+
class_attribute :cached_key,
|
9
|
+
:cached_indices,
|
10
|
+
:cached_methods,
|
11
|
+
:cached_class_methods,
|
12
|
+
:cached_associations
|
9
13
|
instance_exec &block
|
10
14
|
end
|
11
15
|
|
@@ -70,9 +74,33 @@ module Cacheable
|
|
70
74
|
end
|
71
75
|
end
|
72
76
|
|
77
|
+
# Cached class method
|
78
|
+
# Should expire on any instance save
|
79
|
+
def with_class_method(*methods)
|
80
|
+
self.cached_class_methods = methods
|
81
|
+
|
82
|
+
class_eval <<-EOF
|
83
|
+
after_commit :expire_class_method_cache, on: :update
|
84
|
+
EOF
|
85
|
+
|
86
|
+
methods.each do |meth|
|
87
|
+
class_eval <<-EOF
|
88
|
+
def self.cached_#{meth}
|
89
|
+
Rails.cache.fetch class_method_cache_key("#{meth}") do
|
90
|
+
#{meth}
|
91
|
+
end
|
92
|
+
end
|
93
|
+
EOF
|
94
|
+
end
|
95
|
+
|
96
|
+
end
|
97
|
+
|
73
98
|
def with_association(*association_names)
|
99
|
+
self.cached_associations = association_names
|
100
|
+
|
74
101
|
association_names.each do |association_name|
|
75
102
|
association = reflect_on_association(association_name)
|
103
|
+
|
76
104
|
if :belongs_to == association.macro
|
77
105
|
polymorphic = association.options[:polymorphic]
|
78
106
|
polymorphic ||= false
|
@@ -90,32 +118,55 @@ module Cacheable
|
|
90
118
|
# FIXME it should be the only reflection but I'm not 100% positive
|
91
119
|
reverse_through_association = through_association.klass.reflect_on_all_associations(:belongs_to).first
|
92
120
|
|
121
|
+
# In a through association it doesn't have to be a belongs_to
|
93
122
|
reverse_association = association.klass.reflect_on_all_associations(:belongs_to).find { |reverse_association|
|
94
123
|
reverse_association.options[:polymorphic] ? reverse_association.name == association.source_reflection.options[:as] : reverse_association.klass == self
|
95
124
|
}
|
96
125
|
|
97
|
-
|
98
|
-
|
126
|
+
if reverse_association
|
127
|
+
association.klass.class_eval <<-EOF
|
128
|
+
after_commit :expire_#{association_name}_cache
|
99
129
|
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
130
|
+
def expire_#{association_name}_cache
|
131
|
+
if respond_to? :cached_#{reverse_association.name}
|
132
|
+
# cached_viewable.expire_association_cache
|
133
|
+
cached_#{reverse_association.name}.expire_association_cache(:#{association_name})
|
134
|
+
else
|
135
|
+
#{reverse_association.name}.#{reverse_through_association.name}.expire_association_cache(:#{association_name})
|
136
|
+
end
|
106
137
|
end
|
107
|
-
|
108
|
-
|
138
|
+
EOF
|
139
|
+
end
|
140
|
+
elsif :has_and_belongs_to_many == association.macro
|
141
|
+
# No such thing as a polymorphic has_and_belongs_to_many
|
142
|
+
reverse_association = association.klass.reflect_on_all_associations(:has_and_belongs_to_many).find { |reverse_association|
|
143
|
+
reverse_association.klass == self
|
144
|
+
}
|
145
|
+
|
146
|
+
association.klass.class_eval <<-EOF
|
147
|
+
after_commit :expire_#{association_name}_cache
|
148
|
+
|
149
|
+
def expire_#{association_name}_cache
|
150
|
+
if respond_to? :cached_#{reverse_association.name}
|
151
|
+
# cached_viewable.expire_association_cache
|
152
|
+
cached_#{reverse_association.name}.expire_association_cache(:#{association_name})
|
153
|
+
else
|
154
|
+
#{reverse_association.name}.each do |assoc|
|
155
|
+
assoc.expire_association_cache(:#{association_name})
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
159
|
+
EOF
|
109
160
|
else
|
110
161
|
reverse_association = association.klass.reflect_on_all_associations(:belongs_to).find { |reverse_association|
|
111
162
|
reverse_association.options[:polymorphic] ? reverse_association.name == association.options[:as] : reverse_association.klass == self
|
112
163
|
}
|
164
|
+
|
113
165
|
association.klass.class_eval <<-EOF
|
114
166
|
after_commit :expire_#{association_name}_cache
|
115
167
|
|
116
168
|
def expire_#{association_name}_cache
|
117
169
|
if respond_to? :cached_#{reverse_association.name}
|
118
|
-
# cached_viewable.expire_association_cache
|
119
170
|
cached_#{reverse_association.name}.expire_association_cache(:#{association_name})
|
120
171
|
else
|
121
172
|
#{reverse_association.name}.expire_association_cache(:#{association_name})
|
@@ -123,7 +174,6 @@ module Cacheable
|
|
123
174
|
end
|
124
175
|
EOF
|
125
176
|
end
|
126
|
-
|
127
177
|
class_eval <<-EOF
|
128
178
|
def cached_#{association_name}
|
129
179
|
Rails.cache.fetch have_association_cache_key("#{association_name}") do
|
@@ -142,8 +192,15 @@ module Cacheable
|
|
142
192
|
def all_attribute_cache_key(attribute, value)
|
143
193
|
"#{name.tableize}/attribute/#{attribute}/all/#{URI.escape(value.to_s)}"
|
144
194
|
end
|
195
|
+
|
196
|
+
def class_method_cache_key(meth)
|
197
|
+
"#{name.tableize}/class_method/#{meth}"
|
198
|
+
end
|
199
|
+
|
145
200
|
end
|
201
|
+
|
146
202
|
end
|
203
|
+
|
147
204
|
end
|
148
205
|
|
149
206
|
def expire_model_cache
|
@@ -151,6 +208,13 @@ module Cacheable
|
|
151
208
|
expire_attribute_cache if self.class.cached_indices.present?
|
152
209
|
expire_all_attribute_cache if self.class.cached_indices.present?
|
153
210
|
expire_method_cache if self.class.cached_methods.present?
|
211
|
+
expire_class_method_cache if self.class.cached_class_methods.present?
|
212
|
+
|
213
|
+
if self.class.cached_associations.present?
|
214
|
+
self.class.cached_associations.each do |assoc|
|
215
|
+
expire_association_cache(assoc)
|
216
|
+
end
|
217
|
+
end
|
154
218
|
end
|
155
219
|
|
156
220
|
def expire_key_cache
|
@@ -177,6 +241,12 @@ module Cacheable
|
|
177
241
|
end
|
178
242
|
end
|
179
243
|
|
244
|
+
def expire_class_method_cache
|
245
|
+
self.class.cached_class_methods.each do |meth|
|
246
|
+
Rails.cache.delete self.class.class_method_cache_key(meth)
|
247
|
+
end
|
248
|
+
end
|
249
|
+
|
180
250
|
def expire_association_cache(name)
|
181
251
|
Rails.cache.delete have_association_cache_key(name)
|
182
252
|
end
|
data/spec/cacheable_spec.rb
CHANGED
@@ -5,13 +5,16 @@ describe Cacheable do
|
|
5
5
|
|
6
6
|
before :all do
|
7
7
|
@user = User.create(:login => 'flyerhzm')
|
8
|
-
@
|
8
|
+
@group1 = Group.create(name: "Ruby On Rails")
|
9
|
+
@account = @user.create_account(group: @group1)
|
9
10
|
@post1 = @user.posts.create(:title => 'post1')
|
10
11
|
@post2 = @user.posts.create(:title => 'post2')
|
11
12
|
@image1 = @post1.images.create
|
12
13
|
@image2 = @post1.images.create
|
13
14
|
@comment1 = @post1.comments.create
|
14
15
|
@comment2 = @post1.comments.create
|
16
|
+
@tag1 = @post1.tags.create(title: "Rails")
|
17
|
+
@tag2 = @post1.tags.create(title: "Caching")
|
15
18
|
end
|
16
19
|
|
17
20
|
before :each do
|
@@ -94,6 +97,22 @@ describe Cacheable do
|
|
94
97
|
end
|
95
98
|
end
|
96
99
|
|
100
|
+
context "with_class_method" do
|
101
|
+
it "should not cache Post.default_post" do
|
102
|
+
Rails.cache.read("posts/class_method/default_post").should be_nil
|
103
|
+
end
|
104
|
+
|
105
|
+
it "should cache Post.default_post" do
|
106
|
+
Post.cached_default_post.should == @post1
|
107
|
+
Rails.cache.read("posts/class_method/default_post").should == @post1
|
108
|
+
end
|
109
|
+
|
110
|
+
it "should cache Post.default_post multiple times" do
|
111
|
+
Post.cached_default_post
|
112
|
+
Post.cached_default_post.should == @post1
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
97
116
|
context "with_association" do
|
98
117
|
context "belongs_to" do
|
99
118
|
it "should not cache association" do
|
@@ -190,6 +209,49 @@ describe Cacheable do
|
|
190
209
|
end
|
191
210
|
end
|
192
211
|
|
212
|
+
context "has_one through belongs_to" do
|
213
|
+
it "should not cache associations" do
|
214
|
+
Rails.cache.read("users/#{@user.id}/association/group").should be_nil
|
215
|
+
end
|
216
|
+
|
217
|
+
it "should cache User#group" do
|
218
|
+
@user.cached_group.should == @group1
|
219
|
+
Rails.cache.read("users/#{@user.id}/association/group").should == @group1
|
220
|
+
end
|
221
|
+
|
222
|
+
it "should cache User#group multiple times" do
|
223
|
+
@user.cached_group
|
224
|
+
@user.cached_group.should == @group1
|
225
|
+
end
|
226
|
+
|
227
|
+
end
|
228
|
+
|
229
|
+
context "has_and_belongs_to_many" do
|
230
|
+
|
231
|
+
it "should not cache associations off the bat" do
|
232
|
+
Rails.cache.read("posts/#{@post1.id}/association/tags").should be_nil
|
233
|
+
end
|
234
|
+
|
235
|
+
it "should cache Post#tags" do
|
236
|
+
@post1.cached_tags.should == [@tag1, @tag2]
|
237
|
+
Rails.cache.read("posts/#{@post1.id}/association/tags").should == [@tag1, @tag2]
|
238
|
+
end
|
239
|
+
|
240
|
+
it "should handle multiple requests" do
|
241
|
+
@post1.cached_tags
|
242
|
+
@post1.cached_tags.should == [@tag1, @tag2]
|
243
|
+
end
|
244
|
+
|
245
|
+
context "expiry" do
|
246
|
+
it "should have the correct collection" do
|
247
|
+
@tag3 = @post1.tags.create!(title: "Invalidation is hard")
|
248
|
+
Rails.cache.read("posts/#{@post1.id}/association/tags").should be_nil
|
249
|
+
@post1.cached_tags.should == [@tag1, @tag2, @tag3]
|
250
|
+
Rails.cache.read("posts/#{@post1.id}/association/tags").should == [@tag1, @tag2, @tag3]
|
251
|
+
end
|
252
|
+
end
|
253
|
+
end
|
254
|
+
|
193
255
|
end
|
194
256
|
|
195
257
|
context "expire_model_cache" do
|
@@ -214,6 +276,23 @@ describe Cacheable do
|
|
214
276
|
Rails.cache.read("users/#{@user.id}/method/last_post").should be_nil
|
215
277
|
end
|
216
278
|
|
279
|
+
it "should delete with_class_method cache" do
|
280
|
+
Post.cached_default_post
|
281
|
+
Rails.cache.read("posts/class_method/default_post").should_not be_nil
|
282
|
+
@post1.expire_model_cache
|
283
|
+
Rails.cache.read("posts/class_method/default_post").should be_nil
|
284
|
+
end
|
285
|
+
|
286
|
+
it "should delete associations cache" do
|
287
|
+
@user.cached_images
|
288
|
+
Rails.cache.read("users/#{@user.id}/association/images").should_not be_nil
|
289
|
+
@user.expire_model_cache
|
290
|
+
Rails.cache.read("users/#{@user.id}/association/images").should be_nil
|
291
|
+
end
|
292
|
+
|
293
|
+
end
|
294
|
+
|
295
|
+
context "object#save" do
|
217
296
|
it "should delete has_many with_association cache" do
|
218
297
|
@user.cached_posts
|
219
298
|
Rails.cache.read("users/#{@user.id}/association/posts").should_not be_nil
|
@@ -241,5 +320,19 @@ describe Cacheable do
|
|
241
320
|
@account.save
|
242
321
|
Rails.cache.read("users/#{@user.id}/association/account").should be_nil
|
243
322
|
end
|
323
|
+
|
324
|
+
it "should delete has_and_belongs_to_many with_association cache" do
|
325
|
+
@post1.cached_tags
|
326
|
+
Rails.cache.read("posts/#{@post1.id}/association/tags").should_not be_nil
|
327
|
+
@tag1.save
|
328
|
+
Rails.cache.read("posts/#{@post1.id}/association/tags").should be_nil
|
329
|
+
end
|
330
|
+
|
331
|
+
it "should delete has_one through belongs_to with_association cache" do
|
332
|
+
@group1.save
|
333
|
+
Rails.cache.read("users/#{@user.id}/association/group").should be_nil
|
334
|
+
@user.cached_group.should == @group1
|
335
|
+
Rails.cache.read("users/#{@user.id}/association/group").should == @group1
|
336
|
+
end
|
244
337
|
end
|
245
338
|
end
|
data/spec/models/account.rb
CHANGED
data/spec/models/post.rb
CHANGED
@@ -2,13 +2,20 @@ class Post < ActiveRecord::Base
|
|
2
2
|
include Cacheable
|
3
3
|
|
4
4
|
belongs_to :user
|
5
|
-
has_many :comments, :as => :commentable
|
6
5
|
|
6
|
+
has_many :comments, :as => :commentable
|
7
7
|
has_many :images, :as => :viewable
|
8
8
|
|
9
|
+
has_and_belongs_to_many :tags
|
10
|
+
|
9
11
|
model_cache do
|
10
12
|
with_key
|
11
13
|
with_attribute :user_id
|
12
|
-
with_association :user, :comments, :images
|
14
|
+
with_association :user, :comments, :images, :tags
|
15
|
+
with_class_method :default_post
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.default_post
|
19
|
+
Post.first
|
13
20
|
end
|
14
21
|
end
|
data/spec/models/tag.rb
ADDED
data/spec/models/user.rb
CHANGED
@@ -5,11 +5,13 @@ class User < ActiveRecord::Base
|
|
5
5
|
has_one :account
|
6
6
|
has_many :images, through: :posts
|
7
7
|
|
8
|
+
has_one :group, through: :account
|
9
|
+
|
8
10
|
model_cache do
|
9
11
|
with_key
|
10
12
|
with_attribute :login
|
11
13
|
with_method :last_post
|
12
|
-
with_association :posts, :account, :images
|
14
|
+
with_association :posts, :account, :images, :group
|
13
15
|
end
|
14
16
|
|
15
17
|
def last_post
|
data/spec/spec_helper.rb
CHANGED
@@ -4,18 +4,29 @@ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "lib"))
|
|
4
4
|
require 'rails'
|
5
5
|
require 'active_record'
|
6
6
|
require 'rspec'
|
7
|
-
require '
|
7
|
+
require 'mocha/api'
|
8
8
|
require 'memcached'
|
9
|
-
|
10
9
|
require 'cacheable'
|
10
|
+
require 'debugger'
|
11
11
|
|
12
|
-
|
13
|
-
|
12
|
+
# MODELS = File.join(File.dirname(__FILE__), "models")
|
13
|
+
# $LOAD_PATH.unshift(MODELS)
|
14
|
+
# Dir[ File.join(MODELS, "*.rb") ].each { |f| require f }
|
14
15
|
|
15
|
-
|
16
|
-
|
16
|
+
# It needs this order otherwise cacheable throws
|
17
|
+
# errors when looking for reflection classes
|
18
|
+
# Specifically, post can't be before tag
|
19
|
+
# and user can't be before post
|
20
|
+
require 'models/account'
|
21
|
+
require 'models/group'
|
22
|
+
require 'models/comment'
|
23
|
+
require 'models/image'
|
24
|
+
require 'models/tag'
|
25
|
+
require 'models/post'
|
26
|
+
require 'models/user'
|
17
27
|
|
18
|
-
|
28
|
+
ActiveRecord::Migration.verbose = false
|
29
|
+
ActiveRecord::Base.establish_connection(:adapter => 'sqlite3', :database => ':memory:')
|
19
30
|
|
20
31
|
module Rails
|
21
32
|
class <<self
|
@@ -39,6 +50,7 @@ RSpec.configure do |config|
|
|
39
50
|
|
40
51
|
create_table :accounts do |t|
|
41
52
|
t.integer :user_id
|
53
|
+
t.integer :group_id
|
42
54
|
end
|
43
55
|
|
44
56
|
create_table :posts do |t|
|
@@ -55,7 +67,21 @@ RSpec.configure do |config|
|
|
55
67
|
t.integer :viewable_id
|
56
68
|
t.string :viewable_type
|
57
69
|
end
|
70
|
+
|
71
|
+
create_table :tags do |t|
|
72
|
+
t.string :title
|
73
|
+
end
|
74
|
+
|
75
|
+
create_table :posts_tags, id: false do |t|
|
76
|
+
t.integer :post_id
|
77
|
+
t.integer :tag_id
|
78
|
+
end
|
79
|
+
|
80
|
+
create_table :groups do |t|
|
81
|
+
t.string :name
|
82
|
+
end
|
58
83
|
end
|
84
|
+
|
59
85
|
end
|
60
86
|
|
61
87
|
config.after :all do
|
metadata
CHANGED
@@ -1,15 +1,16 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: simple_cacheable
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.3.
|
4
|
+
version: 1.3.2
|
5
5
|
prerelease:
|
6
6
|
platform: ruby
|
7
7
|
authors:
|
8
8
|
- Richard Huang
|
9
|
+
- Scott Carleton
|
9
10
|
autorequire:
|
10
11
|
bindir: bin
|
11
12
|
cert_chain: []
|
12
|
-
date: 2012-12-
|
13
|
+
date: 2012-12-31 00:00:00.000000000 Z
|
13
14
|
dependencies:
|
14
15
|
- !ruby/object:Gem::Dependency
|
15
16
|
name: rails
|
@@ -62,6 +63,7 @@ dependencies:
|
|
62
63
|
description: a simple cache implementation based on activerecord
|
63
64
|
email:
|
64
65
|
- flyerhzm@gmail.com
|
66
|
+
- scott@artsicle.com
|
65
67
|
executables: []
|
66
68
|
extensions: []
|
67
69
|
extra_rdoc_files: []
|
@@ -79,8 +81,10 @@ files:
|
|
79
81
|
- spec/cacheable_spec.rb
|
80
82
|
- spec/models/account.rb
|
81
83
|
- spec/models/comment.rb
|
84
|
+
- spec/models/group.rb
|
82
85
|
- spec/models/image.rb
|
83
86
|
- spec/models/post.rb
|
87
|
+
- spec/models/tag.rb
|
84
88
|
- spec/models/user.rb
|
85
89
|
- spec/spec_helper.rb
|
86
90
|
homepage: https://github.com/flyerhzm/simple-cacheable
|
@@ -95,18 +99,12 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
95
99
|
- - ! '>='
|
96
100
|
- !ruby/object:Gem::Version
|
97
101
|
version: '0'
|
98
|
-
segments:
|
99
|
-
- 0
|
100
|
-
hash: -672054861354724444
|
101
102
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
102
103
|
none: false
|
103
104
|
requirements:
|
104
105
|
- - ! '>='
|
105
106
|
- !ruby/object:Gem::Version
|
106
107
|
version: '0'
|
107
|
-
segments:
|
108
|
-
- 0
|
109
|
-
hash: -672054861354724444
|
110
108
|
requirements: []
|
111
109
|
rubyforge_project:
|
112
110
|
rubygems_version: 1.8.24
|
@@ -117,7 +115,9 @@ test_files:
|
|
117
115
|
- spec/cacheable_spec.rb
|
118
116
|
- spec/models/account.rb
|
119
117
|
- spec/models/comment.rb
|
118
|
+
- spec/models/group.rb
|
120
119
|
- spec/models/image.rb
|
121
120
|
- spec/models/post.rb
|
121
|
+
- spec/models/tag.rb
|
122
122
|
- spec/models/user.rb
|
123
123
|
- spec/spec_helper.rb
|