group_hierarchy.rb 5.0 KB
Newer Older
1 2 3 4 5
module Gitlab
  # Retrieving of parent or child groups based on a base ActiveRecord relation.
  #
  # This class uses recursive CTEs and as a result will only work on PostgreSQL.
  class GroupHierarchy
6 7 8 9 10 11 12
    attr_reader :ancestors_base, :descendants_base, :model

    # ancestors_base - An instance of ActiveRecord::Relation for which to
    #                  get parent groups.
    # descendants_base - An instance of ActiveRecord::Relation for which to
    #                    get child groups. If omitted, ancestors_base is used.
    def initialize(ancestors_base, descendants_base = ancestors_base)
13
      raise ArgumentError.new("Model of ancestors_base does not match model of descendants_base") if ancestors_base.model != descendants_base.model
14 15 16 17

      @ancestors_base = ancestors_base
      @descendants_base = descendants_base
      @model = ancestors_base.model
18 19
    end

20 21
    # Returns the set of descendants of a given relation, but excluding the given
    # relation
22
    # rubocop: disable CodeReuse/ActiveRecord
23 24 25
    def descendants
      base_and_descendants.where.not(id: descendants_base.select(:id))
    end
26
    # rubocop: enable CodeReuse/ActiveRecord
27 28 29 30 31 32 33

    # Returns the set of ancestors of a given relation, but excluding the given
    # relation
    #
    # Passing an `upto` will stop the recursion once the specified parent_id is
    # reached. So all ancestors *lower* than the specified ancestor will be
    # included.
34
    # rubocop: disable CodeReuse/ActiveRecord
35
    def ancestors(upto: nil)
36
      base_and_ancestors(upto: upto).where.not(id: ancestors_base.select(:id))
37
    end
38
    # rubocop: enable CodeReuse/ActiveRecord
39

40 41
    # Returns a relation that includes the ancestors_base set of groups
    # and all their ancestors (recursively).
42 43 44 45 46
    #
    # Passing an `upto` will stop the recursion once the specified parent_id is
    # reached. So all ancestors *lower* than the specified acestor will be
    # included.
    def base_and_ancestors(upto: nil)
47
      return ancestors_base unless Group.supports_nested_groups?
48

49
      read_only(base_and_ancestors_cte(upto).apply_to(model.all))
50 51
    end

52 53
    # Returns a relation that includes the descendants_base set of groups
    # and all their descendants (recursively).
54
    def base_and_descendants
55
      return descendants_base unless Group.supports_nested_groups?
56

57
      read_only(base_and_descendants_cte.apply_to(model.all))
58 59
    end

60 61
    # Returns a relation that includes the base groups, their ancestors,
    # and the descendants of the base groups.
62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79
    #
    # The resulting query will roughly look like the following:
    #
    #     WITH RECURSIVE ancestors AS ( ... ),
    #       descendants AS ( ... )
    #     SELECT *
    #     FROM (
    #       SELECT *
    #       FROM ancestors namespaces
    #
    #       UNION
    #
    #       SELECT *
    #       FROM descendants namespaces
    #     ) groups;
    #
    # Using this approach allows us to further add criteria to the relation with
    # Rails thinking it's selecting data the usual way.
80 81
    #
    # If nested groups are not supported, ancestors_base is returned.
82
    # rubocop: disable CodeReuse/ActiveRecord
83
    def all_groups
84
      return ancestors_base unless Group.supports_nested_groups?
85

86 87 88 89 90 91
      ancestors = base_and_ancestors_cte
      descendants = base_and_descendants_cte

      ancestors_table = ancestors.alias_to(groups_table)
      descendants_table = descendants.alias_to(groups_table)

92
      relation = model
93 94 95
        .unscoped
        .with
        .recursive(ancestors.to_arel, descendants.to_arel)
96 97 98 99
        .from_union([
          model.unscoped.from(ancestors_table),
          model.unscoped.from(descendants_table)
        ])
100 101

      read_only(relation)
102
    end
103
    # rubocop: enable CodeReuse/ActiveRecord
104 105 106

    private

107
    # rubocop: disable CodeReuse/ActiveRecord
108
    def base_and_ancestors_cte(stop_id = nil)
109 110
      cte = SQL::RecursiveCTE.new(:base_and_ancestors)

111
      cte << ancestors_base.except(:order)
112 113

      # Recursively get all the ancestors of the base set.
114
      parent_query = model
115 116 117
        .from([groups_table, cte.table])
        .where(groups_table[:id].eq(cte.table[:parent_id]))
        .except(:order)
118
      parent_query = parent_query.where(cte.table[:parent_id].not_eq(stop_id)) if stop_id
119

120
      cte << parent_query
121 122
      cte
    end
123
    # rubocop: enable CodeReuse/ActiveRecord
124

125
    # rubocop: disable CodeReuse/ActiveRecord
126 127 128
    def base_and_descendants_cte
      cte = SQL::RecursiveCTE.new(:base_and_descendants)

129
      cte << descendants_base.except(:order)
130 131

      # Recursively get all the descendants of the base set.
132 133 134 135
      cte << model
        .from([groups_table, cte.table])
        .where(groups_table[:parent_id].eq(cte.table[:id]))
        .except(:order)
136 137 138

      cte
    end
139
    # rubocop: enable CodeReuse/ActiveRecord
140 141 142 143

    def groups_table
      model.arel_table
    end
144 145 146 147 148 149 150

    def read_only(relation)
      # relations using a CTE are not safe to use with update_all as it will
      # throw away the CTE, hence we mark them as read-only.
      relation.extend(Gitlab::Database::ReadOnlyRelation)
      relation
    end
151 152
  end
end