cache_crispies 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.
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