Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow stats to be nested_on resources #239

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
3 changes: 2 additions & 1 deletion spec/boolean_attribute_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
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] }

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/resource_with_nested_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
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