218 lines
		
	
	
		
			7.7 KiB
		
	
	
	
		
			Ruby
		
	
	
	
			
		
		
	
	
			218 lines
		
	
	
		
			7.7 KiB
		
	
	
	
		
			Ruby
		
	
	
	
| # frozen_string_literal: true
 | |
| 
 | |
| module Gitlab
 | |
|   # Retrieving of parent or child objects based on a base ActiveRecord relation.
 | |
|   #
 | |
|   # This class uses recursive CTEs and as a result will only work on PostgreSQL.
 | |
|   class ObjectHierarchy
 | |
|     DEPTH_COLUMN = :depth
 | |
| 
 | |
|     attr_reader :ancestors_base, :descendants_base, :model
 | |
| 
 | |
|     # ancestors_base - An instance of ActiveRecord::Relation for which to
 | |
|     #                  get parent objects.
 | |
|     # descendants_base - An instance of ActiveRecord::Relation for which to
 | |
|     #                    get child objects. If omitted, ancestors_base is used.
 | |
|     def initialize(ancestors_base, descendants_base = ancestors_base)
 | |
|       raise ArgumentError.new("Model of ancestors_base does not match model of descendants_base") if ancestors_base.model != descendants_base.model
 | |
| 
 | |
|       @ancestors_base = ancestors_base
 | |
|       @descendants_base = descendants_base
 | |
|       @model = ancestors_base.model
 | |
|     end
 | |
| 
 | |
|     # Returns the set of descendants of a given relation, but excluding the given
 | |
|     # relation
 | |
|     # rubocop: disable CodeReuse/ActiveRecord
 | |
|     def descendants
 | |
|       base_and_descendants.where.not(id: descendants_base.select(:id))
 | |
|     end
 | |
|     # rubocop: enable CodeReuse/ActiveRecord
 | |
| 
 | |
|     # Returns the maximum depth starting from the base
 | |
|     # A base object with no children has a maximum depth of `1`
 | |
|     def max_descendants_depth
 | |
|       unless hierarchy_supported?
 | |
|         # This makes the return value consistent with the case where hierarchy is supported
 | |
|         return descendants_base.exists? ? 1 : nil
 | |
|       end
 | |
| 
 | |
|       base_and_descendants(with_depth: true).maximum(DEPTH_COLUMN)
 | |
|     end
 | |
| 
 | |
|     # 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.
 | |
|     # rubocop: disable CodeReuse/ActiveRecord
 | |
|     def ancestors(upto: nil, hierarchy_order: nil)
 | |
|       base_and_ancestors(upto: upto, hierarchy_order: hierarchy_order).where.not(id: ancestors_base.select(:id))
 | |
|     end
 | |
|     # rubocop: enable CodeReuse/ActiveRecord
 | |
| 
 | |
|     # Returns a relation that includes the ancestors_base set of objects
 | |
|     # and all their ancestors (recursively).
 | |
|     #
 | |
|     # 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.
 | |
|     #
 | |
|     # Passing a `hierarchy_order` with either `:asc` or `:desc` will cause the
 | |
|     # recursive query order from most nested object to root or from the root
 | |
|     # ancestor to most nested object respectively. This uses a `depth` column
 | |
|     # where `1` is defined as the depth for the base and increment as we go up
 | |
|     # each parent.
 | |
|     # rubocop: disable CodeReuse/ActiveRecord
 | |
|     def base_and_ancestors(upto: nil, hierarchy_order: nil)
 | |
|       return ancestors_base unless hierarchy_supported?
 | |
| 
 | |
|       recursive_query = base_and_ancestors_cte(upto, hierarchy_order).apply_to(model.all)
 | |
|       recursive_query = recursive_query.order(depth: hierarchy_order) if hierarchy_order
 | |
| 
 | |
|       read_only(recursive_query)
 | |
|     end
 | |
|     # rubocop: enable CodeReuse/ActiveRecord
 | |
| 
 | |
|     # Returns a relation that includes the descendants_base set of objects
 | |
|     # and all their descendants (recursively).
 | |
|     #
 | |
|     # When `with_depth` is `true`, a `depth` column is included where it starts with `1` for the base objects
 | |
|     # and incremented as we go down the descendant tree
 | |
|     def base_and_descendants(with_depth: false)
 | |
|       unless hierarchy_supported?
 | |
|         return with_depth ? descendants_base.select("1 as #{DEPTH_COLUMN}", objects_table[Arel.star]) : descendants_base
 | |
|       end
 | |
| 
 | |
|       read_only(base_and_descendants_cte(with_depth: with_depth).apply_to(model.all))
 | |
|     end
 | |
| 
 | |
|     # Returns a relation that includes the base objects, their ancestors,
 | |
|     # and the descendants of the base objects.
 | |
|     #
 | |
|     # 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.
 | |
|     #
 | |
|     # If nested objects are not supported, ancestors_base is returned.
 | |
|     # rubocop: disable CodeReuse/ActiveRecord
 | |
|     def all_objects
 | |
|       return ancestors_base unless hierarchy_supported?
 | |
| 
 | |
|       ancestors = base_and_ancestors_cte
 | |
|       descendants = base_and_descendants_cte
 | |
| 
 | |
|       ancestors_table = ancestors.alias_to(objects_table)
 | |
|       descendants_table = descendants.alias_to(objects_table)
 | |
| 
 | |
|       relation = model
 | |
|         .unscoped
 | |
|         .with
 | |
|         .recursive(ancestors.to_arel, descendants.to_arel)
 | |
|         .from_union([
 | |
|           model.unscoped.from(ancestors_table),
 | |
|           model.unscoped.from(descendants_table)
 | |
|         ])
 | |
| 
 | |
|       read_only(relation)
 | |
|     end
 | |
|     # rubocop: enable CodeReuse/ActiveRecord
 | |
| 
 | |
|     private
 | |
| 
 | |
|     def hierarchy_supported?
 | |
|       Gitlab::Database.postgresql?
 | |
|     end
 | |
| 
 | |
|     # rubocop: disable CodeReuse/ActiveRecord
 | |
|     def base_and_ancestors_cte(stop_id = nil, hierarchy_order = nil)
 | |
|       cte = SQL::RecursiveCTE.new(:base_and_ancestors)
 | |
| 
 | |
|       base_query = ancestors_base.except(:order)
 | |
|       base_query = base_query.select("1 as #{DEPTH_COLUMN}", "ARRAY[id] AS tree_path", "false AS tree_cycle", objects_table[Arel.star]) if hierarchy_order
 | |
| 
 | |
|       cte << base_query
 | |
| 
 | |
|       # Recursively get all the ancestors of the base set.
 | |
|       parent_query = model
 | |
|         .from([objects_table, cte.table])
 | |
|         .where(objects_table[:id].eq(cte.table[:parent_id]))
 | |
|         .except(:order)
 | |
| 
 | |
|       if hierarchy_order
 | |
|         quoted_objects_table_name = model.connection.quote_table_name(objects_table.name)
 | |
| 
 | |
|         parent_query = parent_query.select(
 | |
|           cte.table[DEPTH_COLUMN] + 1,
 | |
|           "tree_path || #{quoted_objects_table_name}.id",
 | |
|           "#{quoted_objects_table_name}.id = ANY(tree_path)",
 | |
|           objects_table[Arel.star]
 | |
|         ).where(cte.table[:tree_cycle].eq(false))
 | |
|       end
 | |
| 
 | |
|       parent_query = parent_query.where(cte.table[:parent_id].not_eq(stop_id)) if stop_id
 | |
| 
 | |
|       cte << parent_query
 | |
|       cte
 | |
|     end
 | |
|     # rubocop: enable CodeReuse/ActiveRecord
 | |
| 
 | |
|     # rubocop: disable CodeReuse/ActiveRecord
 | |
|     def base_and_descendants_cte(with_depth: false)
 | |
|       cte = SQL::RecursiveCTE.new(:base_and_descendants)
 | |
| 
 | |
|       base_query = descendants_base.except(:order)
 | |
|       base_query = base_query.select("1 AS #{DEPTH_COLUMN}", "ARRAY[id] AS tree_path", "false AS tree_cycle", objects_table[Arel.star]) if with_depth
 | |
| 
 | |
|       cte << base_query
 | |
| 
 | |
|       # Recursively get all the descendants of the base set.
 | |
|       descendants_query = model
 | |
|         .from([objects_table, cte.table])
 | |
|         .where(objects_table[:parent_id].eq(cte.table[:id]))
 | |
|         .except(:order)
 | |
| 
 | |
|       if with_depth
 | |
|         quoted_objects_table_name = model.connection.quote_table_name(objects_table.name)
 | |
| 
 | |
|         descendants_query = descendants_query.select(
 | |
|           cte.table[DEPTH_COLUMN] + 1,
 | |
|           "tree_path || #{quoted_objects_table_name}.id",
 | |
|           "#{quoted_objects_table_name}.id = ANY(tree_path)",
 | |
|           objects_table[Arel.star]
 | |
|         ).where(cte.table[:tree_cycle].eq(false))
 | |
|       end
 | |
| 
 | |
|       cte << descendants_query
 | |
|       cte
 | |
|     end
 | |
|     # rubocop: enable CodeReuse/ActiveRecord
 | |
| 
 | |
|     def objects_table
 | |
|       model.arel_table
 | |
|     end
 | |
| 
 | |
|     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
 | |
|   end
 | |
| end
 |