Replace data loading with new Reports::QueryInterface

This commit is contained in:
Matt-Yorkley
2021-08-26 19:35:03 +01:00
parent 72212fd718
commit 924f6568d6
29 changed files with 587 additions and 180 deletions

View File

@@ -44,7 +44,7 @@ module Admin
def load_form_options
return unless form_options_required?
form_options = Reports::FrontendData.new(spree_current_user)
form_options = Reporting::FrontendData.new(spree_current_user)
@distributors = form_options.distributors.to_a
@suppliers = form_options.suppliers.to_a

View File

@@ -5,7 +5,7 @@ module Api
class ReportsController < Api::V0::BaseController
include ReportsActions
rescue_from Reports::Errors::Base, with: :render_error
rescue_from ::Reporting::Errors::Base, with: :render_error
before_action :validate_report, :authorize_report, :validate_query
@@ -18,7 +18,7 @@ module Api
private
def render_report
render json: @report.as_hashes
render json: @report.as_json
end
def render_error(error)
@@ -26,12 +26,12 @@ module Api
end
def validate_report
raise Reports::Errors::NoReportType if report_type.blank?
raise Reports::Errors::ReportNotFound if report_class.blank?
raise ::Reporting::Errors::NoReportType if report_type.blank?
raise ::Reporting::Errors::ReportNotFound if report_class.blank?
end
def validate_query
raise Reports::Errors::MissingQueryParams if ransack_params.blank?
raise ::Reporting::Errors::MissingQueryParams if ransack_params.blank?
end
end
end

View File

@@ -16,7 +16,7 @@ module ReportsActions
end
def report_loader
@report_loader ||= Reports::ReportLoader.new(report_type, report_subtype)
@report_loader ||= ::Reporting::ReportLoader.new(report_type, report_subtype)
end
def report_type

View File

@@ -198,7 +198,7 @@ module Spree
end
def load_associated_data
form_options = Reports::FrontendData.new(spree_current_user)
form_options = Reporting::FrontendData.new(spree_current_user)
@distributors = form_options.distributors
@suppliers = form_options.suppliers

View File

@@ -10,6 +10,6 @@ module ReportsHelper
end
def report_subtypes(report)
Reports::ReportLoader.new(report).report_subtypes
Reporting::ReportLoader.new(report).report_subtypes
end
end

View File

@@ -5,6 +5,9 @@ class ApplicationRecord < ActiveRecord::Base
include Spree::Core::Permalinks
include Spree::Preferences::Preferable
include Searchable
include ArelHelpers::ArelTable
include ArelHelpers::Aliases
include ArelHelpers::JoinAssociation
self.abstract_class = true
end

View File

@@ -3,6 +3,6 @@
%br
= select_tag :report_format, options_for_select({t('.on_screen') => '', t('.csv_spreadsheet') => 'csv', t('.excel_spreadsheet') => 'xlsx', t('.openoffice_spreadsheet') => 'ods'})
.inline-checkbox
= check_box_tag "options[exclude_summaries]", true, params[:options].andand[:exclude_summaries]
= label_tag t(".hide_summary_rows")
-#.inline-checkbox
-# = check_box_tag "options[exclude_summaries]", true, params[:options].andand[:exclude_summaries]
-# = label_tag t(".hide_summary_rows")

View File

@@ -153,9 +153,9 @@ module Openfoodnetwork
)
initializer "ofn.reports" do |_app|
module ::Reports; end
module ::Reporting; end
loader = Zeitwerk::Loader.new
loader.push_dir("#{Rails.root}/lib/reports", namespace: ::Reports)
loader.push_dir("#{Rails.root}/lib/reporting", namespace: ::Reporting)
loader.setup
loader.eager_load
end

View File

@@ -1213,6 +1213,7 @@ en:
variant: "Variant"
quantity: "Quantity"
is_temperature_controlled: "TempControlled?"
temp_controlled: "TempControlled?"
rendering_options:
generate_report: "Generate report:"
on_screen: "On screen"

View File

@@ -1,6 +1,6 @@
# frozen_string_literal: true
module Reports
module Reporting
module Errors
class Base < StandardError
def i18n_error_scope

View File

@@ -1,6 +1,6 @@
# frozen_string_literal: true
module Reports
module Reporting
class FrontendData
def initialize(current_user)
@current_user = current_user

View File

@@ -0,0 +1,52 @@
# frozen_string_literal: true
module Reporting
module Queries
module Joins
def joins_order
reflect query.join(association(Spree::LineItem, :order))
end
def joins_order_distributor
reflect query.join(association(Spree::Order, :distributor, distributor_alias))
end
def joins_variant
reflect query.join(association(Spree::LineItem, :variant))
end
def joins_variant_product
reflect query.join(association(Spree::Variant, :product))
end
def joins_product_supplier
reflect query.join(association(Spree::Product, :supplier, supplier_alias))
end
def joins_product_shipping_category
reflect query.join(association(Spree::Product, :shipping_category))
end
def joins_order_and_distributor
reflect query.
join(association(Spree::LineItem, :order)).
join(association(Spree::Order, :distributor, distributor_alias))
end
def joins_order_customer
reflect query.join(association(Spree::Order, :customer))
end
def joins_order_bill_address
reflect query.join(association(Spree::Order, :bill_address, bill_address_alias))
end
def join_line_item_option_values
reflect query.
join(association(Spree::LineItem, :option_values)).
join(association(Spree::OptionValuesLineItem, :option_value)
)
end
end
end
end

View File

@@ -0,0 +1,88 @@
# frozen_string_literal: true
module Reporting
module Queries
class QueryBuilder < QueryInterface
include Joins
include Tables
attr_reader :grouping_fields
def initialize(model, grouping_fields = [])
@grouping_fields = instance_exec(&grouping_fields)
super model.arel_table
end
def selecting(lambda)
fields = instance_exec(&lambda).map{ |key, value| value.public_send(:as, key.to_s) }
reflect query.project(*fields)
end
def scoped_to_orders(orders_relation)
reflect query.where(
line_item_table[:order_id].in(Arel.sql(orders_relation.to_sql))
)
end
def with_managed_orders(orders_relation)
reflect query.
outer_join(managed_orders_alias).
on(
managed_orders_alias[:id].eq(line_item_table[:order_id]).
and(managed_orders_alias[:distributor_id].in(Arel.sql(orders_relation.to_sql)))
)
end
def grouped_in_sets(group_sets)
reflect query.group(*instance_exec(&group_sets))
end
def ordered_by(ordering_fields)
reflect query.order(*instance_exec(&ordering_fields))
end
def masked(field, message = nil, mask_rule = nil)
Case.new.
when(mask_rule || default_mask_rule).
then(field).
else(quoted(message || I18n.t("hidden_field", scope: i18n_scope)))
end
def distinct_results(fields = nil)
return reflect query.distinct if fields.blank?
reflect query.distinct_on(fields)
end
def variant_full_name
display_name = variant_table[:display_name]
display_as = variant_table[:display_as]
options_text = option_value_table[:presentation]
unit_to_display = coalesce(nullify_empty_strings(display_as), options_text)
combined_description = sql_concat(display_name, raw("' ('"), unit_to_display, raw("')'"))
Case.new.
when(nullify_empty_strings(display_name).eq(nil)).then(unit_to_display).
when(nullify_empty_strings(unit_to_display).not_eq(nil)).then(combined_description).
else(display_name)
end
private
def default_mask_rule
line_item_table[:order_id].in(raw("#{managed_orders_alias.name}.id"))
end
def summary_row_title
I18n.t("total_items", scope: i18n_scope)
end
def i18n_scope
"admin.reports"
end
end
end
end

View File

@@ -0,0 +1,125 @@
# frozen_string_literal: true
require "arel-helpers"
module Reporting
module Queries
class QueryInterface < ::ArelHelpers::QueryBuilder
include Arel::Nodes
def coalesce(field, default = 0)
NamedFunction.new("COALESCE", [field, default])
end
def sum_values(field, default = 0)
NamedFunction.new("SUM", [coalesce(field, default)])
end
def sum_grouped(field, default = 0)
Case.new(sql_grouping(grouping_fields)).when(0).then(field.maximum).else(field.sum)
end
def sum_new(field, default = 0)
Case.new(sql_grouping(grouping_fields)).when(0).then(field.maximum).else(sum_values(field))
end
def round(field, places: 2)
NamedFunction.new("ROUND", [field, places])
end
def association(base_class, association, alias_node = nil, join_type = InnerJoin)
options = alias_node.present? ? { aliases: [alias_node] } : {}
Arel.sql(base_class.join_association(association, join_type, options).first.to_sql)
end
def arel_join(join)
Arel.sql(join.first.to_sql)
end
def join_source(join_association)
join_association[0].right
end
def default_value(field)
field.maximum
end
def default_blank(field)
Case.new(sql_grouping(grouping_fields)).when(0).then(field.maximum).else(empty_string)
end
def default_string(field, string)
Case.new(sql_grouping(grouping_fields)).when(0).then(field.maximum).else(quoted(string))
end
def default_summary(field)
Case.new(sql_grouping(grouping_fields)).when(0).then(field.maximum).else(empty_string)
end
def boolean_blank(field, true_string = I18n.t(:yes), false_string = I18n.t(:no))
Case.new(sql_grouping(grouping_fields)).when(0).
then(pretty_boolean(field, true_string, false_string).maximum).
else(empty_string)
end
def pretty_boolean(field, true_string, false_string)
Case.new(field).when(true).
then(Arel.sql("'#{true_string}'")).
else(Arel.sql("'#{false_string}'"))
end
def cast(field, type)
NamedFunction.new("CAST", [field.as(type)])
end
def null_if(field, nullif)
NamedFunction.new("NULLIF", [field, nullif])
end
def parenthesise(args)
Grouping.new(args)
end
def nullify_empty_strings(field)
null_if(field, empty_string)
end
def empty_string
raw("''")
end
def sql_concat(*args)
NamedFunction.new("CONCAT", args)
end
def raw(string)
SqlLiteral.new(string)
end
def quoted(string)
Quoted.new(string)
end
def sql_grouping(groupings = grouping_fields)
NamedFunction.new("GROUPING", [groupings])
end
def grouping_sets(groupings)
GroupingSet.new(groupings)
end
def sql_case(expression)
Case.new(expression)
end
def rollup(groupings)
RollUp.new(groupings)
end
def raw_result
ActiveRecord::Base.connection.exec_query(query.to_sql)
end
end
end
end

View File

@@ -0,0 +1,51 @@
# frozen_string_literal: true
module Reporting
module Queries
module Tables
def order_table
Spree::Order.arel_table
end
def line_item_table
Spree::LineItem.arel_table
end
def product_table
Spree::Product.arel_table
end
def variant_table
Spree::Variant.arel_table
end
def customer_table
::Customer.arel_table
end
def distributor_alias
Enterprise.arel_table.alias(:order_distributor)
end
def supplier_alias
Enterprise.arel_table.alias(:product_supplier)
end
def bill_address_alias
Spree::Address.arel_table.alias(:bill_address)
end
def managed_orders_alias
Spree::Order.arel_table.alias(:managed_orders)
end
def option_value_table
Spree::OptionValue.arel_table
end
def shipping_category_table
Spree::ShippingCategory.arel_table
end
end
end
end

View File

@@ -1,6 +1,6 @@
# frozen_string_literal: true
module Reports
module Reporting
class ReportLoader
delegate :report_subtypes, to: :base_class
@@ -12,7 +12,7 @@ module Reports
def report_class
"#{report_module}::#{report_subtype_class}".constantize
rescue NameError
raise Reports::Errors::ReportNotFound
raise Reporting::Errors::ReportNotFound
end
def default_report_subtype
@@ -24,7 +24,7 @@ module Reports
attr_reader :report_type, :report_subtype
def report_module
"Reports::#{report_type.camelize}"
"Reporting::Reports::#{report_type.camelize}"
end
def report_subtype_class
@@ -34,7 +34,7 @@ module Reports
def base_class
"#{report_module}::Base".constantize
rescue NameError
raise Reports::Errors::ReportNotFound
raise Reporting::Errors::ReportNotFound
end
end
end

View File

@@ -0,0 +1,39 @@
# frozen_string_literal: true
require 'spreadsheet_architect'
module Reporting
class ReportRenderer
def initialize(report)
@report = report
end
def table_headers
@report.report_data.columns
end
def table_rows
@report.report_data.rows
end
def as_json
@report.report_data.as_json
end
def as_arrays
@as_arrays ||= [table_headers] + table_rows
end
def to_csv
SpreadsheetArchitect.to_csv(headers: table_headers, data: table_rows)
end
def to_ods
SpreadsheetArchitect.to_ods(headers: table_headers, data: table_rows)
end
def to_xlsx
SpreadsheetArchitect.to_xlsx(headers: table_headers, data: table_rows)
end
end
end

View File

@@ -0,0 +1,52 @@
# frozen_string_literal: true
module Reporting
class ReportTemplate
delegate :as_json, :as_arrays, :table_headers, :table_rows,
:to_csv, :to_xlsx, :to_ods, :to_json, to: :renderer
attr_reader :options
SUBTYPES = []
def self.report_subtypes
self::SUBTYPES
end
def initialize(current_user, ransack_params, options = {})
@current_user = current_user
@ransack_params = ransack_params.with_indifferent_access
@options = ( options || {} ).with_indifferent_access
end
def report_data
@report_data ||= report_query.raw_result
end
private
attr_reader :current_user, :ransack_params
def renderer
@renderer ||= ReportRenderer.new(self)
end
def scoped_orders_relation
visible_orders_relation.ransack(ransack_params).result
end
def visible_orders_relation
::Permissions::Order.new(current_user).
visible_orders.complete.not_state(:canceled).
select(:id).distinct
end
def managed_orders_relation
::Enterprise.managed_by(current_user).select(:id).distinct
end
def i18n_scope
"admin.reports"
end
end
end

View File

@@ -0,0 +1,41 @@
# frozen_string_literal: true
module Reporting
module Reports
module Packing
class Base < ReportTemplate
SUBTYPES = ["customer", "supplier"]
def primary_model
Spree::LineItem
end
def report_query
Queries::QueryBuilder.new(primary_model, grouping_fields).
scoped_to_orders(scoped_orders_relation).
with_managed_orders(managed_orders_relation).
joins_order_and_distributor.
joins_order_customer.
joins_order_bill_address.
joins_variant.
joins_variant_product.
joins_product_supplier.
joins_product_shipping_category.
join_line_item_option_values.
selecting(select_fields).
grouped_in_sets(group_sets).
ordered_by(ordering_fields)
end
def grouping_fields
lambda do
[
order_table[:id],
line_item_table[:id]
]
end
end
end
end
end
end

View File

@@ -0,0 +1,49 @@
# frozen_string_literal: true
module Reporting
module Reports
module Packing
class Customer < Base
def select_fields
lambda do
{
hub: default_blank(distributor_alias[:name]),
customer_code: default_blank(masked(customer_table[:code])),
first_name: default_blank(masked(bill_address_alias[:firstname])),
last_name: default_blank(masked(bill_address_alias[:lastname])),
supplier: default_blank(supplier_alias[:name]),
product: default_string(product_table[:name], summary_row_title),
variant: default_blank(variant_full_name),
quantity: sum_values(line_item_table[:quantity]),
temp_controlled: boolean_blank(shipping_category_table[:temperature_controlled]),
}
end
end
def ordering_fields
lambda do
[
distributor_alias[:name],
bill_address_alias[:lastname],
order_table[:id],
sql_grouping(grouping_fields),
Arel.sql("supplier"),
Arel.sql("product"),
Arel.sql("variant"),
]
end
end
def group_sets
lambda do
[
distributor_alias[:name],
bill_address_alias[:lastname],
grouping_sets([parenthesise(order_table[:id]), parenthesise(grouping_fields)])
]
end
end
end
end
end
end

View File

@@ -0,0 +1,48 @@
# frozen_string_literal: true
module Reporting
module Reports
module Packing
class Supplier < Base
def select_fields
lambda do
{
hub: default_blank(distributor_alias[:name]),
supplier: default_blank(supplier_alias[:name]),
customer_code: default_blank(customer_table[:code]),
first_name: default_blank(bill_address_alias[:firstname]),
last_name: default_blank(bill_address_alias[:lastname]),
product: default_string(product_table[:name], summary_row_title),
variant: default_blank(variant_full_name),
quantity: sum_values(line_item_table[:quantity]),
temp_controlled: boolean_blank(shipping_category_table[:temperature_controlled]),
}
end
end
def group_sets
lambda do
[
distributor_alias[:name],
supplier_alias[:name],
grouping_sets([parenthesise(supplier_alias[:name]), parenthesise(grouping_fields)])
]
end
end
def ordering_fields
lambda do
[
distributor_alias[:name],
supplier_alias[:name],
sql_grouping(grouping_fields),
Arel.sql("product"),
Arel.sql("variant"),
Arel.sql("supplier"),
]
end
end
end
end
end
end

View File

@@ -1,17 +0,0 @@
# frozen_string_literal: true
module Reports
module Packing
class Base < ReportTemplate
SUBTYPES = ["customer", "supplier"]
private
def i18n_scope
"admin.reports"
end
end
end
end

View File

@@ -1,9 +0,0 @@
# frozen_string_literal: true
module Reports
module Packing
class Customer < Base
end
end
end

View File

@@ -1,9 +0,0 @@
# frozen_string_literal: true
module Reports
module Packing
class Supplier < Base
end
end
end

View File

@@ -1,67 +0,0 @@
# frozen_string_literal: true
require 'spreadsheet_architect'
module Reports
class ReportRenderer
def initialize(report)
@report = report
end
def table_headers
as_arrays.first
end
def table_rows
as_arrays.drop(1)
end
def as_hashes
report_rows
end
def as_arrays
@as_arrays ||= rows_as_arrays
end
def to_csv
SpreadsheetArchitect.to_csv(headers: table_headers, data: table_rows)
end
def to_ods
SpreadsheetArchitect.to_ods(headers: table_headers, data: table_rows)
end
def to_xlsx
SpreadsheetArchitect.to_xlsx(headers: table_headers, data: table_rows)
end
private
def report_rows
@report.report_rows
end
def rows_as_arrays
report_array = [header_row]
report_rows.each do |row|
report_array << row_or_summary(row)
end
report_array
end
def header_row
report_rows.first.keys - [:summary_row_title]
end
def row_or_summary(row)
summary_row_title = row.delete :summary_row_title
row_values = row.values
row_values[0] = summary_row_title if summary_row_title
row_values
end
end
end

View File

@@ -1,42 +0,0 @@
# frozen_string_literal: true
module Reports
class ReportTemplate
delegate :as_hashes, :as_arrays, :table_headers, :table_rows,
:to_csv, :to_xlsx, :to_ods, :to_json, to: :report_renderer
attr_reader :options
attr_accessor :report_rows
SUBTYPES = []
def self.report_subtypes
self::SUBTYPES
end
def initialize(current_user, ransack_params, options = {})
@current_user = current_user
@ransack_params = ransack_params.with_indifferent_access
@options = ( options || {} ).with_indifferent_access
@report_rows = []
build_report
end
def headers
report_rows.first&.keys || []
end
private
attr_reader :current_user, :ransack_params
def build_report
# TODO
end
def report_renderer
@report_renderer ||= ReportRenderer.new(self)
end
end
end

View File

@@ -59,7 +59,7 @@ describe "Packing Reports" do
it "shows line items supplied by my producers, with names hidden" do
expect(subject.collection).to eq([line_item2])
expect(subject.as_hashes.first[:first_name]).to eq(
expect(subject.as_json.first[:first_name]).to eq(
I18n.t('admin.reports.hidden_field')
)
end

View File

@@ -2,17 +2,19 @@
require 'spec_helper'
module Reports
module Bananas
class Base; end
class Green; end
class Yellow; end
module Reporting
module Reports
module Bananas
class Base; end
class Green; end
class Yellow; end
end
end
end
describe Reports::ReportLoader do
let(:service) { Reports::ReportLoader.new(*arguments) }
let(:report_base_class) { Reports::Bananas::Base }
describe Reporting::ReportLoader do
let(:service) { Reporting::ReportLoader.new(*arguments) }
let(:report_base_class) { Reporting::Reports::Bananas::Base }
let(:report_subtypes) { ["green", "yellow"] }
before do
@@ -24,7 +26,7 @@ describe Reports::ReportLoader do
let(:arguments) { ["bananas", "yellow"] }
it "returns a report class when given type and subtype" do
expect(service.report_class).to eq Reports::Bananas::Yellow
expect(service.report_class).to eq Reporting::Reports::Bananas::Yellow
end
end
@@ -33,7 +35,7 @@ describe Reports::ReportLoader do
let(:arguments) { ["bananas"] }
it "returns first listed report type" do
expect(service.report_class).to eq Reports::Bananas::Green
expect(service.report_class).to eq Reporting::Reports::Bananas::Green
end
end
@@ -42,7 +44,7 @@ describe Reports::ReportLoader do
let(:report_subtypes) { [] }
it "returns base class" do
expect(service.report_class).to eq Reports::Bananas::Base
expect(service.report_class).to eq Reporting::Reports::Bananas::Base
end
end
@@ -51,7 +53,7 @@ describe Reports::ReportLoader do
let(:report_subtypes) { [] }
it "raises an error" do
expect{ service.report_class }.to raise_error(Reports::Errors::ReportNotFound)
expect{ service.report_class }.to raise_error(Reporting::Errors::ReportNotFound)
end
end
end
@@ -80,7 +82,7 @@ describe Reports::ReportLoader do
let(:report_subtypes) { [] }
it "raises an error" do
expect{ service.report_class }.to raise_error(Reports::Errors::ReportNotFound)
expect{ service.report_class }.to raise_error(Reporting::Errors::ReportNotFound)
end
end
end

View File

@@ -27,9 +27,9 @@ describe Reports::ReportRenderer do
end
end
describe "#as_hashes" do
describe "#as_json" do
it "returns the report's data as hashes" do
expect(service.as_hashes).to eq report_rows
expect(service.as_json).to eq report_rows
end
end