Skip to content
Draft
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 app/models/persisted_query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ def self.inherited(subclass)
subclass.serialize :filters, coder: Queries::Serialization::Filters.new(subclass)
subclass.serialize :orders, coder: Queries::Serialization::Orders.new(subclass)
subclass.serialize :selects, coder: Queries::Serialization::Selects.new(subclass)
subclass.serialize :group_bys, coder: Queries::Serialization::GroupBys.new(subclass)
end

def self.register_query(&)
Expand Down
55 changes: 37 additions & 18 deletions app/models/queries/base_query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ module Queries::BaseQuery

validate :filters_valid,
:sortation_valid
validate :group_by_valid, if: -> { respond_to?(:group_by) }
validate :group_bys_valid, if: -> { respond_to?(:group_bys) }
end

class_methods do
Expand Down Expand Up @@ -72,15 +72,20 @@ def results
end

def groups
return nil if group_by.nil?
return nil if group_bys.empty?
return empty_scope unless valid?

apply_group_by(apply_filters(default_scope))
.select(group_by.name, Arel.sql("COUNT(*)"))
.select(*group_by_names, Arel.sql("COUNT(*)"))
end

# Keys are the plain value when grouping by a single attribute, and an array
# of values - one per group by - when grouping by several.
def group_values
groups_hash = groups.pluck(group_by.name, Arel.sql("COUNT(*)")).to_h
groups_hash = groups.pluck(*group_by_names, Arel.sql("COUNT(*)")).to_h do |*values, count|
[group_bys.one? ? values.first : values, count]
end

instantiate_group_keys groups_hash
end

Expand Down Expand Up @@ -123,8 +128,8 @@ def order(hash)
self
end

def group(attribute)
self.group_by = group_by_for(attribute)
def group(*attributes)
self.group_bys = attributes.map { |attribute| group_by_for(attribute) }

self
end
Expand Down Expand Up @@ -163,10 +168,12 @@ def sortation_valid
end
end

def group_by_valid
return if group_by.nil? || group_by.valid?
def group_bys_valid
group_bys.each do |group_by|
next if group_by.valid?

add_error(:group_by, group_by.name, group_by)
add_error(:group_by, group_by.name, group_by)
end
end

def add_error(local_attribute, attribute_name, object)
Expand Down Expand Up @@ -207,30 +214,42 @@ def apply_orders(query_scope)
end

def apply_group_by(query_scope)
return query_scope if group_by.nil?
return query_scope if group_bys.empty?

group_by.apply_to(query_scope)
.order(group_by.name)
group_bys
.inject(query_scope) { |scope, group_by| group_by.apply_to(scope) }
.order(*group_by_names)
end

def group_by_names
group_bys.map(&:name)
end

def build_orders
return orders if !respond_to?(:group_by) || group_by.nil? || has_group_by_order?
return orders unless respond_to?(:group_bys)

group_by_orders + orders
end

[group_by_order] + orders
def group_by_orders
group_bys
.reject { |group_by| ordered_by?(group_by) }
.map { |group_by| group_by_order(group_by) }
end

def has_group_by_order?
!!group_by && orders.detect { |order| order.class.key == group_by.order_key }
def ordered_by?(group_by)
orders.any? { |order| order.class.key == group_by.order_key }
end

def group_by_order
def group_by_order(group_by)
order_for(group_by.order_key).tap do |order|
order.direction = :asc
end
end

def instantiate_group_keys(groups)
return groups unless group_by&.association_class
group_by = group_bys.first
return groups unless group_bys.one? && group_by.association_class

ar_keys = group_by.association_class.where(id: groups.keys.compact)

Expand Down
52 changes: 25 additions & 27 deletions app/models/queries/register.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,52 +31,50 @@
module Queries::Register
class << self
def filter(query, filter)
@filters ||= Hash.new do |hash, filter_key|
hash[filter_key] = []
end

@filters[query] << filter
filters[query] << filter
end

# Exclude filter from filters collection representer.
def exclude(filter)
@excluded_filters ||= []
@excluded_filters << filter
excluded_filters << filter
end

def order(query, order)
@orders ||= Hash.new do |hash, order_key|
hash[order_key] = []
end

@orders[query] << order
orders[query] << order
end

def group_by(query, group_by)
@group_bys ||= Hash.new do |hash, group_key|
hash[group_key] = []
end

@group_bys[query] << group_by
group_bys[query] << group_by
end

def select(query, select)
@selects ||= Hash.new do |hash, select_key|
hash[select_key] = []
end

@selects[query] << select
selects[query] << select
end

def register(query, &)
Registration.new(query).instance_exec(&)
end

attr_accessor :filters,
:excluded_filters,
:orders,
:selects,
:group_bys
# A query class registering none of a given kind is normal - most notably
# group_bys, which only a handful of queries declare - so these must return
# an empty registry rather than nil.
def filters = @filters ||= registry
def orders = @orders ||= registry
def selects = @selects ||= registry
def group_bys = @group_bys ||= registry
def excluded_filters = @excluded_filters ||= []

attr_writer :filters,
:excluded_filters,
:orders,
:selects,
:group_bys

private

def registry
Hash.new { |hash, key| hash[key] = [] }
end
end

class Registration
Expand Down
55 changes: 55 additions & 0 deletions app/models/queries/serialization/group_bys.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# frozen_string_literal: true

# -- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
# ++

class Queries::Serialization::GroupBys
include Queries::GroupBys::AvailableGroupBys

def load(serialized_group_bys)
return [] if serialized_group_bys.nil?

serialized_group_bys.map do |group_by|
group_by_for(group_by.to_sym)
end
end

def dump(group_bys)
group_bys.map { |group_by| group_by.attribute.to_s }
end

def group_by_register
::Queries::Register.group_bys[klass]
end

def initialize(klass)
@klass = klass
end

attr_reader :klass
end
6 changes: 3 additions & 3 deletions app/models/queries/unpersisted_query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,18 @@ module Queries::UnpersistedQuery
included do
attr_accessor :filters,
:orders
attr_reader :group_by
attr_reader :group_bys

def initialize(*args)
@filters = []
@orders = []
@group_by = nil
@group_bys = []
@user = args.first[:user] if args&.first
end

protected

attr_accessor :user
attr_writer :group_by
attr_writer :group_bys
end
end
35 changes: 35 additions & 0 deletions db/migrate/20260814140153_add_group_bys_to_persisted_queries.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# frozen_string_literal: true

#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

class AddGroupBysToPersistedQueries < ActiveRecord::Migration[8.1]
def change
add_column :persisted_queries, :group_bys, :jsonb, default: []
end
end
Loading