cache_crispies 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bed703f9038261d6ff63e08465e7c0dccea1bcb6e2cb583de3ab154da562ef8f
4
+ data.tar.gz: ed55298237e61b9c6995a0194c61cd6ec221ea3cd62be176afa8842cb9d28c50
5
+ SHA512:
6
+ metadata.gz: e6897a7621e75851e7d9b2092674a44ed0ff42f328c61876b9de82dfc984c1eb2a7379dbe60f336659552be6445798063a1df83941fab55aeb0537a67fea5718
7
+ data.tar.gz: 0a9ac99db021a8e5ac1d06547d55c84e346aa550f020598f716993a5b783c2f2f8bcf002fda92d113f1864da2083f03719a5a240ae3e8dbebf3e24df01e1a543
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --require spec_helper
@@ -0,0 +1,66 @@
1
+ module CacheCrispies
2
+ class Attribute
3
+ class InvalidCoersionType < ArgumentError; end
4
+
5
+ def initialize(key, from: nil, with: nil, to: nil, nesting: [], conditions: [])
6
+ @key = key
7
+ @method_name = from || key || :itself
8
+ @serializer = with
9
+ @coerce_to = to
10
+ @nesting = Array(nesting)
11
+ @conditions = Array(conditions)
12
+ end
13
+
14
+ attr_reader :method_name, :key, :serializer, :coerce_to, :nesting, :conditions
15
+
16
+ def value_for(model, options)
17
+ value = model.public_send(method_name)
18
+
19
+ serializer ? serialize(value, options) : coerce(value)
20
+ end
21
+
22
+ private
23
+
24
+ def serialize(value, options)
25
+ plan = CacheCrispies::Plan.new(serializer, value, options)
26
+
27
+ if value.respond_to?(:each)
28
+ plan.cache { Collection.new(value, serializer, options).as_json }
29
+ else
30
+ plan.cache { serializer.new(value, options).as_json }
31
+ end
32
+ end
33
+
34
+ def coerce(value)
35
+ return value if coerce_to.nil?
36
+
37
+ case coerce_to.to_s.to_sym
38
+ when :String
39
+ value.to_s
40
+ when :Integer
41
+ try_coerce_via_string(value, :to_i)
42
+ when :Float
43
+ try_coerce_via_string(value, :to_f)
44
+ when :BigDecimal
45
+ BigDecimal(value)
46
+ when :Array
47
+ Array(value)
48
+ when :Hash
49
+ value.respond_to?(:to_h) ? value.to_h : value.to_hash
50
+ when :bool, :boolean, :TrueClass, :FalseClass
51
+ !!value
52
+ else
53
+ raise(
54
+ InvalidCoersionType,
55
+ "#{coerce_to} has no registered coercion strategy"
56
+ )
57
+ end
58
+ end
59
+
60
+ def try_coerce_via_string(value, method_name)
61
+ (
62
+ value.respond_to?(method_name) ? value : value.to_s
63
+ ).public_send(method_name)
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,110 @@
1
+ require 'digest'
2
+ require 'rails'
3
+
4
+ module CacheCrispies
5
+ class Base
6
+ attr_reader :model, :options
7
+
8
+ def initialize(model, options = {})
9
+ @model = model
10
+ @options = options
11
+ end
12
+
13
+ def as_json
14
+ HashBuilder.new(self).call
15
+ end
16
+
17
+ def self.do_caching?
18
+ false
19
+ end
20
+
21
+ def self.key
22
+ to_s.demodulize.chomp('Serializer').underscore.to_sym
23
+ end
24
+
25
+ def self.collection_key
26
+ return nil unless key
27
+
28
+ key.to_s.pluralize.to_sym
29
+ end
30
+
31
+ # Can be overridden in subclasses
32
+ # options: Hash of the same options that would be passed to the
33
+ # individual serializer instances
34
+ def self.cache_key_addons(_options = {})
35
+ []
36
+ end
37
+
38
+ def self.cache_key_base
39
+ # TODO: we may need to get a cache key from nested serializers as well :(
40
+ @cache_key_base ||= "#{self}-#{file_hash}"
41
+ end
42
+
43
+ def self.attributes
44
+ @attributes || []
45
+ end
46
+ delegate :attributes, to: :class
47
+
48
+ private
49
+
50
+ def self.file_hash
51
+ @file_hash ||= Digest::MD5.file(path).to_s
52
+ end
53
+ private_class_method :file_hash
54
+
55
+ def self.path
56
+ @path ||= begin
57
+ parts = %w[app serializers]
58
+ parts += to_s.deconstantize.split('::').map(&:underscore)
59
+ parts << "#{to_s.demodulize.underscore}.rb"
60
+ Rails.root.join(*parts)
61
+ end
62
+ end
63
+ private_class_method :path
64
+
65
+ def self.nest_in(key, &block)
66
+ @nesting ||= []
67
+ @nesting << key
68
+
69
+ block.call
70
+
71
+ @nesting.pop
72
+ end
73
+ private_class_method :nest_in
74
+
75
+ def self.show_if(condition_proc, &block)
76
+ @conditions ||= []
77
+ @conditions << Condition.new(condition_proc)
78
+
79
+ block.call
80
+
81
+ @conditions.pop
82
+ end
83
+ private_class_method :show_if
84
+
85
+ def self.serialize(*attribute_names, from: nil, with: nil, to: nil)
86
+ @attributes ||= []
87
+
88
+ attribute_names.flatten.map { |att| att&.to_sym }.map do |attrib|
89
+ current_nesting = Array(@nesting).dup
90
+ current_conditions = Array(@conditions).dup
91
+
92
+ @attributes <<
93
+ Attribute.new(
94
+ attrib,
95
+ from: from,
96
+ with: with,
97
+ to: to,
98
+ nesting: current_nesting,
99
+ conditions: current_conditions
100
+ )
101
+ end
102
+ end
103
+ private_class_method :serialize
104
+
105
+ def self.merge(attribute = nil, with: nil)
106
+ serialize(nil, from: attribute, with: with)
107
+ end
108
+ private_class_method :merge
109
+ end
110
+ end
@@ -0,0 +1,43 @@
1
+ require 'rails'
2
+
3
+ module CacheCrispies
4
+ class Collection
5
+ def initialize(collection, serializer, options = {})
6
+ @collection = collection
7
+ @serializer = serializer
8
+ @options = options
9
+ end
10
+
11
+ def as_json
12
+ if serializer.do_caching? && collection.respond_to?(:cache_key)
13
+ cached_json
14
+ else
15
+ uncached_json
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ attr_reader :collection, :serializer, :options
22
+
23
+ def uncached_json
24
+ collection.map do |model|
25
+ serializer.new(model, options).as_json
26
+ end
27
+ end
28
+
29
+ def cached_json
30
+ models_by_cache_key = collection.each_with_object({}) do |model, hash|
31
+ plan = Plan.new(serializer, model, options)
32
+
33
+ hash[plan.cache_key] = model
34
+ end
35
+
36
+ Rails.cache.fetch_multi(models_by_cache_key.keys) do |cache_key|
37
+ model = models_by_cache_key[cache_key]
38
+
39
+ serializer.new(model, options).as_json
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,30 @@
1
+ module CacheCrispies
2
+ # Represents an instance of a conditional built by a show_if call
3
+ class Condition
4
+ def initialize(block)
5
+ @block = block
6
+ end
7
+
8
+ # Public: A system-wide unique ID used for memoizaiton
9
+ # Returns an Integer
10
+ def uid
11
+ # Just reusing the block's object_id seems to make sense
12
+ block.object_id
13
+ end
14
+
15
+ def true_for?(model, options = {})
16
+ !!case block.arity
17
+ when 0
18
+ block.call
19
+ when 1
20
+ block.call(model)
21
+ else
22
+ block.call(model, options)
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :block
29
+ end
30
+ end
@@ -0,0 +1,26 @@
1
+ module CacheCrispies
2
+ module Controller
3
+ extend ActiveSupport::Concern
4
+
5
+ OJ_MODE = :rails
6
+
7
+ def cache_render(serializer, cacheable, options = {})
8
+ plan = CacheCrispies::Plan.new(serializer, cacheable, options)
9
+
10
+ # TODO: It would probably be good to add configuration to etiher
11
+ # enable or disable this
12
+ response.weak_etag = plan.etag
13
+
14
+ serializer_json =
15
+ if plan.collection?
16
+ cacheable.map do |one_cacheable|
17
+ plan.cache { serializer.new(one_cacheable, options).as_json }
18
+ end
19
+ else
20
+ plan.cache { serializer.new(cacheable, options).as_json }
21
+ end
22
+
23
+ render json: Oj.dump(plan.wrap(serializer_json), mode: OJ_MODE)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,61 @@
1
+ module CacheCrispies
2
+ class HashBuilder
3
+ def initialize(serializer)
4
+ @serializer = serializer
5
+ @condition_results = Memoizer.new
6
+ end
7
+
8
+ def call
9
+ hash = {}
10
+
11
+ serializer.attributes.each do |attrib|
12
+ next unless show?(attrib)
13
+
14
+ deepest_hash = hash
15
+
16
+ attrib.nesting.each do |key|
17
+ deepest_hash[key] ||= {}
18
+ deepest_hash = deepest_hash[key]
19
+ end
20
+
21
+ value = value_for(attrib)
22
+
23
+ if attrib.key
24
+ deepest_hash[attrib.key] = value
25
+ else
26
+ deepest_hash.merge! value
27
+ end
28
+ end
29
+
30
+ hash
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :serializer, :condition_results
36
+
37
+ def show?(attribute)
38
+ # Memoize conditions so they aren't executed for each attribute in a
39
+ # show_if block
40
+ attribute.conditions.all? do |cond|
41
+ condition_results.fetch(cond.uid) do
42
+ cond.true_for?(serializer.model, serializer.options)
43
+ end
44
+ end
45
+ end
46
+
47
+ def value_for(attribute)
48
+ meth = attribute.method_name
49
+
50
+ target =
51
+ if meth != :itself && serializer.respond_to?(meth)
52
+ serializer
53
+ else
54
+ serializer.model
55
+ end
56
+
57
+ # TODO: rescue NoMethodErrors here with something more telling
58
+ attribute.value_for(target, serializer.options)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,18 @@
1
+ module CacheCrispies
2
+ class Memoizer
3
+ def initialize
4
+ @cache = {}
5
+ end
6
+
7
+ def fetch(key, &_block)
8
+ # Avoid ||= because we need to memoize falsey values.
9
+ return cache[key] if cache.key?(key)
10
+
11
+ cache[key] = yield
12
+ end
13
+
14
+ private
15
+
16
+ attr_reader :cache
17
+ end
18
+ end
@@ -0,0 +1,68 @@
1
+ module CacheCrispies
2
+ class Plan
3
+ attr_reader :serializer, :cacheable, :options
4
+
5
+ def initialize(serializer, cacheable, options = {})
6
+ @serializer = serializer
7
+ @cacheable = cacheable
8
+ @key = options.delete(:key)
9
+ @options = options
10
+ end
11
+
12
+ def collection?
13
+ cacheable.respond_to?(:each)
14
+ end
15
+
16
+ def etag
17
+ Digest::MD5.hexdigest(cache_key)
18
+ end
19
+
20
+ def cache_key
21
+ @cache_key ||=
22
+ [
23
+ CACHE_KEY_PREFIX,
24
+ serializer.cache_key_base,
25
+ addons_key,
26
+ cacheable.cache_key
27
+ ].flatten.compact.join(CACHE_KEY_SEPARATOR)
28
+ end
29
+
30
+ def cache
31
+ if cache?
32
+ Rails.cache.fetch(cache_key) { yield }
33
+ else
34
+ yield
35
+ end
36
+ end
37
+
38
+ def wrap(json_hash)
39
+ return json_hash unless key?
40
+
41
+ { key => json_hash }
42
+ end
43
+
44
+ private
45
+
46
+ def key
47
+ return @key unless @key.nil?
48
+
49
+ (collection? ? serializer.collection_key : serializer.key)
50
+ end
51
+
52
+ def key?
53
+ !!key
54
+ end
55
+
56
+ def cache?
57
+ serializer.do_caching? && cacheable.respond_to?(:cache_key)
58
+ end
59
+
60
+ def addons_key
61
+ addons = serializer.cache_key_addons(options)
62
+
63
+ return nil if addons.compact.empty?
64
+
65
+ Digest::MD5.hexdigest(addons.join('|'))
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,3 @@
1
+ module CacheCrispies
2
+ VERSION = '0.1.0'.freeze
3
+ end
@@ -0,0 +1,18 @@
1
+ require 'active_support/dependencies'
2
+ require 'oj'
3
+
4
+ module CacheCrispies
5
+ CACHE_KEY_PREFIX = 'cache-crispies'.freeze
6
+ CACHE_KEY_SEPARATOR = '+'.freeze
7
+
8
+ require 'cache_crispies/version'
9
+
10
+ autoload :Attribute, 'cache_crispies/attribute'
11
+ autoload :Base, 'cache_crispies/base'
12
+ autoload :Collection, 'cache_crispies/collection'
13
+ autoload :Condition, 'cache_crispies/condition'
14
+ autoload :HashBuilder, 'cache_crispies/hash_builder'
15
+ autoload :Memoizer, 'cache_crispies/memoizer'
16
+ autoload :Controller, 'cache_crispies/controller'
17
+ autoload :Plan, 'cache_crispies/plan'
18
+ end
@@ -0,0 +1,159 @@
1
+ require 'spec_helper'
2
+
3
+ describe CacheCrispies::Attribute do
4
+ class NameSerializer < CacheCrispies::Base
5
+ serialize :spanish
6
+ end
7
+
8
+ class ToHashClass
9
+ def to_hash
10
+ { portuguese: 'Capitão Crise' }
11
+ end
12
+ end
13
+
14
+ let(:key) { :name }
15
+ let(:from) { nil }
16
+ let(:with) { nil }
17
+ let(:to) { nil }
18
+ let(:nesting) { [] }
19
+ let(:conditions) { [] }
20
+ let(:instance) {
21
+ described_class.new(
22
+ key,
23
+ from: from,
24
+ with: with,
25
+ to: to,
26
+ nesting: nesting,
27
+ conditions: conditions
28
+ )
29
+ }
30
+
31
+ subject { instance }
32
+
33
+ describe '#value_for' do
34
+ let(:name) { "Cap'n Crunch" }
35
+ let(:model) { OpenStruct.new(name: name) }
36
+ let(:options) { {} }
37
+
38
+ subject { instance.value_for(model, options) }
39
+
40
+ it 'returns the value' do
41
+ expect(subject).to eq name
42
+ end
43
+
44
+ context 'with a from: argument' do
45
+ let(:key) { :nombre }
46
+ let(:from) { :name }
47
+
48
+ it 'returns the value using the from: attribute' do
49
+ expect(subject).to eq name
50
+ end
51
+ end
52
+
53
+ context 'with a with: argument' do
54
+ let(:spanish_name) { 'Capitán Crujido' }
55
+ let(:name) { OpenStruct.new(spanish: spanish_name)}
56
+ let(:with) { NameSerializer }
57
+
58
+ it 'returns the value using the from attribute' do
59
+ expect(subject).to eq spanish: spanish_name
60
+ end
61
+ end
62
+
63
+ context 'with a to: argument' do
64
+ context 'when corecing to a String' do
65
+ let(:name) { 1138 }
66
+ let(:to) { String }
67
+
68
+ it 'returns a String' do
69
+ expect(subject).to eq '1138'
70
+ end
71
+ end
72
+
73
+ context 'when corecing to an Integer' do
74
+ let(:name) { '1138' }
75
+ let(:to) { Integer }
76
+
77
+ it 'returns a String' do
78
+ expect(subject).to eq 1138
79
+ end
80
+ end
81
+
82
+ context 'when corecing to an Float' do
83
+ let(:name) { '1138' }
84
+ let(:to) { Float }
85
+
86
+ it 'returns a String' do
87
+ expect(subject).to eq 1138.0
88
+ end
89
+ end
90
+
91
+ context 'when corecing to an BigDecimal' do
92
+ let(:name) { '1138' }
93
+ let(:to) { BigDecimal }
94
+
95
+ it 'returns a String' do
96
+ expect(subject).to eq BigDecimal(1138)
97
+ end
98
+ end
99
+
100
+ context 'when corecing to an Array' do
101
+ let(:name) { 1138 }
102
+ let(:to) { Array }
103
+
104
+ it 'returns an Array' do
105
+ expect(subject).to eq [1138]
106
+ end
107
+ end
108
+
109
+ context 'when corecing to a Hash' do
110
+ let(:to) { Hash }
111
+
112
+ context 'that responds to to_h' do
113
+ let(:french_name) { 'capitaine croquer' }
114
+ let(:name) { OpenStruct.new(french: french_name) }
115
+
116
+ it 'returns a Hash' do
117
+ expect(subject).to eq french: french_name
118
+ end
119
+ end
120
+
121
+ context 'that responds to to_hash' do
122
+ let(:name) { ToHashClass.new }
123
+
124
+ it 'returns a Hash' do
125
+ expect(subject).to eq portuguese: 'Capitão Crise'
126
+ end
127
+ end
128
+ end
129
+
130
+ context 'when corecing to an boolean' do
131
+ let(:to) { :boolean }
132
+
133
+ context 'when value is falsey' do
134
+ let(:name) { nil }
135
+
136
+ it 'returns false' do
137
+ expect(subject).to be false
138
+ end
139
+ end
140
+
141
+ context 'when value is truthy' do
142
+ let(:name) { 'true' }
143
+
144
+ it 'returns true' do
145
+ expect(subject).to be true
146
+ end
147
+ end
148
+ end
149
+
150
+ context 'when corercing to an invalid type' do
151
+ let(:to) { OpenStruct }
152
+
153
+ it 'raises an exception' do
154
+ expect { subject }.to raise_exception CacheCrispies::Attribute::InvalidCoersionType
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end