catche 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/MIT-LICENSE +20 -0
- data/README.md +44 -0
- data/Rakefile +6 -0
- data/lib/catche.rb +19 -0
- data/lib/catche/adapter.rb +1 -0
- data/lib/catche/adapter/base.rb +27 -0
- data/lib/catche/controller.rb +1 -0
- data/lib/catche/controller/base.rb +55 -0
- data/lib/catche/model.rb +1 -0
- data/lib/catche/model/base.rb +35 -0
- data/lib/catche/railtie.rb +7 -0
- data/lib/catche/tag.rb +71 -0
- data/lib/catche/tag/object.rb +136 -0
- data/lib/catche/tag/resource.rb +35 -0
- data/lib/catche/version.rb +3 -0
- data/lib/tasks/catche_tasks.rake +4 -0
- metadata +127 -0
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright 2012 Arjen Oosterkamp
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
# Catche [](http://travis-ci.org/Arjeno/catche)
|
2
|
+
|
3
|
+
Catche is a caching library for easy and automated resource and collection caching. It basically tags cached outputs and expires them based on configuration.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add this to your Gemfile and run `bundle`.
|
8
|
+
```
|
9
|
+
gem "catche"
|
10
|
+
```
|
11
|
+
|
12
|
+
## Controller caching
|
13
|
+
|
14
|
+
Controller caching is based on `caches_action` using the method `catche`.
|
15
|
+
|
16
|
+
### Simple caching
|
17
|
+
|
18
|
+
```ruby
|
19
|
+
class ProjectsController < ApplicationController
|
20
|
+
catche Project, :index, :show
|
21
|
+
end
|
22
|
+
```
|
23
|
+
|
24
|
+
### Associative caching
|
25
|
+
|
26
|
+
```ruby
|
27
|
+
class TasksController < ApplicationController
|
28
|
+
catche Task, :index, :show, :through => :project
|
29
|
+
end
|
30
|
+
```
|
31
|
+
|
32
|
+
On resource change this will expire:
|
33
|
+
|
34
|
+
* Resource task
|
35
|
+
* Resource task within specific project
|
36
|
+
|
37
|
+
On resource or collection change this will expire:
|
38
|
+
|
39
|
+
* Collection tasks
|
40
|
+
* Collection tasks within specific project
|
41
|
+
|
42
|
+
## License
|
43
|
+
|
44
|
+
This project is released under the MIT license.
|
data/Rakefile
ADDED
data/lib/catche.rb
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
require 'catche/railtie'
|
2
|
+
require 'catche/adapter'
|
3
|
+
require 'catche/controller'
|
4
|
+
require 'catche/model'
|
5
|
+
require 'catche/tag'
|
6
|
+
|
7
|
+
module Catche
|
8
|
+
|
9
|
+
extend self
|
10
|
+
|
11
|
+
def initialize_defaults
|
12
|
+
|
13
|
+
end
|
14
|
+
|
15
|
+
def adapter
|
16
|
+
Catche::Adapter::Base
|
17
|
+
end
|
18
|
+
|
19
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
require 'catche/adapter/base'
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module Catche
|
2
|
+
module Adapter
|
3
|
+
class Base
|
4
|
+
|
5
|
+
class << self
|
6
|
+
|
7
|
+
def read(key, default_value=nil)
|
8
|
+
adapter.read(key) || default_value
|
9
|
+
end
|
10
|
+
|
11
|
+
def write(key, value)
|
12
|
+
adapter.write(key, value)
|
13
|
+
end
|
14
|
+
|
15
|
+
def delete(key)
|
16
|
+
adapter.delete(key)
|
17
|
+
end
|
18
|
+
|
19
|
+
def adapter
|
20
|
+
Rails.cache
|
21
|
+
end
|
22
|
+
|
23
|
+
end
|
24
|
+
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
require 'catche/controller/base'
|
@@ -0,0 +1,55 @@
|
|
1
|
+
module Catche
|
2
|
+
module Controller
|
3
|
+
module Base
|
4
|
+
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
module ClassMethods
|
8
|
+
|
9
|
+
# Caches a controller action by tagging it.
|
10
|
+
# Supports any option parameters caches_action supports.
|
11
|
+
#
|
12
|
+
# catche Project, :index
|
13
|
+
# catche Task, :through => :project
|
14
|
+
def catche(model, *args)
|
15
|
+
options = args.extract_options!
|
16
|
+
tag = Proc.new { |controller| Catche::Tag::Object.for(model, controller.class, options) }
|
17
|
+
|
18
|
+
# Use Rails caches_action to pass along the tag
|
19
|
+
caches_action(*args, { :tag => tag }.merge(options))
|
20
|
+
end
|
21
|
+
|
22
|
+
end
|
23
|
+
|
24
|
+
def _save_fragment(name, options={})
|
25
|
+
key = fragment_cache_key(name)
|
26
|
+
object = options[:tag]
|
27
|
+
tags = []
|
28
|
+
|
29
|
+
if object.present?
|
30
|
+
if object.respond_to?(:call)
|
31
|
+
object = self.instance_exec(self, &object)
|
32
|
+
|
33
|
+
if object.respond_to?(:tags)
|
34
|
+
tags = object.tags(self)
|
35
|
+
else
|
36
|
+
tags = Array.new(object)
|
37
|
+
end
|
38
|
+
else
|
39
|
+
tags = Array.new(object)
|
40
|
+
end
|
41
|
+
|
42
|
+
Catche::Tag.tag! key, *tags
|
43
|
+
|
44
|
+
# Store for future reference
|
45
|
+
@catche_cache_object = object
|
46
|
+
end
|
47
|
+
|
48
|
+
super
|
49
|
+
end
|
50
|
+
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
ActionController::Base.send :include, Catche::Controller::Base
|
data/lib/catche/model.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require 'catche/model/base'
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module Catche
|
2
|
+
module Model
|
3
|
+
module Base
|
4
|
+
extend ActiveSupport::Concern
|
5
|
+
|
6
|
+
included do
|
7
|
+
after_update :expire_resource!
|
8
|
+
after_destroy :expire_resource!
|
9
|
+
|
10
|
+
after_create :expire_collection!
|
11
|
+
after_destroy :expire_collection!
|
12
|
+
end
|
13
|
+
|
14
|
+
def expire_collection!
|
15
|
+
expire_cache!
|
16
|
+
end
|
17
|
+
|
18
|
+
def expire_resource!
|
19
|
+
expire_cache!
|
20
|
+
end
|
21
|
+
|
22
|
+
def expire_cache!
|
23
|
+
tags = Catche::Tag::Object.find_by_model(self.class).collect do |obj|
|
24
|
+
self.instance_variable_set("@#{obj.options[:resource_name]}", self)
|
25
|
+
obj.expiration_tags(self)
|
26
|
+
end.flatten.compact.uniq
|
27
|
+
|
28
|
+
Catche::Tag.expire! *tags
|
29
|
+
end
|
30
|
+
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
ActiveRecord::Base.send :include, Catche::Model::Base
|
data/lib/catche/tag.rb
ADDED
@@ -0,0 +1,71 @@
|
|
1
|
+
require 'catche/tag/object'
|
2
|
+
require 'catche/tag/resource'
|
3
|
+
|
4
|
+
module Catche
|
5
|
+
module Tag
|
6
|
+
|
7
|
+
KEY = 'catche'
|
8
|
+
DIVIDER = '_'
|
9
|
+
|
10
|
+
extend self
|
11
|
+
|
12
|
+
def join(*tags)
|
13
|
+
tags.flatten.compact.uniq.join(DIVIDER)
|
14
|
+
end
|
15
|
+
|
16
|
+
def tag!(key, *tags)
|
17
|
+
tags.each do |tag|
|
18
|
+
keys = fetch_tag(tag)
|
19
|
+
key_tags = fetch_key(key)
|
20
|
+
tag_key = stored_key(:tags, tag)
|
21
|
+
key_key = stored_key(:keys, key)
|
22
|
+
|
23
|
+
Catche.adapter.write(tag_key, keys << key)
|
24
|
+
Catche.adapter.write(key_key, key_tags << tag_key)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def expire!(*tags)
|
29
|
+
expired_keys = []
|
30
|
+
|
31
|
+
tags.each do |tag|
|
32
|
+
keys = fetch_tag(tag)
|
33
|
+
expired_keys += keys
|
34
|
+
|
35
|
+
keys.each do |key|
|
36
|
+
# Expires the cached value
|
37
|
+
Catche.adapter.delete key
|
38
|
+
|
39
|
+
# Removes the tag from the tag list in case it's never used again
|
40
|
+
Catche.adapter.write(
|
41
|
+
stored_key(:keys, key),
|
42
|
+
fetch_key(key).delete(stored_key(:tags, tag))
|
43
|
+
)
|
44
|
+
end
|
45
|
+
|
46
|
+
Catche.adapter.delete stored_key(:tags, tag)
|
47
|
+
end
|
48
|
+
|
49
|
+
expired_keys
|
50
|
+
end
|
51
|
+
|
52
|
+
protected
|
53
|
+
|
54
|
+
def fetch_tag(tag)
|
55
|
+
Catche.adapter.read stored_key(:tags, tag), []
|
56
|
+
end
|
57
|
+
|
58
|
+
def fetch_key(key)
|
59
|
+
Catche.adapter.read stored_key(:keys, key), []
|
60
|
+
end
|
61
|
+
|
62
|
+
def stored_key(scope, value)
|
63
|
+
join_keys KEY, scope.to_s, value.to_s
|
64
|
+
end
|
65
|
+
|
66
|
+
def join_keys(*keys)
|
67
|
+
keys.join('.')
|
68
|
+
end
|
69
|
+
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,136 @@
|
|
1
|
+
module Catche
|
2
|
+
module Tag
|
3
|
+
class Object
|
4
|
+
|
5
|
+
class << self
|
6
|
+
|
7
|
+
@@objects = []
|
8
|
+
|
9
|
+
# Returns an existing (duplicate) or new tag object for the given arguments.
|
10
|
+
#
|
11
|
+
# Catche::Tag::Object.for(Project, ProjectsController)
|
12
|
+
def for(model, object, options={})
|
13
|
+
tag_object = find_or_initialize(model, object, options)
|
14
|
+
|
15
|
+
objects << tag_object
|
16
|
+
objects.uniq!
|
17
|
+
|
18
|
+
tag_object
|
19
|
+
end
|
20
|
+
|
21
|
+
# Finds a previously declared (same) tag object or returns a new one.
|
22
|
+
def find_or_initialize(model, object, options={})
|
23
|
+
new_object = self.new(model, object, options)
|
24
|
+
|
25
|
+
objects.each do |tag_object|
|
26
|
+
return tag_object if tag_object.same?(new_object)
|
27
|
+
end
|
28
|
+
|
29
|
+
new_object
|
30
|
+
end
|
31
|
+
|
32
|
+
def find_by_model(model)
|
33
|
+
objects.select { |tag_object| tag_object.model == model }
|
34
|
+
end
|
35
|
+
|
36
|
+
def find_by_association(association)
|
37
|
+
objects.select { |tag_object| tag_object.associations.include?(association) }
|
38
|
+
end
|
39
|
+
|
40
|
+
def clear
|
41
|
+
@@objects = []
|
42
|
+
end
|
43
|
+
|
44
|
+
def objects
|
45
|
+
@@objects
|
46
|
+
end
|
47
|
+
|
48
|
+
end
|
49
|
+
|
50
|
+
attr_reader :model, :object, :options
|
51
|
+
attr_accessor :associations
|
52
|
+
|
53
|
+
def initialize(model, object, options={})
|
54
|
+
@model = model
|
55
|
+
@object = object
|
56
|
+
|
57
|
+
association = options[:through]
|
58
|
+
|
59
|
+
@options = {
|
60
|
+
:resource_name => Catche::Tag::Resource.singularize(@model),
|
61
|
+
:collection_name => Catche::Tag::Resource.pluralize(@model),
|
62
|
+
:associations => [association].flatten.compact,
|
63
|
+
:bubble => false,
|
64
|
+
:expire_collection => true
|
65
|
+
}.merge(options)
|
66
|
+
|
67
|
+
@associations = @options[:associations]
|
68
|
+
end
|
69
|
+
|
70
|
+
# Returns the tags for the given object.
|
71
|
+
# If `bubble` is set to true it will pass along the separate tag for each association.
|
72
|
+
# This means the collection or resource is expired as soon as the association changes.
|
73
|
+
#
|
74
|
+
# object = Catche::Tag::Object.new(Task, TasksController, :through => [:user, :project])
|
75
|
+
# object.tags(controller.new) => ['users_1_projects_1_tasks_1']
|
76
|
+
def tags(initialized_object)
|
77
|
+
tags = []
|
78
|
+
|
79
|
+
tags += association_tags(initialized_object) if bubble?
|
80
|
+
tags += expiration_tags(initialized_object)
|
81
|
+
|
82
|
+
tags
|
83
|
+
end
|
84
|
+
|
85
|
+
# The tags that should expire as soon as the resource or collection changes.
|
86
|
+
def expiration_tags(initialized_object)
|
87
|
+
tags = []
|
88
|
+
|
89
|
+
# Add collection tags when enabled
|
90
|
+
tags << Catche::Tag.join(
|
91
|
+
association_tags(initialized_object),
|
92
|
+
options[:collection_name]
|
93
|
+
) if options[:expire_collection]
|
94
|
+
|
95
|
+
tags << Catche::Tag.join(
|
96
|
+
association_tags(initialized_object),
|
97
|
+
identifier_tags(initialized_object)
|
98
|
+
)
|
99
|
+
|
100
|
+
tags.uniq
|
101
|
+
end
|
102
|
+
|
103
|
+
# Identifying tag for the current resource or collection.
|
104
|
+
#
|
105
|
+
# object = Catche::Tag::Object.new(Task, TasksController)
|
106
|
+
# object.identifier_tags(controller) => ['tasks', 1]
|
107
|
+
def identifier_tags(initialized_object)
|
108
|
+
Catche::Tag.join options[:collection_name], Catche::Tag::Resource.resource(initialized_object, options[:resource_name]).try(:id)
|
109
|
+
end
|
110
|
+
|
111
|
+
# Maps the given resources names to tags by fetching the resources from the given object.
|
112
|
+
def resource_tags(*resources)
|
113
|
+
resources.map { |resource| Catche::Tag.join(Catche::Tag::Resource.pluralize(resource.class), resource.id) }
|
114
|
+
end
|
115
|
+
|
116
|
+
# Maps association tags.
|
117
|
+
#
|
118
|
+
# object = Catche::Tag::Object.new(Task, TasksController, :through => [:user, :project])
|
119
|
+
# object.association_tags(controller) => ['users_1', 'projects_1']
|
120
|
+
def association_tags(initialized_object)
|
121
|
+
resource_tags(*Catche::Tag::Resource.associations(initialized_object, associations))
|
122
|
+
end
|
123
|
+
|
124
|
+
def bubble?
|
125
|
+
options[:bubble]
|
126
|
+
end
|
127
|
+
|
128
|
+
def same?(object)
|
129
|
+
self.model == object.model &&
|
130
|
+
self.object == object.object &&
|
131
|
+
self.options == object.options
|
132
|
+
end
|
133
|
+
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module Catche
|
2
|
+
module Tag
|
3
|
+
class Resource
|
4
|
+
|
5
|
+
class << self
|
6
|
+
|
7
|
+
def pluralize(object)
|
8
|
+
object.name.to_s.pluralize.downcase
|
9
|
+
end
|
10
|
+
|
11
|
+
def singularize(object)
|
12
|
+
object.name.to_s.singularize.downcase
|
13
|
+
end
|
14
|
+
|
15
|
+
def resource(object, name)
|
16
|
+
if resource = object.instance_variable_get("@#{name}")
|
17
|
+
return resource
|
18
|
+
elsif object.respond_to?(name)
|
19
|
+
begin
|
20
|
+
return object.send(name)
|
21
|
+
rescue; end
|
22
|
+
end
|
23
|
+
|
24
|
+
nil
|
25
|
+
end
|
26
|
+
|
27
|
+
def associations(object, associations)
|
28
|
+
associations.map { |association| resource(object, association) }.compact
|
29
|
+
end
|
30
|
+
|
31
|
+
end
|
32
|
+
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
metadata
ADDED
@@ -0,0 +1,127 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: catche
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Arjen Oosterkamp
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2012-06-18 00:00:00.000000000Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: rails
|
16
|
+
requirement: &70257700363240 !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ~>
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: 3.2.0
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: *70257700363240
|
25
|
+
- !ruby/object:Gem::Dependency
|
26
|
+
name: sqlite3
|
27
|
+
requirement: &70257700362820 !ruby/object:Gem::Requirement
|
28
|
+
none: false
|
29
|
+
requirements:
|
30
|
+
- - ! '>='
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '0'
|
33
|
+
type: :development
|
34
|
+
prerelease: false
|
35
|
+
version_requirements: *70257700362820
|
36
|
+
- !ruby/object:Gem::Dependency
|
37
|
+
name: rspec-rails
|
38
|
+
requirement: &70257700362360 !ruby/object:Gem::Requirement
|
39
|
+
none: false
|
40
|
+
requirements:
|
41
|
+
- - ! '>='
|
42
|
+
- !ruby/object:Gem::Version
|
43
|
+
version: '0'
|
44
|
+
type: :development
|
45
|
+
prerelease: false
|
46
|
+
version_requirements: *70257700362360
|
47
|
+
- !ruby/object:Gem::Dependency
|
48
|
+
name: capybara
|
49
|
+
requirement: &70257700361940 !ruby/object:Gem::Requirement
|
50
|
+
none: false
|
51
|
+
requirements:
|
52
|
+
- - ! '>='
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
type: :development
|
56
|
+
prerelease: false
|
57
|
+
version_requirements: *70257700361940
|
58
|
+
- !ruby/object:Gem::Dependency
|
59
|
+
name: guard-rspec
|
60
|
+
requirement: &70257700361520 !ruby/object:Gem::Requirement
|
61
|
+
none: false
|
62
|
+
requirements:
|
63
|
+
- - ! '>='
|
64
|
+
- !ruby/object:Gem::Version
|
65
|
+
version: '0'
|
66
|
+
type: :development
|
67
|
+
prerelease: false
|
68
|
+
version_requirements: *70257700361520
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: guard-spork
|
71
|
+
requirement: &70257700361100 !ruby/object:Gem::Requirement
|
72
|
+
none: false
|
73
|
+
requirements:
|
74
|
+
- - ! '>='
|
75
|
+
- !ruby/object:Gem::Version
|
76
|
+
version: '0'
|
77
|
+
type: :development
|
78
|
+
prerelease: false
|
79
|
+
version_requirements: *70257700361100
|
80
|
+
description: ''
|
81
|
+
email:
|
82
|
+
- mail@arjen.me
|
83
|
+
executables: []
|
84
|
+
extensions: []
|
85
|
+
extra_rdoc_files: []
|
86
|
+
files:
|
87
|
+
- lib/catche/adapter/base.rb
|
88
|
+
- lib/catche/adapter.rb
|
89
|
+
- lib/catche/controller/base.rb
|
90
|
+
- lib/catche/controller.rb
|
91
|
+
- lib/catche/model/base.rb
|
92
|
+
- lib/catche/model.rb
|
93
|
+
- lib/catche/railtie.rb
|
94
|
+
- lib/catche/tag/object.rb
|
95
|
+
- lib/catche/tag/resource.rb
|
96
|
+
- lib/catche/tag.rb
|
97
|
+
- lib/catche/version.rb
|
98
|
+
- lib/catche.rb
|
99
|
+
- lib/tasks/catche_tasks.rake
|
100
|
+
- MIT-LICENSE
|
101
|
+
- Rakefile
|
102
|
+
- README.md
|
103
|
+
homepage: http://arjen.me/
|
104
|
+
licenses: []
|
105
|
+
post_install_message:
|
106
|
+
rdoc_options: []
|
107
|
+
require_paths:
|
108
|
+
- lib
|
109
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
110
|
+
none: false
|
111
|
+
requirements:
|
112
|
+
- - ! '>='
|
113
|
+
- !ruby/object:Gem::Version
|
114
|
+
version: '0'
|
115
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
116
|
+
none: false
|
117
|
+
requirements:
|
118
|
+
- - ! '>='
|
119
|
+
- !ruby/object:Gem::Version
|
120
|
+
version: '0'
|
121
|
+
requirements: []
|
122
|
+
rubyforge_project:
|
123
|
+
rubygems_version: 1.8.10
|
124
|
+
signing_key:
|
125
|
+
specification_version: 3
|
126
|
+
summary: Smart collection and resource caching
|
127
|
+
test_files: []
|