Skip to content

Commit

Permalink
Allow stats to be nested_on resources
Browse files Browse the repository at this point in the history
  • Loading branch information
evanrolfe committed May 29, 2020
1 parent a5bbb67 commit b62ffce
Show file tree
Hide file tree
Showing 12 changed files with 223 additions and 5 deletions.
1 change: 1 addition & 0 deletions lib/graphiti.rb
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ def self.setup!
require "graphiti/scoping/filter"
require "graphiti/stats/dsl"
require "graphiti/stats/payload"
require "graphiti/stats/nested_payload"
require "graphiti/delegates/pagination"
require "graphiti/util/include_params"
require "graphiti/util/field_params"
Expand Down
12 changes: 12 additions & 0 deletions lib/graphiti/resource_proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,18 @@ def stats
end
end

def nested_stats
@nested_stats ||= if @query.hash[:stats]
payload = Stats::NestedPayload.new @resource,
@query,
@scope.unpaginated_object,
data
payload.generate
else
{}
end
end

def pagination
@pagination ||= Delegates::Pagination.new(self)
end
Expand Down
10 changes: 10 additions & 0 deletions lib/graphiti/serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ def as_jsonapi(*)
super.tap do |hash|
strip_relationships!(hash) if strip_relationships?
add_links!(hash)
add_meta!(hash)
end
end

Expand Down Expand Up @@ -62,6 +63,15 @@ def strip_relationships!(hash)
end
end

def add_meta!(hash)
return if @resource.try(:type).nil?

resource_stats = @_exposures[:proxy].nested_stats.fetch(@resource.type, {})
nested_stats = resource_stats[@object.id]

hash[:meta] = { stats: nested_stats } if nested_stats.present?
end

def strip_relationships?
return false unless Graphiti.config.links_on_demand
params = Graphiti.context[:object].params || {}
Expand Down
4 changes: 3 additions & 1 deletion lib/graphiti/stats/dsl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ module Stats
# @attr_reader [Symbol] name the stat, e.g. :total
# @attr_reader [Hash] calculations procs for various metrics
class DSL
attr_reader :name, :calculations
attr_reader :name, :calculations, :nested_on

# @param [Adapters::Abstract] adapter the Resource adapter
# @param [Symbol, Hash] config example: +:total+ or +{ total: [:count] }+
Expand All @@ -35,6 +35,8 @@ def initialize(adapter, config)
@adapter = adapter
@calculations = {}
@name = config.keys.first
@nested_on = config[:nested_on]

Array(config.values.first).each { |c| send(:"#{c}!") }
end

Expand Down
66 changes: 66 additions & 0 deletions lib/graphiti/stats/nested_payload.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
module Graphiti
module Stats
# Generate the nested stats payload so we can return it in the response for each record i.e.
#
# {
# data: [
# {
# id: "1",
# type: "employee",
# attributes: {},
# relationships: {},
# meta: { stats: { total: { count: 100 } } }
# }
# ],
# meta: {}
# }

class NestedPayload
def initialize(resource, query, scope, data)
@resource = resource
@query = query
@scope = scope
@data = data
end

# Generate the payload for +{ meta: { stats: { ... } } }+
# Loops over all calculations, computes then, and gives back
# a hash of stats and their results.
# @return [Hash] the generated payload
def generate
{}.tap do |stats|
@query.stats.each_pair do |name, calculation|
nested_on = @resource.stats[name].nested_on
next if nested_on.blank?

stats[nested_on] ||= {}

each_calculation(name, calculation) do |calc, function|
data_arr = (@data.is_a? Enumerable) ? @data : [@data]

data_arr.each do |object|
args = [@scope, name]
args << @resource.context if function.arity >= 3
args << object if function.arity == 4
result = function.call(*args)

stats[nested_on][object.id] ||= {}
stats[nested_on][object.id][name] ||= {}
stats[nested_on][object.id][name][calc] = result
end
end
end
end
end

private

def each_calculation(name, calculations)
calculations.each do |calc|
function = @resource.stat(name, calc)
yield calc, function
end
end
end
end
end
3 changes: 3 additions & 0 deletions lib/graphiti/stats/payload.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ def initialize(resource, query, scope, data)
def generate
{}.tap do |stats|
@query.stats.each_pair do |name, calculation|
nested_on = @resource.stats[name]&.nested_on
next if nested_on.present?

stats[name] = {}

each_calculation(name, calculation) do |calc, function|
Expand Down
4 changes: 3 additions & 1 deletion spec/boolean_attribute_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@
end

let(:author) { double(id: 1) }
let(:resource) { klass.new(object: author) }
let(:proxy) { double(nested_stats: {}) }
let(:resource) { klass.new(resource: double(type: 'klass'), object: author, proxy: proxy) }

subject { resource.as_jsonapi[:attributes] }

before do
allow(author).to receive(:celebrity?) { true }

end

it { is_expected.to eq(is_celebrity: true) }
Expand Down
3 changes: 2 additions & 1 deletion spec/fixtures/poro.rb
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,8 @@ def sum(scope, attr)
end

def average(scope, attr)
"poro_average_#{attr}"
items = ::PORO::DB.all(scope)
items.map(&attr).sum / items.count
end

def maximum(scope, attr)
Expand Down
3 changes: 3 additions & 0 deletions spec/stats/payload_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ def stub_stat(attr, calc, result)
stub_stat(:attr1, :count, 2)
stub_stat(:attr1, :average, 1)
stub_stat(:attr2, :maximum, 3)

stats_obj = double(nested_on: false)
allow(dsl).to receive(:stats).and_return({ attr1: stats_obj, attr2: stats_obj })
end

it "generates the correct payload for each requested stat" do
Expand Down
58 changes: 58 additions & 0 deletions spec/stats/per_resource_stats_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
require "spec_helper"

RSpec.describe 'A resource with nested stats' do
include_context "resource testing"

let!(:employee1) { PORO::Employee.create first_name: 'Alice', age: 25 }
let!(:employee2) { PORO::Employee.create first_name: 'Bob', age: 40 }

let!(:position1) { PORO::Position.create employee_id: employee1.id, rank: 4 }
let!(:position2) { PORO::Position.create employee_id: employee1.id, rank: 8 }
let!(:position3) { PORO::Position.create employee_id: employee2.id, rank: 10 }
let!(:position4) { PORO::Position.create employee_id: employee2.id, rank: 22 }

let(:state_group_count) { [{ id: 10, count: 3 }, { id: 11, count: 0 }] }

def jsonapi
JSON.parse(proxy.to_jsonapi)
end

describe "has_many" do
context "with include directive" do
let(:resource) do
Class.new(PORO::EmployeeResource) do
def self.name
"PORO::EmployeeResource"
end

has_many :positions

stat age: [:squared], nested_on: :employees do
squared do |scope, attr, context, employee|
employee.age * employee.age
end
end
end
end

before do
allow_any_instance_of(PORO::Employee).to receive(:applications_by_state_group_count).and_return(state_group_count)

params[:include] = "positions"
params[:stats] = {age: 'squared'}
render
end

it "includes the top-level stats" do
expect(jsonapi['meta']['stats']).to be_nil
end

it "includes the stats nested on employees" do
jsonapi['data'].each do |record|
expect(record['meta']['stats']).to_not be_nil
expect(record['meta']['stats']['age']).to_not be_nil
end
end
end
end
end
60 changes: 60 additions & 0 deletions spec/stats/resource_with_nested_stats_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
require "spec_helper"

RSpec.describe 'A resource with nested stats' do
include_context "resource testing"

let!(:employee1) { PORO::Employee.create first_name: 'Alice', age: 25 }
let!(:employee2) { PORO::Employee.create first_name: 'Bob', age: 40 }

let!(:position1) { PORO::Position.create employee_id: employee1.id, rank: 4 }
let!(:position2) { PORO::Position.create employee_id: employee1.id, rank: 8 }
let!(:position3) { PORO::Position.create employee_id: employee2.id, rank: 10 }
let!(:position4) { PORO::Position.create employee_id: employee2.id, rank: 22 }

let(:state_group_count) { [{ id: 10, count: 3 }, { id: 11, count: 0 }] }

def jsonapi
JSON.parse(proxy.to_jsonapi)
end

describe "has_many" do
context "with include directive" do
let(:resource) do
Class.new(PORO::EmployeeResource) do
def self.name
"PORO::EmployeeResource"
end

has_many :positions

stat age: [:average]

stat jobApplicationsByStateGroup: [:count], nested_on: :employees do
count do |scope, attr, context, employee|
employee.applications_by_state_group_count
end
end
end
end

before do
allow_any_instance_of(PORO::Employee).to receive(:applications_by_state_group_count).and_return(state_group_count)

params[:include] = "positions"
params[:stats] = {age: 'average', jobApplicationsByStateGroup: 'count' }
render
end

it "includes the top-level stats" do
expect(jsonapi['meta']['stats']).to_not be_nil
end

it "includes the stats nested on employees" do
jsonapi['data'].each do |record|
expect(record['meta']['stats']).to_not be_nil
expect(record['meta']['stats']['jobApplicationsByStateGroup']).to_not be_nil
end
end
end
end
end
4 changes: 2 additions & 2 deletions spec/stats_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
it "responds with average in meta stats" do
render
expect(json["meta"]["stats"])
.to eq({"age" => {"average" => "poro_average_age"}})
.to eq({"age" => {"average" => 0}})
end
end

Expand Down Expand Up @@ -190,7 +190,7 @@ def resolve(scope)
render
expect(json["meta"]["stats"]).to eq({
"total" => {"count" => "poro_count_total"},
"age" => {"sum" => "poro_sum_age", "average" => "poro_average_age"}
"age" => {"sum" => "poro_sum_age", "average" => 0}
})
end
end
Expand Down

0 comments on commit b62ffce

Please sign in to comment.