From 023ccf2e865c55fca551ec9b2992d1ca54ab8b1b Mon Sep 17 00:00:00 2001 From: Adam Hegyi Date: Wed, 17 Sep 2025 15:21:42 +0200 Subject: [PATCH 1/2] WIP [ci skip] --- app/graphql/resolvers/aggregation_resolver.rb | 57 +++++++++++++++++++ .../types/aggregation/dimension_type.rb | 10 ++++ .../types/aggregation/measurement_type.rb | 9 +++ app/graphql/types/aggregation/row_type.rb | 12 ++++ app/graphql/types/aggregation/value_type.rb | 10 ++++ app/graphql/types/aggregation_type.rb | 7 +++ .../types/merge_request_connection_type.rb | 2 + 7 files changed, 107 insertions(+) create mode 100644 app/graphql/resolvers/aggregation_resolver.rb create mode 100644 app/graphql/types/aggregation/dimension_type.rb create mode 100644 app/graphql/types/aggregation/measurement_type.rb create mode 100644 app/graphql/types/aggregation/row_type.rb create mode 100644 app/graphql/types/aggregation/value_type.rb create mode 100644 app/graphql/types/aggregation_type.rb diff --git a/app/graphql/resolvers/aggregation_resolver.rb b/app/graphql/resolvers/aggregation_resolver.rb new file mode 100644 index 00000000000000..e57debc0311a8d --- /dev/null +++ b/app/graphql/resolvers/aggregation_resolver.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Resolvers + class AggregationResolver < BaseResolver + argument :dimensions, [Types::Aggregation::DimensionType], required: true + argument :measurements, [Types::Aggregation::MeasurementType], required: true + + def resolve(dimensions:, measurements:) + projections = build_projections(dimensions:, measurements:) + + variables = Array.new((dimensions.size + measurements.size)) do |i| + "c_#{i + 1}" + end + + # TODO: This will ignore all filters (testing only) + # a) Inherit the AR scope from the outer GraphQL query + # b) Build a completely new scope depending on the DB engine + result = MergeRequest + .select(*projections) + .group(Array.new(dimensions.size) { |i| (i + 1).to_s }) + .map { |m| m.attributes.slice(*variables).values } + + result = result.map do |row| + { + dimensions: row.first(dimensions.size).map { |v| { value: v.to_s } }, + values: row.last(measurements.size).map { |v| { value: v.to_s } } + } + end + + { rows: result } + end + + private + + def build_projections(dimensions:, measurements:) + index = 1 + projections = [] + dimensions.each do |dimension| + projections << if dimension.grain + "date_trunc('#{dimension.grain}', #{dimension.field}) AS c_#{index}" + else + "#{dimension.field} AS c_#{index}" + end + + index += 1 + end + + measurements.each do |m| + projections << ("count(*) as c_#{index}" if m.name == 'count') + + index += 1 + end + + projections + end + end +end diff --git a/app/graphql/types/aggregation/dimension_type.rb b/app/graphql/types/aggregation/dimension_type.rb new file mode 100644 index 00000000000000..f832af73f0672f --- /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 00000000000000..4786b9f7866727 --- /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 00000000000000..a880a84f5c2d6c --- /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 00000000000000..9c6fb644760abd --- /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 00000000000000..da829fbd11b541 --- /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 abe00c9b2a31b2..9007d3a28caa37 100644 --- a/app/graphql/types/merge_request_connection_type.rb +++ b/app/graphql/types/merge_request_connection_type.rb @@ -8,6 +8,8 @@ class MergeRequestConnectionType < Types::CountableConnectionType null: true, description: 'Total sum of time to merge, in seconds, for the collection of merge requests.' + field :aggregation, Types::AggregationType, null: true, resolver: ::Resolvers::AggregationResolver + def total_time_to_merge object.items.without_order.total_time_to_merge end -- GitLab From 05eeb8f1395cc58dc4b3b9976622bbab9cdc20a2 Mon Sep 17 00:00:00 2001 From: Adam Hegyi Date: Wed, 17 Sep 2025 15:22:28 +0200 Subject: [PATCH 2/2] WIP [ci skip] --- app/graphql/resolvers/aggregation_resolver.rb | 57 ------- .../types/merge_request_connection_type.rb | 156 +++++++++++++++++- 2 files changed, 154 insertions(+), 59 deletions(-) delete mode 100644 app/graphql/resolvers/aggregation_resolver.rb diff --git a/app/graphql/resolvers/aggregation_resolver.rb b/app/graphql/resolvers/aggregation_resolver.rb deleted file mode 100644 index e57debc0311a8d..00000000000000 --- a/app/graphql/resolvers/aggregation_resolver.rb +++ /dev/null @@ -1,57 +0,0 @@ -# frozen_string_literal: true - -module Resolvers - class AggregationResolver < BaseResolver - argument :dimensions, [Types::Aggregation::DimensionType], required: true - argument :measurements, [Types::Aggregation::MeasurementType], required: true - - def resolve(dimensions:, measurements:) - projections = build_projections(dimensions:, measurements:) - - variables = Array.new((dimensions.size + measurements.size)) do |i| - "c_#{i + 1}" - end - - # TODO: This will ignore all filters (testing only) - # a) Inherit the AR scope from the outer GraphQL query - # b) Build a completely new scope depending on the DB engine - result = MergeRequest - .select(*projections) - .group(Array.new(dimensions.size) { |i| (i + 1).to_s }) - .map { |m| m.attributes.slice(*variables).values } - - result = result.map do |row| - { - dimensions: row.first(dimensions.size).map { |v| { value: v.to_s } }, - values: row.last(measurements.size).map { |v| { value: v.to_s } } - } - end - - { rows: result } - end - - private - - def build_projections(dimensions:, measurements:) - index = 1 - projections = [] - dimensions.each do |dimension| - projections << if dimension.grain - "date_trunc('#{dimension.grain}', #{dimension.field}) AS c_#{index}" - else - "#{dimension.field} AS c_#{index}" - end - - index += 1 - end - - measurements.each do |m| - projections << ("count(*) as c_#{index}" if m.name == 'count') - - index += 1 - end - - projections - end - end -end diff --git a/app/graphql/types/merge_request_connection_type.rb b/app/graphql/types/merge_request_connection_type.rb index 9007d3a28caa37..b44e3173f4f204 100644 --- a/app/graphql/types/merge_request_connection_type.rb +++ b/app/graphql/types/merge_request_connection_type.rb @@ -8,10 +8,162 @@ class MergeRequestConnectionType < Types::CountableConnectionType null: true, description: 'Total sum of time to merge, in seconds, for the collection of merge requests.' - field :aggregation, Types::AggregationType, null: true, resolver: ::Resolvers::AggregationResolver - 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 -- GitLab