Skip to content

Commit b391224

Browse files
authored
Merge pull request #2 from richmolj/stats2
Add stats API
2 parents f953276 + f4bafdf commit b391224

16 files changed

+561
-12
lines changed

lib/jsonapi_compliable.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,12 @@
1313
require "jsonapi_compliable/scope/filterable"
1414
require "jsonapi_compliable/scope/default_filter"
1515
require "jsonapi_compliable/scope/filter"
16+
require "jsonapi_compliable/stats/dsl"
17+
require "jsonapi_compliable/stats/payload"
1618
require "jsonapi_compliable/util/include_params"
1719
require "jsonapi_compliable/util/field_params"
1820
require "jsonapi_compliable/util/scoping"
21+
require "jsonapi_compliable/util/pagination"
1922

2023
require 'jsonapi_compliable/railtie' if defined?(::Rails)
2124

lib/jsonapi_compliable/base.rb

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ module Base
77

88
included do
99
class_attribute :_jsonapi_compliable
10-
attr_reader :_jsonapi_scoped
10+
attr_reader :_jsonapi_scope
1111

1212
before_action :parse_fieldsets!
1313
after_action :reset_scope_flag
@@ -36,13 +36,14 @@ def jsonapi_scope(scope,
3636
scope = JsonapiCompliable::Scope::ExtraFields.new(self, scope).apply if extra_fields
3737
scope = JsonapiCompliable::Scope::Sideload.new(self, scope).apply if includes
3838
scope = JsonapiCompliable::Scope::Sort.new(self, scope).apply if sort
39+
# This is set before pagination so it can be re-used for stats
40+
@_jsonapi_scope = scope
3941
scope = JsonapiCompliable::Scope::Paginate.new(self, scope).apply if paginate
40-
@_jsonapi_scoped = true
4142
scope
4243
end
4344

4445
def reset_scope_flag
45-
@_jsonapi_scoped = false
46+
@_jsonapi_scope = nil
4647
end
4748

4849
def parse_fieldsets!
@@ -51,14 +52,16 @@ def parse_fieldsets!
5152
end
5253

5354
def render_ams(scope, opts = {})
54-
scope = jsonapi_scope(scope) if Util::Scoping.apply?(self, scope, opts.delete(:scope))
55+
scoped = Util::Scoping.apply?(self, scope, opts.delete(:scope)) ? jsonapi_scope(scope) : scope
5556
options = default_ams_options
5657
options[:include] = forced_includes || Util::IncludeParams.scrub(self)
57-
options[:jsonapi] = scope
58+
options[:jsonapi] = JsonapiCompliable::Util::Pagination.zero?(params) ? [] : scoped
5859
options[:fields] = Util::FieldParams.fieldset(params, :fields) if params[:fields]
5960
options[:extra_fields] = Util::FieldParams.fieldset(params, :extra_fields) if params[:extra_fields]
60-
61+
options[:meta] ||= {}
6162
options.merge!(opts)
63+
options[:meta][:stats] = Stats::Payload.new(self, scoped).generate if params[:stats]
64+
6265
render(options)
6366
end
6467

lib/jsonapi_compliable/dsl.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ class DSL
55
:extra_fields,
66
:filters,
77
:sorting,
8+
:stats,
89
:pagination
910

1011
def initialize
@@ -19,6 +20,7 @@ def copy
1920
instance.extra_fields = extra_fields.deep_dup
2021
instance.sorting = sorting.deep_dup
2122
instance.pagination = pagination.deep_dup
23+
instance.stats = stats.deep_dup
2224
instance
2325
end
2426

@@ -27,6 +29,7 @@ def clear!
2729
@filters = {}
2830
@default_filters = {}
2931
@extra_fields = {}
32+
@stats = {}
3033
@sorting = nil
3134
@pagination = nil
3235
end
@@ -50,6 +53,12 @@ def allow_filter(name, *args, &blk)
5053
}
5154
end
5255

56+
def allow_stat(symbol_or_hash, &blk)
57+
dsl = Stats::DSL.new(symbol_or_hash)
58+
dsl.instance_eval(&blk) if blk
59+
@stats[dsl.name] = dsl
60+
end
61+
5362
def default_filter(name, &blk)
5463
@default_filters[name.to_sym] = {
5564
filter: blk
@@ -71,5 +80,11 @@ def extra_field(field, &blk)
7180
proc: blk
7281
}
7382
end
83+
84+
def stat(attribute, calculation)
85+
stats_dsl = @stats[attribute] || @stats[attribute.to_sym]
86+
raise Errors::StatNotFound.new(attribute, calculation) unless stats_dsl
87+
stats_dsl.calculation(calculation)
88+
end
7489
end
7590
end

lib/jsonapi_compliable/errors.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,26 @@ def message
1111
"Requested page size #{@size} is greater than max supported size #{@max}"
1212
end
1313
end
14+
15+
class StatNotFound < StandardError
16+
def initialize(attribute, calculation)
17+
@attribute = attribute
18+
@calculation = calculation
19+
end
20+
21+
def message
22+
"No stat configured for calculation #{pretty(@calculation)} on attribute #{pretty(@attribute)}"
23+
end
24+
25+
private
26+
27+
def pretty(input)
28+
if input.is_a?(Symbol)
29+
":#{input}"
30+
else
31+
"'#{input}'"
32+
end
33+
end
34+
end
1435
end
1536
end

lib/jsonapi_compliable/stats/dsl.rb

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
module JsonapiCompliable
2+
module Stats
3+
class DSL
4+
attr_reader :name, :calculations
5+
6+
def self.defaults
7+
{
8+
count: ->(scope, attr) { scope.count },
9+
average: ->(scope, attr) { scope.average(attr).to_f },
10+
sum: ->(scope, attr) { scope.sum(attr) },
11+
maximum: ->(scope, attr) { scope.maximum(attr) },
12+
minimum: ->(scope, attr) { scope.minimum(attr) }
13+
}
14+
end
15+
16+
def initialize(config)
17+
config = { config => [] } if config.is_a?(Symbol)
18+
19+
@calculations = {}
20+
@name = config.keys.first
21+
Array(config.values.first).each { |c| send(:"#{c}!") }
22+
end
23+
24+
def method_missing(meth, *args, &blk)
25+
@calculations[meth] = blk
26+
end
27+
28+
def calculation(name)
29+
callable = @calculations[name] || @calculations[name.to_sym]
30+
callable || raise(Errors::StatNotFound.new(@name, name))
31+
end
32+
33+
def count!
34+
@calculations[:count] = self.class.defaults[:count]
35+
end
36+
37+
def sum!
38+
@calculations[:sum] = self.class.defaults[:sum]
39+
end
40+
41+
def average!
42+
@calculations[:average] = self.class.defaults[:average]
43+
end
44+
45+
def maximum!
46+
@calculations[:maximum] = self.class.defaults[:maximum]
47+
end
48+
49+
def minimum!
50+
@calculations[:minimum] = self.class.defaults[:minimum]
51+
end
52+
end
53+
end
54+
end
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
module JsonapiCompliable
2+
module Stats
3+
class Payload
4+
def initialize(controller, scope)
5+
@dsl = controller._jsonapi_compliable
6+
@directive = controller.params[:stats]
7+
@scope = controller._jsonapi_scope || scope
8+
end
9+
10+
def generate
11+
{}.tap do |stats|
12+
@directive.each_pair do |name, calculation|
13+
stats[name] = {}
14+
15+
each_calculation(name, calculation) do |calc, function|
16+
stats[name][calc] = function.call(@scope, name)
17+
end
18+
end
19+
end
20+
end
21+
22+
private
23+
24+
def each_calculation(name, calculation_string)
25+
calculations = calculation_string.split(',').map(&:to_sym)
26+
27+
calculations.each do |calc|
28+
function = @dsl.stat(name, calc)
29+
yield calc, function
30+
end
31+
end
32+
end
33+
end
34+
end
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
module JsonapiCompliable
2+
module Util
3+
class Pagination
4+
def self.zero?(params)
5+
params = params[:page] || params['page'] || {}
6+
size = params[:size] || params['size']
7+
[0, '0'].include?(size)
8+
end
9+
end
10+
end
11+
end

lib/jsonapi_compliable/util/scoping.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ module Util
33
class Scoping
44
def self.apply?(controller, object, force)
55
return false if force == false
6-
return true if !controller._jsonapi_scoped && object.is_a?(ActiveRecord::Relation)
6+
return true if controller._jsonapi_scope.nil? && object.is_a?(ActiveRecord::Relation)
77

8-
already_scoped = !!controller._jsonapi_scoped
8+
already_scoped = !!controller._jsonapi_scope
99
is_activerecord = object.is_a?(ActiveRecord::Base)
1010
is_activerecord_array = object.is_a?(Array) && object[0].is_a?(ActiveRecord::Base)
1111

spec/dsl_spec.rb

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@
4646
expect(copy.pagination).to eq(instance.pagination)
4747
expect(copy.pagination.object_id).to_not eq(instance.pagination.object_id)
4848
end
49+
50+
it 'copies stats' do
51+
instance.stats = { foo: 'bar' }
52+
expect(copy.stats).to eq(foo: 'bar')
53+
expect(copy.stats.object_id).to_not eq(instance.stats.object_id)
54+
end
4955
end
5056

5157
describe '#clear' do
@@ -54,6 +60,7 @@
5460
instance.filters = { foo: 'bar' }
5561
instance.default_filters = { foo: 'bar' }
5662
instance.extra_fields = { foo: 'bar' }
63+
instance.stats = { foo: 'bar' }
5764
instance.sorting = 'a'
5865
instance.pagination = 'a'
5966
end
@@ -93,5 +100,47 @@
93100
instance.clear!
94101
}.to change { instance.pagination }.to(nil)
95102
end
103+
104+
it 'resets stats' do
105+
expect {
106+
instance.clear!
107+
}.to change { instance.stats }.to({})
108+
end
109+
end
110+
111+
describe '#stat' do
112+
let(:avg_proc) { proc { |scope, attr| 1 } }
113+
114+
before do
115+
dsl = JsonapiCompliable::Stats::DSL.new(:myattr)
116+
dsl.average(&avg_proc)
117+
instance.stats = { myattr: dsl }
118+
end
119+
120+
context 'when passing strings' do
121+
it 'returns the corresponding proc' do
122+
expect(instance.stat('myattr', 'average')).to eq(avg_proc)
123+
end
124+
end
125+
126+
context 'when passing symbols' do
127+
it 'returns the corresponding proc' do
128+
expect(instance.stat(:myattr, :average)).to eq(avg_proc)
129+
end
130+
end
131+
132+
context 'when no corresponding attribute' do
133+
it 'raises error' do
134+
expect { instance.stat(:foo, 'average') }
135+
.to raise_error(JsonapiCompliable::Errors::StatNotFound, "No stat configured for calculation 'average' on attribute :foo")
136+
end
137+
end
138+
139+
context 'when no corresponding calculation' do
140+
it 'raises error' do
141+
expect { instance.stat('myattr', :median) }
142+
.to raise_error(JsonapiCompliable::Errors::StatNotFound, "No stat configured for calculation :median on attribute :myattr")
143+
end
144+
end
96145
end
97146
end

spec/jsonapi_compliable_spec.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,11 @@ def index
8585
end
8686

8787
it 'resets scope flag after action' do
88+
controller.instance_variable_set(:@_jsonapi_scope, 'a')
8889
expect {
8990
get :index
90-
}.to change { controller.instance_variable_get(:@_jsonapi_scoped) }
91-
.from(nil).to(false)
91+
}.to change { controller.instance_variable_get(:@_jsonapi_scope) }
92+
.from('a').to(nil)
9293
end
9394

9495
context 'when passing scope: false' do

0 commit comments

Comments
 (0)