Skip to content

Commit 430438b

Browse files
committed
Adding Postgres Cycle detection
1 parent 919daa7 commit 430438b

File tree

7 files changed

+67
-27
lines changed

7 files changed

+67
-27
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
### NEXT
2-
- Added :dependent option for setting explicit
2+
- Added :dependent option for setting explicit deletion behaviour (issue #31)
3+
- Added automatic cycle detection when supported (currently only PostgresSQL 14+) (issue #22)
34

45
### Version 3.4.0
56
- Rails 7.1 compatibility

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,15 @@ Instance Methods make no difference of the class from which they are called:
224224
sub_node_instance.descendants # => returns Node and SubNode instances
225225
```
226226

227+
## A note on endless recursion / cycle detection
228+
229+
### Inserting
230+
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.
231+
232+
### Querying
233+
If you want to make sure to not run into an endless recursion when querying, then there are following options:
234+
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.
235+
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.
227236

228237
## Contributing
229238

lib/acts_as_recursive_tree/builders/ancestors.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ module Builders
55
class Ancestors < RelationBuilder
66
self.traversal_strategy = ActsAsRecursiveTree::Builders::Strategies::Ancestor
77

8-
def get_query_options(_)
8+
def get_query_options(&block)
99
opts = super
1010
opts.ensure_ordering!
1111
opts

lib/acts_as_recursive_tree/builders/leaves.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def create_select_manger(column = nil)
1616
select_manager
1717
end
1818

19-
def get_query_options(_)
19+
def get_query_options(&_block)
2020
# do not allow any custom options
2121
ActsAsRecursiveTree::Options::QueryOptions.new
2222
end

lib/acts_as_recursive_tree/builders/relation_builder.rb

Lines changed: 38 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# frozen_string_literal: true
22

3+
require 'securerandom'
4+
35
module ActsAsRecursiveTree
46
module Builders
57
#
@@ -12,40 +14,42 @@ def self.build(klass, ids, exclude_ids: false, &block)
1214

1315
class_attribute :traversal_strategy, instance_writer: false
1416

15-
attr_reader :klass, :ids, :recursive_temp_table, :travers_loc_table, :without_ids
16-
17-
mattr_reader(:random) { Random.new }
17+
attr_reader :klass, :ids, :without_ids
1818

1919
# Delegators for easier accessing config and query options
20-
delegate :primary_key, :depth_column, :parent_key, :parent_type_column, to: :@config
20+
delegate :primary_key, :depth_column, :parent_key, :parent_type_column, to: :config
2121
delegate :depth_present?, :depth, :condition, :ensure_ordering, to: :@query_opts
2222

2323
def initialize(klass, ids, exclude_ids: false, &block)
2424
@klass = klass
25-
@config = klass._recursive_tree_config
26-
@ids = ActsAsRecursiveTree::Options::Values.create(ids, @config)
25+
@ids = ActsAsRecursiveTree::Options::Values.create(ids, klass._recursive_tree_config)
2726
@without_ids = exclude_ids
2827

29-
@query_opts = get_query_options(block)
28+
@query_opts = get_query_options(&block)
29+
30+
# random seed for the temp tables
31+
@rand_int = SecureRandom.rand(1_000_000)
32+
end
33+
34+
def recursive_temp_table
35+
@recursive_temp_table ||= Arel::Table.new("recursive_#{klass.table_name}_#{@rand_int}_temp")
36+
end
37+
38+
def travers_loc_table
39+
@travers_loc_table ||= Arel::Table.new("traverse_#{@rand_int}_loc")
40+
end
3041

31-
rand_int = random.rand(1_000_000)
32-
@recursive_temp_table = Arel::Table.new("recursive_#{klass.table_name}_#{rand_int}_temp")
33-
@travers_loc_table = Arel::Table.new("traverse_#{rand_int}_loc")
42+
def config
43+
klass._recursive_tree_config
3444
end
3545

3646
#
3747
# Constructs a new QueryOptions and yield it to the proc if one is present.
3848
# Subclasses may override this method to provide sane defaults.
3949
#
40-
# @param proc [Proc] a proc or nil
41-
#
4250
# @return [ActsAsRecursiveTree::Options::QueryOptions] the new QueryOptions instance
43-
def get_query_options(proc)
44-
opts = ActsAsRecursiveTree::Options::QueryOptions.new
45-
46-
proc&.call(opts)
47-
48-
opts
51+
def get_query_options(&block)
52+
ActsAsRecursiveTree::Options::QueryOptions.from(&block)
4953
end
5054

5155
def base_table
@@ -71,11 +75,7 @@ def apply_depth(select_manager)
7175
end
7276

7377
def create_select_manger(column = nil)
74-
projections = if column
75-
travers_loc_table[column]
76-
else
77-
Arel.star
78-
end
78+
projections = column ? travers_loc_table[column] : Arel.star
7979

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

@@ -85,10 +85,24 @@ def create_select_manger(column = nil)
8585
def build_cte_table
8686
Arel::Nodes::As.new(
8787
travers_loc_table,
88-
build_base_select.union(build_union_select)
88+
add_pg_cycle_detection(
89+
build_base_select.union(build_union_select)
90+
)
91+
)
92+
end
93+
94+
def add_pg_cycle_detection(union_query)
95+
return union_query unless config.cycle_detection?
96+
97+
Arel::Nodes::InfixOperation.new(
98+
'',
99+
union_query,
100+
Arel.sql("CYCLE #{primary_key} SET is_cycle USING path")
89101
)
90102
end
91103

104+
# Builds SQL:
105+
# SELECT id, parent_id, 0 AS depth FROM base_table WHERE id = 123
92106
def build_base_select
93107
id_node = base_table[primary_key]
94108

lib/acts_as_recursive_tree/config.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,15 @@ def initialize(model_class:, parent_key:, parent_type_column:, depth_column: :re
2121
def primary_key
2222
@primary_key ||= @model_class.primary_key.to_sym
2323
end
24+
25+
#
26+
# Checks if SQL cycle detection can be used. This is currently supported only on PostgreSQL 14+.
27+
# @return [TrueClass|FalseClass]
28+
def cycle_detection?
29+
return @cycle_detection if defined?(@cycle_detection)
30+
31+
@cycle_detection = @model_class.connection.adapter_name == 'PostgreSQL' &&
32+
@model_class.connection.database_version >= 140_000
33+
end
2434
end
2535
end

lib/acts_as_recursive_tree/options/query_options.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ module Options
55
class QueryOptions
66
STRATEGIES = %i[subselect join].freeze
77

8+
def self.from
9+
options = new
10+
yield(options) if block_given?
11+
options
12+
end
13+
814
attr_accessor :condition
915
attr_reader :ensure_ordering, :query_strategy
1016

0 commit comments

Comments
 (0)