diff --git a/app/graphql/types/aggregation/dimension_type.rb b/app/graphql/types/aggregation/dimension_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..f832af73f0672f6e7036857b8c3b61155ea76bc0 --- /dev/null +++ b/app/graphql/types/aggregation/dimension_type.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Types + module Aggregation + class DimensionType < BaseInputObject + argument :field, String, required: true + argument :grain, String, required: false + end + end +end diff --git a/app/graphql/types/aggregation/measurement_type.rb b/app/graphql/types/aggregation/measurement_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..4786b9f78667277397116969af60bbcfc14d5ae0 --- /dev/null +++ b/app/graphql/types/aggregation/measurement_type.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Types + module Aggregation + class MeasurementType < BaseInputObject + argument :name, String, required: true + end + end +end diff --git a/app/graphql/types/aggregation/row_type.rb b/app/graphql/types/aggregation/row_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..a880a84f5c2d6cc026006c9e0f843d4a84510e27 --- /dev/null +++ b/app/graphql/types/aggregation/row_type.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# rubocop:disable Graphql/AuthorizeTypes + +module Types + module Aggregation + class RowType < Types::BaseObject + field :dimensions, [Types::Aggregation::ValueType] + field :values, [Types::Aggregation::ValueType] + end + end +end diff --git a/app/graphql/types/aggregation/value_type.rb b/app/graphql/types/aggregation/value_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..9c6fb644760abd944d568f322a24f49b5017516b --- /dev/null +++ b/app/graphql/types/aggregation/value_type.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Types + module Aggregation + class ValueType < Types::BaseObject + field :type, String + field :value, String + end + end +end diff --git a/app/graphql/types/aggregation_type.rb b/app/graphql/types/aggregation_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..da829fbd11b541b43d2df757009d06a39c509ce4 --- /dev/null +++ b/app/graphql/types/aggregation_type.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Types + class AggregationType < BaseObject + field :rows, [Types::Aggregation::RowType] + end +end diff --git a/app/graphql/types/merge_request_connection_type.rb b/app/graphql/types/merge_request_connection_type.rb index abe00c9b2a31b26fc0e8af8549b8101231e7f28b..b44e3173f4f2047f08e1eb0bbe3a2905d9c952ad 100644 --- a/app/graphql/types/merge_request_connection_type.rb +++ b/app/graphql/types/merge_request_connection_type.rb @@ -11,5 +11,159 @@ class MergeRequestConnectionType < Types::CountableConnectionType def total_time_to_merge object.items.without_order.total_time_to_merge end + + def aggregation + AggregationContract.new(object) + end + + class ActiveRecordAggregationEngine + attr_reader :identifier, :configured_dimensions, :configured_measurements + + def initialize(identifier:, dimensions:, measurements:) + @identifier = identifier + @configured_dimensions = dimensions + @configured_measurements = measurements + end + + def prepare_scope(scope) + scope = scope.spawn.without_order + scope.preload_values = [] + scope.includes_values = [] + scope.eager_load_values = [] + @scope = scope + end + + def execute_query(object, given_dimensions:, given_measurements:) + scope = prepare_scope(object.items).joins(:metrics) + + measurements = given_measurements.filter_map do |measurement| + mes = measurement.name.to_sym + configured_measurements.find { |d| d.identifier == mes } + end + + projections = [] + dimension_columns = [] + measurement_columns = [] + index = 0 + + given_dimensions.each do |dimension| + dim = dimension.field.to_sym + next unless configured_dimension = configured_dimensions.find { |d| d.field == dim } + + column = "dimension_#{index}" + projections << "#{configured_dimension.to_sql(dimension)} AS #{column}" + dimension_columns << column + + index += 1 + end + + measurements.each do |measurement| + column = "measurement_#{index}" + projections << "#{measurement.to_sql} AS #{column}" + measurement_columns << column + index += 1 + end + + result = scope.select(*projections).group(*dimension_columns) + + result = result.map do |row| + { + dimensions: row.values_at(dimension_columns).map { |v| { value: v.to_s } }, + values: row.values_at(*measurement_columns).map { |v| { value: v.to_s } } + } + end + + { rows: result } + end + end + + class AggregationContract + def initialize(engines:) + @engines = engines.index_by(&:identifier).freeze + end + + def build_resolver + engines = @engines + Class.new(Resolvers::BaseResolver) do + argument :dimensions, [Types::Aggregation::DimensionType], required: true + argument :engine, String, default_value: 'active_record' + argument :measurements, [Types::Aggregation::MeasurementType], required: true + + define_method(:resolve) do |engine:, dimensions:, measurements:| + engine = engines[engine.to_sym] + + engine.execute_query(object, given_dimensions: dimensions, given_measurements: measurements) + end + end + end + end + + class DimensionColumn + attr_reader :field + + def initialize(field) + @field = field + end + + def to_sql(_dimension_object) + field.to_s + end + end + + class TimestampDimension + attr_reader :field + + def initialize(field, grains = []) + @field = field + @grains = grains + end + + def to_sql(dimension_object) + Arel::Nodes::NamedFunction.new('date_trunc', + [Arel.sql("'#{dimension_object.grain}'"), Arel.sql("merge_requests.#{field}")]).to_sql + end + end + + class CountMeasurement + attr_reader :identifier + + def initialize + @identifier = :count + end + + def to_sql + Arel.sql('COUNT(*)') + end + end + + class Measurement + attr_reader :identifier, :arel + + def initialize(identifier, arel) + @identifier = identifier + @arel = arel + end + + def to_sql + arel + end + end + + # Contract definition + engine = ActiveRecordAggregationEngine.new(identifier: :active_record, + dimensions: [ + DimensionColumn.new(:author_id), + DimensionColumn.new(:state_id), + DimensionColumn.new(:project_id), + TimestampDimension.new(:created_at, %i[week month year]) + ], + measurements: [ + CountMeasurement.new, + Measurement.new(:avg_duration, Arel.sql('AVG(FLOOR(EXTRACT(EPOCH FROM (merge_request_metrics.merged_at - merge_requests.created_at)))::bigint)')) + ] + ) + + + field :aggregation, Types::AggregationType, null: true, resolver: AggregationContract.new(engines: [engine]).build_resolver end end