# frozen_string_literal: true require 'securerandom' module ActsAsRecursiveTree module Builders # # Constructs the Arel necessary for recursion. # class RelationBuilder def self.build(klass, ids, exclude_ids: false, &block) new(klass, ids, exclude_ids:, &block).build end class_attribute :traversal_strategy, instance_writer: false attr_reader :klass, :ids, :without_ids # Delegators for easier accessing config and query options delegate :primary_key, :depth_column, :parent_key, :parent_type_column, to: :config delegate :depth_present?, :depth, :condition, :ensure_ordering, to: :@query_opts def initialize(klass, ids, exclude_ids: false, &block) @klass = klass @ids = ActsAsRecursiveTree::Options::Values.create(ids, klass._recursive_tree_config) @without_ids = exclude_ids @query_opts = get_query_options(&block) # random seed for the temp tables @rand_int = SecureRandom.rand(1_000_000) end def recursive_temp_table @recursive_temp_table ||= Arel::Table.new("recursive_#{klass.table_name}_#{@rand_int}_temp") end def travers_loc_table @travers_loc_table ||= Arel::Table.new("traverse_#{@rand_int}_loc") end def config klass._recursive_tree_config end # # Constructs a new QueryOptions and yield it to the proc if one is present. # Subclasses may override this method to provide sane defaults. # # @return [ActsAsRecursiveTree::Options::QueryOptions] the new QueryOptions instance def get_query_options(&) ActsAsRecursiveTree::Options::QueryOptions.from(&) end def base_table klass.arel_table end def build relation = Strategies.for_query_options(@query_opts).build(self) apply_except_id(relation) end def apply_except_id(relation) return relation unless without_ids relation.where(ids.apply_negated_to(base_table[primary_key])) end def apply_depth(select_manager) return select_manager unless depth_present? select_manager.where(depth.apply_to(travers_loc_table[depth_column])) end def create_select_manger(column = nil) projections = column ? travers_loc_table[column] : Arel.star select_mgr = travers_loc_table.project(projections).with(:recursive, build_cte_table) apply_depth(select_mgr) end def build_cte_table Arel::Nodes::As.new( travers_loc_table, add_pg_cycle_detection( build_base_select.union(build_union_select) ) ) end def add_pg_cycle_detection(union_query) return union_query unless config.cycle_detection? Arel::Nodes::InfixOperation.new( '', union_query, Arel.sql("CYCLE #{primary_key} SET is_cycle USING path") ) end # Builds SQL: # SELECT id, parent_id, 0 AS depth FROM base_table WHERE id = 123 def build_base_select id_node = base_table[primary_key] base_table.where( ids.apply_to(id_node) ).project( id_node, base_table[parent_key], Arel.sql('0').as(depth_column.to_s) ) end def build_union_select join_condition = apply_parent_type_column( traversal_strategy.build(self) ) select_manager = base_table.join(travers_loc_table).on(join_condition) # need to use ActiveRecord here for merging relation relation = build_base_join_select(select_manager) relation = apply_query_opts_condition(relation) relation.arel end def apply_parent_type_column(arel_condition) return arel_condition if parent_type_column.blank? arel_condition.and(base_table[parent_type_column].eq(klass.base_class)) end def build_base_join_select(select_manager) klass.select( base_table[primary_key], base_table[parent_key], Arel.sql( (travers_loc_table[depth_column] + 1).to_sql ).as(depth_column.to_s) ).unscope(where: :type).joins(select_manager.join_sources) end def apply_query_opts_condition(relation) # check with nil? and not #present?/#blank? which will execute the query return relation if condition.nil? relation.merge(condition) end end end end