From 572b5c12e572a2232d15e92710097e4cc2bac034 Mon Sep 17 00:00:00 2001 From: Evan Rolfe Date: Fri, 29 May 2020 10:32:41 +0100 Subject: [PATCH] Allow stats to be nested_on resources --- lib/graphiti.rb | 1 + lib/graphiti/resource_proxy.rb | 12 ++++ lib/graphiti/serializer.rb | 10 +++ lib/graphiti/stats/dsl.rb | 4 +- lib/graphiti/stats/nested_payload.rb | 66 +++++++++++++++++++ lib/graphiti/stats/payload.rb | 3 + spec/boolean_attribute_spec.rb | 4 +- spec/fixtures/poro.rb | 3 +- spec/stats/payload_spec.rb | 3 + spec/stats/resource_with_nested_stats_spec.rb | 58 ++++++++++++++++ spec/stats_spec.rb | 4 +- 11 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 lib/graphiti/stats/nested_payload.rb create mode 100644 spec/stats/resource_with_nested_stats_spec.rb diff --git a/lib/graphiti.rb b/lib/graphiti.rb index fd3971aa..e38b8792 100644 --- a/lib/graphiti.rb +++ b/lib/graphiti.rb @@ -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" diff --git a/lib/graphiti/resource_proxy.rb b/lib/graphiti/resource_proxy.rb index ba69f4c0..70057cb0 100644 --- a/lib/graphiti/resource_proxy.rb +++ b/lib/graphiti/resource_proxy.rb @@ -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 diff --git a/lib/graphiti/serializer.rb b/lib/graphiti/serializer.rb index 987e699b..a6335fcb 100644 --- a/lib/graphiti/serializer.rb +++ b/lib/graphiti/serializer.rb @@ -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 @@ -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 || {} diff --git a/lib/graphiti/stats/dsl.rb b/lib/graphiti/stats/dsl.rb index f2a6e2a5..3e018056 100644 --- a/lib/graphiti/stats/dsl.rb +++ b/lib/graphiti/stats/dsl.rb @@ -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] }+ @@ -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 diff --git a/lib/graphiti/stats/nested_payload.rb b/lib/graphiti/stats/nested_payload.rb new file mode 100644 index 00000000..efd4853e --- /dev/null +++ b/lib/graphiti/stats/nested_payload.rb @@ -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 diff --git a/lib/graphiti/stats/payload.rb b/lib/graphiti/stats/payload.rb index fe215f2e..123646a3 100644 --- a/lib/graphiti/stats/payload.rb +++ b/lib/graphiti/stats/payload.rb @@ -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| diff --git a/spec/boolean_attribute_spec.rb b/spec/boolean_attribute_spec.rb index 6620bbde..ca364847 100644 --- a/spec/boolean_attribute_spec.rb +++ b/spec/boolean_attribute_spec.rb @@ -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) } diff --git a/spec/fixtures/poro.rb b/spec/fixtures/poro.rb index 1a294824..b855c873 100644 --- a/spec/fixtures/poro.rb +++ b/spec/fixtures/poro.rb @@ -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) diff --git a/spec/stats/payload_spec.rb b/spec/stats/payload_spec.rb index a52fb73d..6c987ae8 100644 --- a/spec/stats/payload_spec.rb +++ b/spec/stats/payload_spec.rb @@ -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 diff --git a/spec/stats/resource_with_nested_stats_spec.rb b/spec/stats/resource_with_nested_stats_spec.rb new file mode 100644 index 00000000..314deae8 --- /dev/null +++ b/spec/stats/resource_with_nested_stats_spec.rb @@ -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 diff --git a/spec/stats_spec.rb b/spec/stats_spec.rb index e4e71d2b..1057cee7 100644 --- a/spec/stats_spec.rb +++ b/spec/stats_spec.rb @@ -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 @@ -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