Skip to content

Commit

Permalink
Adding Postgres Cycle detection
Browse files Browse the repository at this point in the history
  • Loading branch information
wollistik committed Aug 11, 2023
1 parent 919daa7 commit 430438b
Show file tree
Hide file tree
Showing 7 changed files with 67 additions and 27 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
### NEXT
- Added :dependent option for setting explicit
- Added :dependent option for setting explicit deletion behaviour (issue #31)
- Added automatic cycle detection when supported (currently only PostgresSQL 14+) (issue #22)

### Version 3.4.0
- Rails 7.1 compatibility
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,15 @@ Instance Methods make no difference of the class from which they are called:
sub_node_instance.descendants # => returns Node and SubNode instances
```

## A note on endless recursion / cycle detection

### Inserting
As of now it is up to the user code to guarantee there will be no cycles created in the parent/child entries. If not, your DB might run into an endless recursion. Inserting/updating records that will cause a cycle is not prevented by some validation checks, so you have to do this by your own. This might change in a future version.

### Querying
If you want to make sure to not run into an endless recursion when querying, then there are following options:
1. Add a maximum depth to the query options. If an cycle is present in your data, the recursion will stop when reaching the max depth and stop further traversing.
2. When you are on recent version of PostgreSQL (14+) you are lucky. Postgres added the CYCLE detection feature to detect cycles and prevent endless recursion. Our query builder will add this feature if your DB does support this.

## Contributing

Expand Down
2 changes: 1 addition & 1 deletion lib/acts_as_recursive_tree/builders/ancestors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ module Builders
class Ancestors < RelationBuilder
self.traversal_strategy = ActsAsRecursiveTree::Builders::Strategies::Ancestor

def get_query_options(_)
def get_query_options(&block)
opts = super
opts.ensure_ordering!
opts
Expand Down
2 changes: 1 addition & 1 deletion lib/acts_as_recursive_tree/builders/leaves.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def create_select_manger(column = nil)
select_manager
end

def get_query_options(_)
def get_query_options(&_block)
# do not allow any custom options
ActsAsRecursiveTree::Options::QueryOptions.new
end
Expand Down
62 changes: 38 additions & 24 deletions lib/acts_as_recursive_tree/builders/relation_builder.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true

require 'securerandom'

module ActsAsRecursiveTree
module Builders
#
Expand All @@ -12,40 +14,42 @@ def self.build(klass, ids, exclude_ids: false, &block)

class_attribute :traversal_strategy, instance_writer: false

attr_reader :klass, :ids, :recursive_temp_table, :travers_loc_table, :without_ids

mattr_reader(:random) { Random.new }
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 :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
@config = klass._recursive_tree_config
@ids = ActsAsRecursiveTree::Options::Values.create(ids, @config)
@ids = ActsAsRecursiveTree::Options::Values.create(ids, klass._recursive_tree_config)
@without_ids = exclude_ids

@query_opts = get_query_options(block)
@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

rand_int = random.rand(1_000_000)
@recursive_temp_table = Arel::Table.new("recursive_#{klass.table_name}_#{rand_int}_temp")
@travers_loc_table = Arel::Table.new("traverse_#{rand_int}_loc")
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.
#
# @param proc [Proc] a proc or nil
#
# @return [ActsAsRecursiveTree::Options::QueryOptions] the new QueryOptions instance
def get_query_options(proc)
opts = ActsAsRecursiveTree::Options::QueryOptions.new

proc&.call(opts)

opts
def get_query_options(&block)
ActsAsRecursiveTree::Options::QueryOptions.from(&block)
end

def base_table
Expand All @@ -71,11 +75,7 @@ def apply_depth(select_manager)
end

def create_select_manger(column = nil)
projections = if column
travers_loc_table[column]
else
Arel.star
end
projections = column ? travers_loc_table[column] : Arel.star

select_mgr = travers_loc_table.project(projections).with(:recursive, build_cte_table)

Expand All @@ -85,10 +85,24 @@ def create_select_manger(column = nil)
def build_cte_table
Arel::Nodes::As.new(
travers_loc_table,
build_base_select.union(build_union_select)
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]

Expand Down
10 changes: 10 additions & 0 deletions lib/acts_as_recursive_tree/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,15 @@ def initialize(model_class:, parent_key:, parent_type_column:, depth_column: :re
def primary_key
@primary_key ||= @model_class.primary_key.to_sym
end

#
# Checks if SQL cycle detection can be used. This is currently supported only on PostgreSQL 14+.
# @return [TrueClass|FalseClass]
def cycle_detection?
return @cycle_detection if defined?(@cycle_detection)

@cycle_detection = @model_class.connection.adapter_name == 'PostgreSQL' &&
@model_class.connection.database_version >= 140_000
end
end
end
6 changes: 6 additions & 0 deletions lib/acts_as_recursive_tree/options/query_options.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ module Options
class QueryOptions
STRATEGIES = %i[subselect join].freeze

def self.from
options = new
yield(options) if block_given?
options
end

attr_accessor :condition
attr_reader :ensure_ordering, :query_strategy

Expand Down

0 comments on commit 430438b

Please sign in to comment.