the-pantry 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +12 -0
- data/.rspec +2 -0
- data/.ruby-version +1 -0
- data/.travis.yml +6 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +68 -0
- data/LICENSE.txt +21 -0
- data/README.md +211 -0
- data/Rakefile +7 -0
- data/bin/console +14 -0
- data/bin/setup +7 -0
- data/lib/pantry/configuration_error.rb +3 -0
- data/lib/pantry/dry_good.rb +26 -0
- data/lib/pantry/stocked.rb +518 -0
- data/lib/pantry/version.rb +3 -0
- data/lib/pantry.rb +46 -0
- data/pantry.gemspec +32 -0
- metadata +200 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 5d98a34b1bfccc46013bd9330cfbb48bb2c34a2e
|
4
|
+
data.tar.gz: e8c1a702702ba0cbefe61de8915b19c5c605e9a4
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: f056f5e25806051ecad7f81fb01b436f2122a87116665300eccac0a1a6fc2a8c351721e585bddfc57180fe3cce9f628980f28b887efa95f57d5d2810f72ed487
|
7
|
+
data.tar.gz: 8c5745f26e77259b62b3b20467736918cd4b52ba79ad9687408f90eb7cff6a705d4681feccec4cd1b77c978540f968b903c57392296eb8df9764ee8912ae70c8
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.ruby-version
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
2.3.1
|
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,68 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
the-pantry (1.0.0)
|
5
|
+
activerecord (~> 4.2)
|
6
|
+
hiredis (~> 0.6)
|
7
|
+
json (~> 1.8)
|
8
|
+
redis (~> 3.2)
|
9
|
+
|
10
|
+
GEM
|
11
|
+
remote: https://rubygems.org/
|
12
|
+
specs:
|
13
|
+
activemodel (4.2.7.1)
|
14
|
+
activesupport (= 4.2.7.1)
|
15
|
+
builder (~> 3.1)
|
16
|
+
activerecord (4.2.7.1)
|
17
|
+
activemodel (= 4.2.7.1)
|
18
|
+
activesupport (= 4.2.7.1)
|
19
|
+
arel (~> 6.0)
|
20
|
+
activesupport (4.2.7.1)
|
21
|
+
i18n (~> 0.7)
|
22
|
+
json (~> 1.7, >= 1.7.7)
|
23
|
+
minitest (~> 5.1)
|
24
|
+
thread_safe (~> 0.3, >= 0.3.4)
|
25
|
+
tzinfo (~> 1.1)
|
26
|
+
arel (6.0.4)
|
27
|
+
builder (3.2.3)
|
28
|
+
database_cleaner (1.4.1)
|
29
|
+
diff-lcs (1.2.5)
|
30
|
+
hiredis (0.6.1)
|
31
|
+
i18n (0.8.0)
|
32
|
+
json (1.8.6)
|
33
|
+
minitest (5.10.1)
|
34
|
+
rake (10.4.2)
|
35
|
+
redis (3.3.3)
|
36
|
+
rspec (3.3.0)
|
37
|
+
rspec-core (~> 3.3.0)
|
38
|
+
rspec-expectations (~> 3.3.0)
|
39
|
+
rspec-mocks (~> 3.3.0)
|
40
|
+
rspec-core (3.3.2)
|
41
|
+
rspec-support (~> 3.3.0)
|
42
|
+
rspec-expectations (3.3.1)
|
43
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
44
|
+
rspec-support (~> 3.3.0)
|
45
|
+
rspec-mocks (3.3.2)
|
46
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
47
|
+
rspec-support (~> 3.3.0)
|
48
|
+
rspec-support (3.3.0)
|
49
|
+
sqlite3 (1.3.10)
|
50
|
+
thread_safe (0.3.5)
|
51
|
+
timecop (0.7.4)
|
52
|
+
tzinfo (1.2.2)
|
53
|
+
thread_safe (~> 0.1)
|
54
|
+
|
55
|
+
PLATFORMS
|
56
|
+
ruby
|
57
|
+
|
58
|
+
DEPENDENCIES
|
59
|
+
bundler (~> 1.3)
|
60
|
+
database_cleaner (~> 1.3)
|
61
|
+
rake (~> 10.0)
|
62
|
+
rspec (~> 3.3)
|
63
|
+
sqlite3 (~> 1.3)
|
64
|
+
the-pantry!
|
65
|
+
timecop (~> 0.7)
|
66
|
+
|
67
|
+
BUNDLED WITH
|
68
|
+
1.14.0
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2015 - 2017 Sprig
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,211 @@
|
|
1
|
+
# Pantry [![Build Status](https://travis-ci.org/eatsprig/the-pantry.svg?branch=master)](https://travis-ci.org/eatsprig/the-pantry)
|
2
|
+
|
3
|
+
Pantry provides ActiveRecord model caching in Redis via a mixin. **Before you go to the store, check the pantry!**
|
4
|
+
|
5
|
+
Pantry strives to be simple and intuitive. Its philosophy is that a cache should be dumb. You should use a cache to perform basic queries quickly; you should not use it to mimic a relational database. As a result, the query API that Pantry exposes is small and straightforward.
|
6
|
+
|
7
|
+
## Features
|
8
|
+
|
9
|
+
+ Retrieval of a single object from the cache by primary key.
|
10
|
+
+ Retrieval of multiple objects from the cache by list of primary keys.
|
11
|
+
+ Retrieval of all cached objects for a model in one fell swoop.
|
12
|
+
+ Global and model-specific cache invalidation via configuration.
|
13
|
+
+ Specification of cached object attributes via configuration.
|
14
|
+
|
15
|
+
## Installation
|
16
|
+
|
17
|
+
Add this line to your application's Gemfile:
|
18
|
+
|
19
|
+
```ruby
|
20
|
+
gem 'the-pantry', require: 'pantry'
|
21
|
+
```
|
22
|
+
|
23
|
+
And then execute:
|
24
|
+
|
25
|
+
$ bundle
|
26
|
+
|
27
|
+
Or install it yourself as:
|
28
|
+
|
29
|
+
$ gem install the-pantry
|
30
|
+
|
31
|
+
## Usage
|
32
|
+
|
33
|
+
You can enable caching on your ActiveRecord model by mixing in `Pantry::Stocked`.
|
34
|
+
|
35
|
+
Basic example:
|
36
|
+
```ruby
|
37
|
+
class User
|
38
|
+
include Pantry::Stocked
|
39
|
+
end
|
40
|
+
```
|
41
|
+
|
42
|
+
Full example:
|
43
|
+
```ruby
|
44
|
+
module UserMethods
|
45
|
+
def full_name
|
46
|
+
"#{first_name} #{last_name}".strip
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
module Cache
|
51
|
+
class User < Pantry::DryGood
|
52
|
+
include UserMethods
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
class User
|
57
|
+
include Pantry::Stocked
|
58
|
+
include UserMethods
|
59
|
+
|
60
|
+
stock_by :email
|
61
|
+
|
62
|
+
class << self
|
63
|
+
def local_key_version
|
64
|
+
123
|
65
|
+
end
|
66
|
+
|
67
|
+
def dry_good_type
|
68
|
+
Cache::User
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def pantry_attributes
|
73
|
+
super.merge(
|
74
|
+
"profile_photo_url" => profile_photo.url
|
75
|
+
)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
user = User.find(1) #=> ActiveRecord::Base
|
80
|
+
user.full_name #=> Dude Guy
|
81
|
+
user.profile_photo.url #=> https://mybucket.s3.amazonaws.com/users/profile_photos/001/me.png
|
82
|
+
|
83
|
+
user = User.get(1) #=> Pantry::DryGood
|
84
|
+
user.full_name #=> Dude Guy
|
85
|
+
user.profile_photo_url #=> https://mybucket.s3.amazonaws.com/users/profile_photos/001/me.png
|
86
|
+
```
|
87
|
+
|
88
|
+
The following methods will be added to the `User` class:
|
89
|
+
|
90
|
+
#### `User.get(id)`
|
91
|
+
|
92
|
+
Retrieves a single object, looking first to the cache and then to the database.
|
93
|
+
|
94
|
+
#### `User.get_by(attribute_name, attribute_value)`
|
95
|
+
|
96
|
+
Retrieves a single object by the value of one of its attributes, looking first to the cache and then to the database. In order to use this method, the model must declare `stock_by` with the desired attribute name.
|
97
|
+
|
98
|
+
#### `User.multi_get(ids)`
|
99
|
+
|
100
|
+
Retrieves multiple objects, looking first to the cache and then to the database.
|
101
|
+
|
102
|
+
#### `User.multi_get_by(attribute_name, attribute_values)`
|
103
|
+
|
104
|
+
Retrieves multiple objects by the given list of values for a single attribute. Looks first to the cache and then to the database.
|
105
|
+
|
106
|
+
#### `User.multi_get_all`
|
107
|
+
|
108
|
+
Retrieves all the objects that exist in the cache. Unlike other methods, this method does not fall back to the database unless the cache for the model in question is completely empty. The `restock?` configuration method (described below) must return `true` in order to use this method.
|
109
|
+
|
110
|
+
#### `User.invalidate(id)`
|
111
|
+
|
112
|
+
Removes a single entry from the cache.
|
113
|
+
|
114
|
+
#### `User.multi_invalidate(ids)`
|
115
|
+
|
116
|
+
Removes multiple entries from the cache.
|
117
|
+
|
118
|
+
#### `User.restock!`
|
119
|
+
|
120
|
+
Writes all model objects to the cache. This is mainly for use in conjunction with models that want to use the `multi_get_all` functionality. This is potentially very expensive, so it should be used with caution.
|
121
|
+
|
122
|
+
#### `User.tidy!`
|
123
|
+
|
124
|
+
Clears all expired entries from the index key used to serve `multi_get_all` requests. Never calling this method will not affect correctness (i.e. expired entries will never be returned from the index), but your index will grow indefinitely.
|
125
|
+
|
126
|
+
## Model Configuration
|
127
|
+
|
128
|
+
You can customize the behaviour of the cache by overriding a few configuration options:
|
129
|
+
|
130
|
+
Example:
|
131
|
+
```ruby
|
132
|
+
class User
|
133
|
+
include Pantry::Stocked
|
134
|
+
|
135
|
+
def self.local_key_prefix
|
136
|
+
"my_application_specific_prefix"
|
137
|
+
end
|
138
|
+
|
139
|
+
def self.restock?
|
140
|
+
true
|
141
|
+
end
|
142
|
+
|
143
|
+
def pantry_attributes
|
144
|
+
{
|
145
|
+
"id": id,
|
146
|
+
"nickname": nickname
|
147
|
+
}
|
148
|
+
end
|
149
|
+
end
|
150
|
+
```
|
151
|
+
|
152
|
+
#### `User.local_key_prefix`
|
153
|
+
|
154
|
+
Returns the portion of the cache key that is specific to this model. Defaults to the lower-cased class name.
|
155
|
+
|
156
|
+
#### `User.local_key_version`
|
157
|
+
|
158
|
+
Returns the cache key version of cache objects for this model. Defaults to `1`.
|
159
|
+
|
160
|
+
#### `User.key_ttl_s`
|
161
|
+
|
162
|
+
Returns the TTL for cache keys for this model. Defaults to the `Pantry.global_key_ttl_s` value (which, by default, is 1 week).
|
163
|
+
|
164
|
+
#### `User.restock?`
|
165
|
+
|
166
|
+
Returns a boolean indicating whether or not the cache should be eagerly re-populated with a record after a change to that record was committed. This method must be configured to return `true` in order to use the `multi_get_all` method. Defaults to `false`.
|
167
|
+
|
168
|
+
#### `User.dry_good_type`
|
169
|
+
|
170
|
+
Allows for specifying a class to instantiate when an object is de-serialized from cache. The returned class should quack like `OpenStruct.new` and accept the attributes returned by `pantry_attributes`. This defaults to `Pantry::DryGood`.
|
171
|
+
|
172
|
+
#### `User#pantry_attributes`
|
173
|
+
|
174
|
+
Returns a hash of attributes to write to the cache.
|
175
|
+
|
176
|
+
#### `User.stock_by(attribute_name, unique: false)`
|
177
|
+
|
178
|
+
Adds a secondary cache index for the given attribute. This enables the use of `get_by` and `multi_get_by`. The `unique` flag specifies whether or not to create a unique index.
|
179
|
+
|
180
|
+
## Global Configuration
|
181
|
+
|
182
|
+
Finally, there are global configuration options that you can set on the `Pantry.configuration` hash to manage configuration across all cached models:
|
183
|
+
|
184
|
+
```ruby
|
185
|
+
# config/initializers/pantry.rb
|
186
|
+
|
187
|
+
Pantry.configuration.redis_uri = "some-redis-host.com:12345" # defaults to "localhost:6379"
|
188
|
+
Pantry.configuration.global_key_prefix = "myapp" # defaults to "pantry"
|
189
|
+
Pantry.configuration.global_key_version = 123 # defaults to 1
|
190
|
+
Pantry.configuration.global_key_ttl_s = 30.days # defaults to 1 week
|
191
|
+
Pantry.configuration.force_cache_misses = true # defaults to false
|
192
|
+
```
|
193
|
+
|
194
|
+
## Development
|
195
|
+
|
196
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake rspec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
197
|
+
|
198
|
+
To install this gem onto your local machine, run `bundle exec rake install`.
|
199
|
+
|
200
|
+
To release a new version:
|
201
|
+
+ Update the version number in `lib/pantry/version.rb`
|
202
|
+
+ `git tag -a -m "Version <VERSION_NUMBER>" v<VERSION_NUMBER>`
|
203
|
+
|
204
|
+
## Contributing
|
205
|
+
|
206
|
+
Bug reports and pull requests are welcome on GitHub.
|
207
|
+
|
208
|
+
## License
|
209
|
+
|
210
|
+
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
211
|
+
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "pantry"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start
|
data/bin/setup
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
module Pantry
|
2
|
+
class DryGood < OpenStruct
|
3
|
+
include ActiveModel::Serialization
|
4
|
+
|
5
|
+
# Implements required interface method for ActiveModel::Serialization.
|
6
|
+
def attributes
|
7
|
+
to_h.deep_stringify_keys
|
8
|
+
end
|
9
|
+
|
10
|
+
def ==(other)
|
11
|
+
super
|
12
|
+
end
|
13
|
+
|
14
|
+
def hash
|
15
|
+
super
|
16
|
+
end
|
17
|
+
|
18
|
+
# OpenStruct objects don't turn into hashes wrapped in a 'table' key
|
19
|
+
# http://goo.gl/vPjtgz
|
20
|
+
def as_json(options = nil)
|
21
|
+
@table.as_json(options)
|
22
|
+
end
|
23
|
+
|
24
|
+
alias_method :eql?, :==
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,518 @@
|
|
1
|
+
module Pantry
|
2
|
+
module Stocked
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
@@includers = []
|
6
|
+
|
7
|
+
def self.includers
|
8
|
+
@@includers
|
9
|
+
end
|
10
|
+
|
11
|
+
included do
|
12
|
+
@@includers << self
|
13
|
+
after_commit -> (record) { self.class.invalidate(record) }
|
14
|
+
end
|
15
|
+
|
16
|
+
module ClassMethods
|
17
|
+
|
18
|
+
def local_key_prefix
|
19
|
+
to_s.downcase
|
20
|
+
end
|
21
|
+
|
22
|
+
def local_key_version
|
23
|
+
1
|
24
|
+
end
|
25
|
+
|
26
|
+
def key_ttl_s
|
27
|
+
Pantry.configuration.global_key_ttl_s
|
28
|
+
end
|
29
|
+
|
30
|
+
def restock?
|
31
|
+
false
|
32
|
+
end
|
33
|
+
|
34
|
+
# Allows the client (the includer of this module) to declare a secondary
|
35
|
+
# index on the given attribute.
|
36
|
+
#
|
37
|
+
# @param attribute [Symbol, String] attribute to stock by, should be an
|
38
|
+
# attribute of the Class
|
39
|
+
# @param unique [Boolean, nil] Whether or not the declared index is
|
40
|
+
# unique.
|
41
|
+
# @param &block [Block] The block should behave like a block you'd use
|
42
|
+
# in a `scope`. It should expect to be chained on to an existing
|
43
|
+
# scoping chain, and should return a scope.
|
44
|
+
# Ex. stock_by(:team) { where.not(nickname: "Benji") }
|
45
|
+
def stock_by(attribute, unique: false, &block)
|
46
|
+
secondary_indices[attribute.to_sym] = {
|
47
|
+
unique: !!unique,
|
48
|
+
block: block
|
49
|
+
}
|
50
|
+
end
|
51
|
+
|
52
|
+
# Retrieves a single object, looking first to the cache and then to the
|
53
|
+
# database.
|
54
|
+
#
|
55
|
+
# @param id [Integer] Unique identifier of the desired object.
|
56
|
+
# @return [DryGood] Cached object. This object will have accessors for
|
57
|
+
# all the attributes returned by `pantry_attributes`.
|
58
|
+
def get(id)
|
59
|
+
multi_get([id])[id]
|
60
|
+
end
|
61
|
+
|
62
|
+
# Retrieves a single object by the value of an indexed attribute.
|
63
|
+
#
|
64
|
+
# @param attribute [String, Symbol] Name of indexed attribute.
|
65
|
+
# @param value [String] Stringified value of the indexed attribute for
|
66
|
+
# which to fetch the result.
|
67
|
+
# @return [DryGood] Cached object.
|
68
|
+
def get_by(attribute, value)
|
69
|
+
multi_get_by(attribute, [value])[value]
|
70
|
+
end
|
71
|
+
|
72
|
+
# Retrieves multiple objects, looking first to the cache and then to the
|
73
|
+
# database.
|
74
|
+
#
|
75
|
+
# @param ids [Array<Integer>] Unique identifiers of the desired objects.
|
76
|
+
# @return [Hash<Integer, DryGood>] Map from object identifier to cached
|
77
|
+
# object.
|
78
|
+
def multi_get(ids)
|
79
|
+
cached_records = multi_fetch(ids)
|
80
|
+
|
81
|
+
misses = cached_records.reduce([]) do |memo, (id, cached)|
|
82
|
+
memo << id unless cached
|
83
|
+
memo
|
84
|
+
end
|
85
|
+
|
86
|
+
db_records = misses.any? ? multi_store(where(id: misses).all.to_a) : {}
|
87
|
+
|
88
|
+
ids.reduce({}) do |memo, id|
|
89
|
+
memo[id] = cached_records[id] || db_records[id.to_i]
|
90
|
+
memo
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
# Retrieves a multiple objects by the values of an indexed attribute.
|
95
|
+
#
|
96
|
+
# @param attribute [String, Symbol] Name of indexed attribute.
|
97
|
+
# @param values [Array<String>] Stringified values of the indexed
|
98
|
+
# attribute for which to fetch the results.
|
99
|
+
# @return [Hash<String, DryGood>] Map from attribute value to cached
|
100
|
+
# object.
|
101
|
+
def multi_get_by(attribute, values)
|
102
|
+
cached_records = multi_fetch_by(attribute, values)
|
103
|
+
|
104
|
+
misses = []
|
105
|
+
|
106
|
+
empty_values = cached_records.reduce([]) do |memo, (val, cached)|
|
107
|
+
memo << val if cached.blank?
|
108
|
+
memo
|
109
|
+
end
|
110
|
+
|
111
|
+
if empty_values.any?
|
112
|
+
existences = Pantry.redis.pipelined do
|
113
|
+
empty_values.map do |val|
|
114
|
+
Pantry.redis.exists(secondary_index_cache_key(attribute, val))
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
empty_values.zip(existences).each do |(val, exists)|
|
119
|
+
unless exists
|
120
|
+
# Explicitly mark the cache miss (since otherwise, i.e. when the
|
121
|
+
# index key exists, an empty array is a valid cache response).
|
122
|
+
cached_records[val] = nil
|
123
|
+
|
124
|
+
misses << val
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
db_records = multi_store_by(attribute, misses)
|
130
|
+
|
131
|
+
values.reduce({}) do |memo, val|
|
132
|
+
memo[val] = cached_records[val] || db_records[val]
|
133
|
+
memo
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
# Retrieves all cached objects for this model class.
|
138
|
+
#
|
139
|
+
# This method should be used with caution: it may have negative
|
140
|
+
# performance consequences if used to retrieve large collections. It is
|
141
|
+
# intended for use on small collections that are important to business
|
142
|
+
# logic but that do not change often. Additionally, method will only work
|
143
|
+
# reliably if `restock?` is set to return `true`.
|
144
|
+
#
|
145
|
+
# @return [Hash<Integer, DryGood>] Map from object identifier to cached
|
146
|
+
# object.
|
147
|
+
def multi_get_all
|
148
|
+
unless restock?
|
149
|
+
fail ConfigurationError.new(
|
150
|
+
"restock?() must return true in order to use multi_get_all()")
|
151
|
+
end
|
152
|
+
|
153
|
+
if Pantry.configuration.force_cache_misses
|
154
|
+
result = all.reduce({}) do |memo, obj|
|
155
|
+
memo[obj.id] = DryGood.new(JSON.parse(obj.send(:pantry_json)))
|
156
|
+
memo
|
157
|
+
end
|
158
|
+
return result
|
159
|
+
end
|
160
|
+
|
161
|
+
keys = Pantry.redis.zrangebyscore(
|
162
|
+
all_index_cache_key,
|
163
|
+
Time.now.to_i,
|
164
|
+
"+inf")
|
165
|
+
if keys.empty?
|
166
|
+
restock!
|
167
|
+
else
|
168
|
+
multi_fetch(keys.map(&:to_i))
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
# Invalidates a single cache entry.
|
173
|
+
#
|
174
|
+
# @param obj_or_id [Object, Integer] Object (or primary key identifier for
|
175
|
+
# the object) whose cache entry we should delete.
|
176
|
+
def invalidate(obj_or_id)
|
177
|
+
multi_invalidate([obj_or_id])
|
178
|
+
end
|
179
|
+
|
180
|
+
# Invalidates a series of cache entries.
|
181
|
+
#
|
182
|
+
# @param objects_or_ids [Array<Object>, Array<Integer>] Objects (or
|
183
|
+
# primary key identifiers for objects) whose cache entries we should
|
184
|
+
# delete.
|
185
|
+
def multi_invalidate(objects_or_ids)
|
186
|
+
return if objects_or_ids.empty?
|
187
|
+
|
188
|
+
is_integer_list = objects_or_ids.first.is_a?(Integer)
|
189
|
+
|
190
|
+
objects = objects_or_ids
|
191
|
+
if is_integer_list && (secondary_indices.any? || restock?)
|
192
|
+
objects = where(id: objects_or_ids).all.to_a
|
193
|
+
end
|
194
|
+
|
195
|
+
Pantry.redis.del(
|
196
|
+
objects_or_ids.map { |o| cache_key(is_integer_list ? o : o.id) })
|
197
|
+
|
198
|
+
if secondary_indices.any?
|
199
|
+
invalidate_secondary_indices(objects)
|
200
|
+
end
|
201
|
+
|
202
|
+
if restock?
|
203
|
+
# TODO(lerebear): Make multi_store take optional list of cache keys.
|
204
|
+
# Store anything that we haven't already.
|
205
|
+
multi_store(
|
206
|
+
objects.find_all { |obj| !obj.destroyed? },
|
207
|
+
skip_deserialization: true)
|
208
|
+
end
|
209
|
+
|
210
|
+
nil
|
211
|
+
end
|
212
|
+
|
213
|
+
# We invalidate a key if either of the following is true:
|
214
|
+
# - The attribute is stocked conditionally (indicated by the presence
|
215
|
+
# of a block option)
|
216
|
+
# - The given objects' attributes that are indexed have changed. In
|
217
|
+
# this case we invalidate both the old and new values for the indexed
|
218
|
+
# attribute
|
219
|
+
#
|
220
|
+
# @param objects [Array<Object>]
|
221
|
+
def invalidate_secondary_indices(objects)
|
222
|
+
objects_by_dirty_index = secondary_indices
|
223
|
+
.reduce({}) do |memo, (attribute, options_hash)|
|
224
|
+
has_block = options_hash[:block]
|
225
|
+
|
226
|
+
if has_block
|
227
|
+
memo[attribute] = objects
|
228
|
+
else
|
229
|
+
dirty_objects = objects.find_all do |obj|
|
230
|
+
!obj.destroyed? && obj.previous_changes[attribute.to_s].present?
|
231
|
+
end
|
232
|
+
|
233
|
+
if dirty_objects.any?
|
234
|
+
memo[attribute] = dirty_objects
|
235
|
+
end
|
236
|
+
end
|
237
|
+
|
238
|
+
memo
|
239
|
+
end
|
240
|
+
|
241
|
+
Pantry.redis.pipelined do
|
242
|
+
objects_by_dirty_index.each do |attribute, dirty_objs|
|
243
|
+
# Invalidate attribute indices.
|
244
|
+
dirty_objs.each do |o|
|
245
|
+
(o.previous_changes[attribute.to_s] || [o.send(attribute)])
|
246
|
+
.each do |val|
|
247
|
+
|
248
|
+
Pantry.redis.del(secondary_index_cache_key(attribute, val))
|
249
|
+
end
|
250
|
+
end
|
251
|
+
end
|
252
|
+
end
|
253
|
+
end
|
254
|
+
|
255
|
+
# Writes all objects for the model class to the cache.
|
256
|
+
#
|
257
|
+
# This is a utility method for ensuring that the cache is populated for
|
258
|
+
# classes that wish to use the `multi_get_all` functionality`. This method
|
259
|
+
# will only write to the cache if `restock?` is set to return
|
260
|
+
# `true`.
|
261
|
+
#
|
262
|
+
# @return [Hash<Integer, DryGood>, Nil] Map from object identifier to
|
263
|
+
# cached object, or nil if no objects were written to the cache.
|
264
|
+
def restock!
|
265
|
+
multi_store(all.to_a) if restock?
|
266
|
+
end
|
267
|
+
|
268
|
+
# Clears all expired entries from index keys.
|
269
|
+
#
|
270
|
+
# Entries in an index will never expire unless you call this method. That
|
271
|
+
# should be fine in most cases, and it never affects correctness (because
|
272
|
+
# only non-expired keys are returned by `multi_get_all`). However, this
|
273
|
+
# method can be used to address the concern of the values of index keys
|
274
|
+
# growing too big.
|
275
|
+
#
|
276
|
+
# @return [Integer, Nil] The number of elements that were expired, or nil
|
277
|
+
# if we aren't using indices.
|
278
|
+
def tidy!
|
279
|
+
# TODO(lerebear): This is no good because you have to call restock!
|
280
|
+
# alongside this to guarantee correctness. Better idea is probably to
|
281
|
+
# just have a clean! method that does both tidy! and restock! for the
|
282
|
+
# all_index_cache_key, and also removes all other attribute index cache
|
283
|
+
# keys at the same time (those will regenerate on the fly). Then make
|
284
|
+
# clear in the docs that you can use clean! on a schedule to help
|
285
|
+
# maintain your cache.
|
286
|
+
Pantry.redis.zremrangebyscore(all_index_cache_key, 0, Time.now.to_i - 1)
|
287
|
+
end
|
288
|
+
|
289
|
+
# Provides a hook for mixers to implement to provide a more specific
|
290
|
+
# class to instantiate when de-serializing from the cache.
|
291
|
+
#
|
292
|
+
# The returned class should quack like `OpenStruct#new`
|
293
|
+
#
|
294
|
+
# @return [Class]
|
295
|
+
def dry_good_type
|
296
|
+
DryGood
|
297
|
+
end
|
298
|
+
|
299
|
+
# Provides a hook for mixers to do pre-processing on the given
|
300
|
+
# objects to improve serialization performance.
|
301
|
+
#
|
302
|
+
# @param objects [Array<ActiveRecord::Base>] the objects fetched from the
|
303
|
+
# database on a cache miss
|
304
|
+
def before_pantry_serialize(objects)
|
305
|
+
end
|
306
|
+
|
307
|
+
private
|
308
|
+
|
309
|
+
def secondary_indices
|
310
|
+
@indices ||= {}
|
311
|
+
end
|
312
|
+
|
313
|
+
def fetch(id)
|
314
|
+
multi_fetch([id])[id]
|
315
|
+
end
|
316
|
+
|
317
|
+
def multi_fetch(ids)
|
318
|
+
return Hash[ids.zip([nil])] if Pantry.configuration.force_cache_misses
|
319
|
+
|
320
|
+
keys = ids.map { |id| cache_key(id) }
|
321
|
+
cached = keys.length > 0 ? Pantry.redis.mget(keys) : []
|
322
|
+
ids.zip(cached).reduce({}) do |memo, (id, retrieved)|
|
323
|
+
memo[id] = retrieved && restore_deserialized(JSON.parse(retrieved))
|
324
|
+
memo
|
325
|
+
end
|
326
|
+
end
|
327
|
+
|
328
|
+
def multi_fetch_by(attribute, values)
|
329
|
+
is_unique_index = secondary_indices[attribute.to_sym].try(:[], :unique)
|
330
|
+
|
331
|
+
if Pantry.configuration.force_cache_misses
|
332
|
+
return values.reduce({}) do |memo, val|
|
333
|
+
memo[val] = is_unique_index ? nil : []
|
334
|
+
memo
|
335
|
+
end
|
336
|
+
end
|
337
|
+
|
338
|
+
# TODO(lerebear): Create a pipelining helper to avoid pipeline cost for
|
339
|
+
# lists of 1.
|
340
|
+
id_lists = Pantry.redis.pipelined do
|
341
|
+
values.map do |val|
|
342
|
+
Pantry
|
343
|
+
.redis
|
344
|
+
.smembers(secondary_index_cache_key(attribute, val))
|
345
|
+
end
|
346
|
+
end
|
347
|
+
|
348
|
+
id_lists = id_lists.map { |ids| ids.map(&:to_i) }
|
349
|
+
cached = multi_get(id_lists.flatten)
|
350
|
+
|
351
|
+
values.zip(id_lists).reduce({}) do |memo, (val, ids)|
|
352
|
+
memo[val] = \
|
353
|
+
if is_unique_index
|
354
|
+
ids.first && cached[ids.first]
|
355
|
+
else
|
356
|
+
ids.reduce([]) do |acc, id|
|
357
|
+
if (retrieved = cached[id])
|
358
|
+
acc << retrieved
|
359
|
+
end
|
360
|
+
acc
|
361
|
+
end
|
362
|
+
end
|
363
|
+
memo
|
364
|
+
end
|
365
|
+
end
|
366
|
+
|
367
|
+
def restore_deserialized(attrs)
|
368
|
+
cast_attributes(dry_good_type.send(:new, attrs))
|
369
|
+
end
|
370
|
+
|
371
|
+
def cast_attributes(dry_good)
|
372
|
+
columns_hash.find_all { |_, hash| hash.type.to_s == "datetime" }
|
373
|
+
.map(&:first)
|
374
|
+
.each do |attr|
|
375
|
+
if (value = dry_good.send(attr)) && value.is_a?(String)
|
376
|
+
dry_good.send("#{attr}=", Time.parse(value))
|
377
|
+
end
|
378
|
+
end
|
379
|
+
dry_good
|
380
|
+
end
|
381
|
+
|
382
|
+
def store(id, skip_deserialization: false)
|
383
|
+
cached = multi_store(
|
384
|
+
where(id: id).all.to_a,
|
385
|
+
skip_deserialization: skip_deserialization)
|
386
|
+
cached[id] unless skip_deserialization
|
387
|
+
end
|
388
|
+
|
389
|
+
def multi_store(objects, skip_deserialization: false)
|
390
|
+
return {} if objects.empty?
|
391
|
+
|
392
|
+
before_pantry_serialize(objects)
|
393
|
+
object_ids = []
|
394
|
+
to_cache = {}
|
395
|
+
objects.each do |obj|
|
396
|
+
object_ids << obj.id
|
397
|
+
to_cache[cache_key(obj.id)] = obj.send(:pantry_json)
|
398
|
+
end
|
399
|
+
|
400
|
+
Pantry.redis.pipelined do
|
401
|
+
Pantry.redis.mset(*to_cache.flatten)
|
402
|
+
object_ids.each do |id|
|
403
|
+
Pantry.redis.expire(cache_key(id), key_ttl_s)
|
404
|
+
end
|
405
|
+
|
406
|
+
# Add to "all" index key if necessary.
|
407
|
+
now = Time.now.to_i
|
408
|
+
if restock?
|
409
|
+
Pantry.redis.zadd(
|
410
|
+
all_index_cache_key,
|
411
|
+
object_ids.map { |id| [now + key_ttl_s, id] })
|
412
|
+
end
|
413
|
+
end
|
414
|
+
|
415
|
+
unless skip_deserialization
|
416
|
+
# HACK(lerebear): Incurring the JSON parsing cost here is really bad.
|
417
|
+
# Ideally we would just return `pantry_attributes` of every object. The
|
418
|
+
# problem is that the hash returned by `pantry_attributes` still
|
419
|
+
# contains unserialized types (e.g. Date, which gets serialized to a
|
420
|
+
# string when written to the cache and so emerges from the cache as a
|
421
|
+
# string). That creates problems in tests and, conceptually, this should
|
422
|
+
# return the same sort of object as `multi_fetch`, so the workaround is
|
423
|
+
# to just parse the JSON.
|
424
|
+
to_cache.values.reduce({}) do |memo, obj|
|
425
|
+
restored = restore_deserialized(JSON.parse(obj))
|
426
|
+
memo[restored.id] = restored
|
427
|
+
memo
|
428
|
+
end
|
429
|
+
end
|
430
|
+
end
|
431
|
+
|
432
|
+
def multi_store_by(attribute, values, skip_deserialization: false)
|
433
|
+
return {} if values.empty?
|
434
|
+
|
435
|
+
is_unique_index = secondary_indices[attribute.to_sym].try(:[], :unique)
|
436
|
+
block = secondary_indices[attribute.to_sym].try(:[], :block)
|
437
|
+
|
438
|
+
scope = where(attribute.to_sym => values)
|
439
|
+
|
440
|
+
if block
|
441
|
+
scope = scope.scoping { block.call }
|
442
|
+
end
|
443
|
+
|
444
|
+
objects_by_attribute_value ||= \
|
445
|
+
scope
|
446
|
+
.all
|
447
|
+
.group_by { |obj| obj.send(attribute) }
|
448
|
+
|
449
|
+
Pantry.redis.pipelined do
|
450
|
+
# Populate secondary indices.
|
451
|
+
objects_by_attribute_value.each do |value, objs|
|
452
|
+
Pantry.redis.sadd(
|
453
|
+
secondary_index_cache_key(attribute, value),
|
454
|
+
objs.map(&:id))
|
455
|
+
end
|
456
|
+
end
|
457
|
+
# Also populate primary index (since we have the objects handy).
|
458
|
+
multi_store(
|
459
|
+
objects_by_attribute_value.values.flatten.sort_by(&:id),
|
460
|
+
skip_deserialization: true)
|
461
|
+
|
462
|
+
unless skip_deserialization
|
463
|
+
# HACK(lerebear): Same hack as in `multi_store` method above.
|
464
|
+
values.reduce({}) do |memo, val|
|
465
|
+
objs = objects_by_attribute_value[val] || []
|
466
|
+
memo[val] = \
|
467
|
+
if is_unique_index
|
468
|
+
objs.first && restore_deserialized(
|
469
|
+
JSON.parse(objs.first.send(:pantry_json)))
|
470
|
+
else
|
471
|
+
objs.map do |o|
|
472
|
+
restore_deserialized(JSON.parse(o.send(:pantry_json)))
|
473
|
+
end
|
474
|
+
end
|
475
|
+
memo
|
476
|
+
end
|
477
|
+
end
|
478
|
+
end
|
479
|
+
|
480
|
+
def cache_key(id)
|
481
|
+
[
|
482
|
+
Pantry.configuration.global_key_prefix,
|
483
|
+
"v#{Pantry.configuration.global_key_version}",
|
484
|
+
local_key_prefix,
|
485
|
+
"v#{local_key_version}",
|
486
|
+
"##{id}"
|
487
|
+
].join(":")
|
488
|
+
end
|
489
|
+
|
490
|
+
def secondary_index_cache_key(attribute, value)
|
491
|
+
[
|
492
|
+
Pantry.configuration.global_key_prefix,
|
493
|
+
"v#{Pantry.configuration.global_key_version}",
|
494
|
+
local_key_prefix,
|
495
|
+
"v#{local_key_version}",
|
496
|
+
"index",
|
497
|
+
attribute,
|
498
|
+
value.nil? ? "__pantry_nil__" : value
|
499
|
+
].join(":")
|
500
|
+
end
|
501
|
+
|
502
|
+
def all_index_cache_key
|
503
|
+
secondary_index_cache_key(:id, "all")
|
504
|
+
end
|
505
|
+
end
|
506
|
+
|
507
|
+
# @return [Hash] Attributes to cache.
|
508
|
+
def pantry_attributes
|
509
|
+
attributes
|
510
|
+
end
|
511
|
+
|
512
|
+
private
|
513
|
+
|
514
|
+
def pantry_json
|
515
|
+
pantry_attributes.to_json
|
516
|
+
end
|
517
|
+
end
|
518
|
+
end
|
data/lib/pantry.rb
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
require "active_record"
|
2
|
+
require "hiredis"
|
3
|
+
require "json"
|
4
|
+
require "ostruct"
|
5
|
+
require "redis"
|
6
|
+
|
7
|
+
require "pantry/version"
|
8
|
+
require "pantry/configuration_error"
|
9
|
+
require "pantry/dry_good"
|
10
|
+
require "pantry/stocked"
|
11
|
+
|
12
|
+
module Pantry
|
13
|
+
class << self
|
14
|
+
# Returns the (mutable) configuration object for this module.
|
15
|
+
#
|
16
|
+
# The following keys are supported:
|
17
|
+
# :redis_uri - The Redis instance to use as the cache. Defaults to nil,
|
18
|
+
# which in turn allows the Redis library to connect to the default host.
|
19
|
+
# :global_key_prefix - The portion of the cache key prefix that should be
|
20
|
+
# used for all cache keys. Defaults to `pantry`.
|
21
|
+
# :global_key_version - The key version to use for all cache keys.
|
22
|
+
# Defaults to `1`.
|
23
|
+
# :global_key_ttl_s - The default TTL (in seconds) to apply to all keys.
|
24
|
+
# Defaults to 1 week.
|
25
|
+
# :force_cache_misses - Whether or not to ignore the cache completely on
|
26
|
+
# read. Defaults to false.
|
27
|
+
# @return [OpenStruct] Configuration object.
|
28
|
+
def configuration
|
29
|
+
@configuration ||= OpenStruct.new(
|
30
|
+
redis_uri: nil,
|
31
|
+
global_key_prefix: "pantry",
|
32
|
+
global_key_version: 1,
|
33
|
+
global_key_ttl_s: 60 * 60 * 24 * 7 * 2,
|
34
|
+
force_cache_misses: false)
|
35
|
+
end
|
36
|
+
|
37
|
+
# Returns the redis instance that this module is using as a cache.
|
38
|
+
#
|
39
|
+
# @return [Redis] Redis instance.
|
40
|
+
def redis
|
41
|
+
@redis ||= Redis.new(
|
42
|
+
url: Pantry.configuration.redis_uri,
|
43
|
+
driver: :hiredis)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
data/pantry.gemspec
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
$LOAD_PATH.unshift(File.expand_path("../lib", __FILE__))
|
2
|
+
|
3
|
+
require "pantry/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = "the-pantry"
|
7
|
+
spec.version = Pantry::VERSION
|
8
|
+
spec.authors = ["sprig"]
|
9
|
+
|
10
|
+
spec.summary = "Before you go to the store, check the pantry!"
|
11
|
+
spec.description = "Simple ActiveRecord model caching using Redis."
|
12
|
+
spec.license = "MIT"
|
13
|
+
|
14
|
+
spec.files = `git ls-files -z`
|
15
|
+
.split("\x0")
|
16
|
+
.reject { |f| f.match(%r{^(test|spec|features)/}) }
|
17
|
+
spec.bindir = "exe"
|
18
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
19
|
+
spec.require_paths = ["lib"]
|
20
|
+
|
21
|
+
spec.add_runtime_dependency "activerecord", "~> 4.2"
|
22
|
+
spec.add_runtime_dependency "hiredis", "~> 0.6"
|
23
|
+
spec.add_runtime_dependency "json", "~> 1.8"
|
24
|
+
spec.add_runtime_dependency "redis", "~> 3.2"
|
25
|
+
|
26
|
+
spec.add_development_dependency "bundler", "~> 1.3"
|
27
|
+
spec.add_development_dependency "database_cleaner", "~> 1.3"
|
28
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
29
|
+
spec.add_development_dependency "rspec", "~> 3.3"
|
30
|
+
spec.add_development_dependency "sqlite3", "~> 1.3"
|
31
|
+
spec.add_development_dependency "timecop", "~> 0.7"
|
32
|
+
end
|
metadata
ADDED
@@ -0,0 +1,200 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: the-pantry
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- sprig
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2017-03-30 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: activerecord
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '4.2'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '4.2'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: hiredis
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0.6'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0.6'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: json
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '1.8'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '1.8'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: redis
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '3.2'
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '3.2'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: bundler
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '1.3'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '1.3'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: database_cleaner
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - "~>"
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '1.3'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - "~>"
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '1.3'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: rake
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - "~>"
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '10.0'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - "~>"
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '10.0'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: rspec
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - "~>"
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '3.3'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - "~>"
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '3.3'
|
125
|
+
- !ruby/object:Gem::Dependency
|
126
|
+
name: sqlite3
|
127
|
+
requirement: !ruby/object:Gem::Requirement
|
128
|
+
requirements:
|
129
|
+
- - "~>"
|
130
|
+
- !ruby/object:Gem::Version
|
131
|
+
version: '1.3'
|
132
|
+
type: :development
|
133
|
+
prerelease: false
|
134
|
+
version_requirements: !ruby/object:Gem::Requirement
|
135
|
+
requirements:
|
136
|
+
- - "~>"
|
137
|
+
- !ruby/object:Gem::Version
|
138
|
+
version: '1.3'
|
139
|
+
- !ruby/object:Gem::Dependency
|
140
|
+
name: timecop
|
141
|
+
requirement: !ruby/object:Gem::Requirement
|
142
|
+
requirements:
|
143
|
+
- - "~>"
|
144
|
+
- !ruby/object:Gem::Version
|
145
|
+
version: '0.7'
|
146
|
+
type: :development
|
147
|
+
prerelease: false
|
148
|
+
version_requirements: !ruby/object:Gem::Requirement
|
149
|
+
requirements:
|
150
|
+
- - "~>"
|
151
|
+
- !ruby/object:Gem::Version
|
152
|
+
version: '0.7'
|
153
|
+
description: Simple ActiveRecord model caching using Redis.
|
154
|
+
email:
|
155
|
+
executables: []
|
156
|
+
extensions: []
|
157
|
+
extra_rdoc_files: []
|
158
|
+
files:
|
159
|
+
- ".gitignore"
|
160
|
+
- ".rspec"
|
161
|
+
- ".ruby-version"
|
162
|
+
- ".travis.yml"
|
163
|
+
- Gemfile
|
164
|
+
- Gemfile.lock
|
165
|
+
- LICENSE.txt
|
166
|
+
- README.md
|
167
|
+
- Rakefile
|
168
|
+
- bin/console
|
169
|
+
- bin/setup
|
170
|
+
- lib/pantry.rb
|
171
|
+
- lib/pantry/configuration_error.rb
|
172
|
+
- lib/pantry/dry_good.rb
|
173
|
+
- lib/pantry/stocked.rb
|
174
|
+
- lib/pantry/version.rb
|
175
|
+
- pantry.gemspec
|
176
|
+
homepage:
|
177
|
+
licenses:
|
178
|
+
- MIT
|
179
|
+
metadata: {}
|
180
|
+
post_install_message:
|
181
|
+
rdoc_options: []
|
182
|
+
require_paths:
|
183
|
+
- lib
|
184
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
185
|
+
requirements:
|
186
|
+
- - ">="
|
187
|
+
- !ruby/object:Gem::Version
|
188
|
+
version: '0'
|
189
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
190
|
+
requirements:
|
191
|
+
- - ">="
|
192
|
+
- !ruby/object:Gem::Version
|
193
|
+
version: '0'
|
194
|
+
requirements: []
|
195
|
+
rubyforge_project:
|
196
|
+
rubygems_version: 2.6.9
|
197
|
+
signing_key:
|
198
|
+
specification_version: 4
|
199
|
+
summary: Before you go to the store, check the pantry!
|
200
|
+
test_files: []
|