Project

General

Profile

Feature #7655 » 0001-Add-group-by-assignees-group-for-issue-queries.patch

Ryoh HAMADA, 2026-10-19 12:56

View differences:

app/models/issue_query.rb
17 17
# along with this program; if not, write to the Free Software
18 18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19 19

  
20
class QueryAssignedToGroupColumn < QueryColumn
21
  include Redmine::I18n
22

  
23
  def initialize
24
    super(:assigned_to_group, :caption => :field_member_of_group)
25
  end
26

  
27
  def groupable?
28
    true
29
  end
30

  
31
  def sortable
32
    group_by_statement
33
  end
34

  
35
  def group_by_statement
36
    gu_table = "#{User.table_name_prefix}groups_users#{User.table_name_suffix}"
37
    "COALESCE(" \
38
      "(SELECT MIN(gu.group_id) FROM #{gu_table} gu" \
39
        " WHERE gu.user_id = #{Issue.table_name}.assigned_to_id)," \
40
      "(SELECT g.id FROM #{User.table_name} g" \
41
        " WHERE g.id = #{Issue.table_name}.assigned_to_id AND g.type = 'Group')" \
42
    ")"
43
  end
44

  
45
  def group_value(issue)
46
    return nil if issue.assigned_to.nil?
47

  
48
    if issue.assigned_to.is_a?(Group)
49
      issue.assigned_to
50
    else
51
      # order(:id) matches MIN(group_id) used in group_by_statement
52
      issue.assigned_to.groups.givable.order(:id).first
53
    end
54
  end
55

  
56
  def value(issue)
57
    group_value(issue)
58
  end
59

  
60
  def cast_group_keys(result)
61
    group_ids = result.keys.compact
62
    return result if group_ids.empty?
63

  
64
    groups = Group.where(:id => group_ids).index_by(&:id)
65
    result.transform_keys {|k| k.nil? ? nil : groups[k.to_i]}
66
  end
67
end
68

  
20 69
class IssueQuery < Query
21 70
  self.queried_class = Issue
22 71
  self.view_permission = :view_issues
......
41 90
    QueryColumn.new(:assigned_to,
42 91
                    :sortable => lambda {User.fields_for_order_statement},
43 92
                    :groupable => true),
93
    QueryAssignedToGroupColumn.new,
44 94
    WatcherQueryColumn.new(:watcher_users, :caption => :label_issue_watchers),
45 95
    TimestampQueryColumn.new(:updated_on, :sortable => "#{Issue.table_name}.updated_on",
46 96
                             :default_order => 'desc', :groupable => true),
app/models/query.rb
1117 1117
      c = group_by_column
1118 1118
      if c.is_a?(QueryCustomFieldColumn)
1119 1119
        r = r.keys.to_h { |k| [c.custom_field.cast_value(k), r[k]] }
1120
      elsif c.respond_to?(:cast_group_keys)
1121
        r = c.cast_group_keys(r)
1120 1122
      end
1121 1123
    end
1122 1124
    r
test/unit/query_test.rb
3487 3487
    assigned_to_values = query.assigned_to_values
3488 3488
    assert_equal expected_names, assigned_to_values[1..].map(&:first)
3489 3489
  end
3490

  
3491
  # Tests for QueryAssignedToGroupColumn / group-by "Assignee's group" (issue #7655)
3492
  def setup_assigned_to_group
3493
    Group.destroy_all
3494
    @user_in_group_a  = User.generate!
3495
    @user_in_group_b  = User.generate!
3496
    @user_in_both     = User.generate!  # belongs to both groups
3497
    @user_no_group    = User.generate!
3498

  
3499
    @group_a = Group.generate!.reload
3500
    @group_a.users << @user_in_group_a
3501
    @group_a.users << @user_in_both
3502

  
3503
    @group_b = Group.generate!.reload
3504
    @group_b.users << @user_in_group_b
3505
    @group_b.users << @user_in_both
3506

  
3507
    Issue.delete_all
3508
    @issue_group_a     = Issue.generate!(:assigned_to => @user_in_group_a)
3509
    @issue_group_b     = Issue.generate!(:assigned_to => @user_in_group_b)
3510
    @issue_both_groups = Issue.generate!(:assigned_to => @user_in_both)
3511
    @issue_no_group    = Issue.generate!(:assigned_to => @user_no_group)
3512
    @issue_unassigned  = Issue.generate!
3513
    @issue_direct_grp  = Issue.generate!(:assigned_to => @group_a)
3514
  end
3515

  
3516
  test "assigned_to_group column should be included in groupable_columns" do
3517
    q = IssueQuery.new
3518
    col = q.groupable_columns.detect {|c| c.name == :assigned_to_group}
3519
    assert_not_nil col, "assigned_to_group should be in groupable_columns"
3520
    assert_kind_of QueryAssignedToGroupColumn, col
3521
  end
3522

  
3523
  test "grouped? should be true when group_by is assigned_to_group" do
3524
    q = IssueQuery.new(:group_by => 'assigned_to_group')
3525
    assert q.grouped?
3526
    assert_not_nil q.group_by_column
3527
    assert_equal :assigned_to_group, q.group_by_column.name
3528
    assert_not_nil q.group_by_statement
3529
  end
3530

  
3531
  test "group_by_statement should be a valid SQL expression" do
3532
    q = IssueQuery.new(:group_by => 'assigned_to_group')
3533
    stmt = q.group_by_statement
3534
    assert_match(/COALESCE/i, stmt)
3535
    assert_match(/groups_users/, stmt)
3536
  end
3537

  
3538
  test "result_count_by_group should return Group objects as keys" do
3539
    setup_assigned_to_group
3540
    q = IssueQuery.new(:name => '_', :group_by => 'assigned_to_group')
3541

  
3542
    counts = q.result_count_by_group
3543
    assert_not_nil counts
3544

  
3545
    counts.each_key do |k|
3546
      assert(k.nil? || k.is_a?(Group),
3547
             "Expected nil or Group key, got #{k.class}: #{k.inspect}")
3548
    end
3549
  end
3550

  
3551
  test "result_count_by_group correctly counts issues per group" do
3552
    setup_assigned_to_group
3553
    q = IssueQuery.new(:name => '_', :group_by => 'assigned_to_group')
3554
    counts = q.result_count_by_group
3555

  
3556
    # nil bucket: @issue_no_group + @issue_unassigned
3557
    assert_equal 2, counts[nil]
3558
    # 4 issues are assigned to grouped users/groups; split between group_a and group_b
3559
    assert_equal 4, (counts[@group_a] || 0) + (counts[@group_b] || 0)
3560
    # @issue_group_a (user only in A) + @issue_direct_grp (A directly) are always in group_a
3561
    assert (counts[@group_a] || 0) >= 2, "group_a must include at least issue_group_a and issue_direct_grp"
3562
    # @issue_group_b (user only in B) is always in group_b
3563
    assert (counts[@group_b] || 0) >= 1, "group_b must include at least issue_group_b"
3564
    # @issue_both_groups goes to whichever group has the lower id (MIN)
3565
    # — exact split depends on auto-increment ordering so we only verify the sum
3566
  end
3567

  
3568
  test "group_value should return the Group for a user-assigned issue" do
3569
    setup_assigned_to_group
3570
    col = QueryAssignedToGroupColumn.new
3571
    result = col.group_value(@issue_group_a)
3572
    assert_kind_of Group, result
3573
    assert_equal @group_a, result
3574
  end
3575

  
3576
  test "group_value should return the Group itself for a directly-group-assigned issue" do
3577
    setup_assigned_to_group
3578
    col = QueryAssignedToGroupColumn.new
3579
    assert_equal @group_a, col.group_value(@issue_direct_grp)
3580
  end
3581

  
3582
  test "group_value should return nil for unassigned issue" do
3583
    setup_assigned_to_group
3584
    col = QueryAssignedToGroupColumn.new
3585
    assert_nil col.group_value(@issue_unassigned)
3586
  end
3587

  
3588
  test "group_value should return nil for user not in any group" do
3589
    setup_assigned_to_group
3590
    col = QueryAssignedToGroupColumn.new
3591
    assert_nil col.group_value(@issue_no_group)
3592
  end
3593

  
3594
  test "issues method should not raise when grouped by assigned_to_group" do
3595
    setup_assigned_to_group
3596
    q = IssueQuery.new(:name => '_', :group_by => 'assigned_to_group')
3597
    assert_nothing_raised { q.issues }
3598
  end
3599

  
3600
  test "issues should be ordered so same-group issues are adjacent" do
3601
    setup_assigned_to_group
3602
    q = IssueQuery.new(:name => '_', :group_by => 'assigned_to_group')
3603
    col = q.group_by_column
3604

  
3605
    issues = q.issues
3606
    groups = issues.map {|i| col.group_value(i)}
3607

  
3608
    seen = []
3609
    groups.each do |g|
3610
      if seen.empty? || seen.last != g
3611
        assert_not_include seen[0..-2], g,
3612
          "Group #{g.inspect} appeared non-adjacently in the ordered issue list"
3613
        seen << g
3614
      end
3615
    end
3616
  end
3617

  
3618
  test "total_by_group_for should work with assigned_to_group" do
3619
    setup_assigned_to_group
3620
    @issue_group_a.update_column(:estimated_hours, 3.0)
3621
    @issue_group_b.update_column(:estimated_hours, 5.0)
3622

  
3623
    q = IssueQuery.new(:name => '_', :group_by => 'assigned_to_group')
3624
    totals = q.total_by_group_for(:estimated_hours)
3625

  
3626
    assert_not_nil totals
3627
    totals.each_key do |k|
3628
      assert(k.nil? || k.is_a?(Group),
3629
             "total_by_group_for keys must be nil or Group, got #{k.class}")
3630
    end
3631
  end
3632

  
3633
  test "QueryAssignedToGroupColumn cast_group_keys converts integer keys to Group objects" do
3634
    setup_assigned_to_group
3635
    col = QueryAssignedToGroupColumn.new
3636

  
3637
    raw = {@group_a.id => 3, @group_b.id => 2, nil => 1}
3638
    converted = col.cast_group_keys(raw)
3639

  
3640
    assert_equal 3, converted[@group_a]
3641
    assert_equal 2, converted[@group_b]
3642
    assert_equal 1, converted[nil]
3643
    converted.each_key do |k|
3644
      assert(k.nil? || k.is_a?(Group),
3645
             "cast_group_keys should not leave integer keys, got #{k.class}")
3646
    end
3647
  end
3648

  
3649
  test "QueryAssignedToGroupColumn cast_group_keys handles empty result" do
3650
    col = QueryAssignedToGroupColumn.new
3651
    assert_equal({}, col.cast_group_keys({}))
3652
  end
3653

  
3654
  test "QueryAssignedToGroupColumn cast_group_keys handles all-nil keys" do
3655
    col = QueryAssignedToGroupColumn.new
3656
    result = col.cast_group_keys({nil => 5})
3657
    assert_equal({nil => 5}, result)
3658
  end
3490 3659
end
(1-1/3)