acts_as_recursive_tree 3.4.0 → 3.5.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f74110127d12218381c9578cf59546b125dddd7f2f994dcbc8fff16b13f27f9f
4
- data.tar.gz: e63babd0dc010e7cded02689cc9fb3ae54f8a9a817de3c00031cbc118e2a652c
3
+ metadata.gz: 2ce9f3e8c4b5dd8d5e61e04d9234a07920f2063955d134110a40c217950595ea
4
+ data.tar.gz: baa87fc513a6fd8e49538309b1833e418328b1f7317584fc828d20689cbbd32b
5
5
  SHA512:
6
- metadata.gz: 3711b930aa6bbb744cdf6ad0e34af98df9065294c0d8d498ec7daafb1ebd8e534acbab8195a5752b47dd1da5650a73e4a2c72d3371d6d2856d885f0cae0d4331
7
- data.tar.gz: 3697fb438ee57c0cbb3d652c26278f6bcd016eab1f8c1d1d60cb0b319f74dd1566af889827a1fca7823b201d16a692b32a9411a95a2850e3f582c928f01aa378
6
+ metadata.gz: 7e21465a62a28f6ca757402689abe0709e131d3104f3334646105af34d594a0704d2aaf54fb72b11cb039fd7436845bec0dd335506ed8f5c48c599f89ae9a502
7
+ data.tar.gz: 242a7f33a0595155d0fc75eec0f17769ae0d92314db07a6cb5acab96737574498266efb6f2aa8f30af3ef3c9a4b87895cb7ab0ebf75d3d9469173131917a32e1
data/CHANGELOG.md CHANGED
@@ -1,3 +1,7 @@
1
+ ### Version 3.5.0
2
+ - Added :dependent option for setting explicit deletion behaviour (issue #31)
3
+ - Added automatic cycle detection when supported (currently only PostgresSQL 14+) (issue #22)
4
+
1
5
  ### Version 3.4.0
2
6
  - Rails 7.1 compatibility
3
7
  - Added ar_next to test matrix
data/README.md CHANGED
@@ -55,18 +55,26 @@ class Node < ActiveRecord::Base
55
55
  recursive_tree
56
56
  end
57
57
  ```
58
- That's it. This will assume that your model has a column named `parent_id` which will be used for traversal. If your column is something different, then you can specifiy it in the call to `recursive_tree`:
58
+ That's it. This will assume that your model has a column named `parent_id` which will be used for traversal. If your column is something different, then you can specify it in the call to `recursive_tree`:
59
59
 
60
60
  ```ruby
61
61
  recursive_tree parent_key: :some_other_column
62
62
  ```
63
63
 
64
- Some extra special stuff - if your parent relation is also polymorphic, the specify the polymorphic column:
64
+ Some extra special stuff - if your parent relation is also polymorphic, then specify the polymorphic column:
65
65
 
66
66
  ```ruby
67
67
  recursive_tree parent_type_column: :some_other_type_column
68
68
  ```
69
69
 
70
+ Controlling deletion behaviour:
71
+
72
+ By default, it is up to the user code to delete all child nodes in a tree when a parent node gets deleted. This can be controlled by the `:dependent` option, which will be set on the `children` association (see [#has_many](https://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html#method-i-has_many) in the Rails doc).
73
+
74
+ ```ruby
75
+ recursive_tree dependent: :nullify # or :destroy, etc.
76
+ ```
77
+
70
78
  ## Usage
71
79
 
72
80
  After you set up a model for usage, there are now several methods you can use.
@@ -216,6 +224,15 @@ Instance Methods make no difference of the class from which they are called:
216
224
  sub_node_instance.descendants # => returns Node and SubNode instances
217
225
  ```
218
226
 
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.
219
236
 
220
237
  ## Contributing
221
238
 
@@ -7,13 +7,14 @@ module ActsAsRecursiveTree
7
7
  #
8
8
  # * <tt>foreign_key</tt> - specifies the column name to use for tracking
9
9
  # of the tree (default: +parent_id+)
10
- def recursive_tree(parent_key: :parent_id, parent_type_column: nil)
10
+ def recursive_tree(parent_key: :parent_id, parent_type_column: nil, dependent: nil)
11
11
  class_attribute(:_recursive_tree_config, instance_writer: false)
12
12
 
13
13
  self._recursive_tree_config = Config.new(
14
14
  model_class: self,
15
15
  parent_key: parent_key.to_sym,
16
- parent_type_column: parent_type_column.try(:to_sym)
16
+ parent_type_column: parent_type_column.try(:to_sym),
17
+ dependent: dependent
17
18
  )
18
19
 
19
20
  include ActsAsRecursiveTree::Model
@@ -16,7 +16,8 @@ module ActsAsRecursiveTree
16
16
  has_many :children,
17
17
  class_name: base_class.to_s,
18
18
  foreign_key: _recursive_tree_config.parent_key,
19
- inverse_of: :parent
19
+ inverse_of: :parent,
20
+ dependent: _recursive_tree_config.dependent
20
21
 
21
22
  has_many :self_and_siblings,
22
23
  through: :parent,
@@ -5,7 +5,7 @@ module ActsAsRecursiveTree
5
5
  class Ancestors < RelationBuilder
6
6
  self.traversal_strategy = ActsAsRecursiveTree::Builders::Strategies::Ancestor
7
7
 
8
- def get_query_options(_)
8
+ def get_query_options(&block)
9
9
  opts = super
10
10
  opts.ensure_ordering!
11
11
  opts
@@ -16,7 +16,7 @@ module ActsAsRecursiveTree
16
16
  select_manager
17
17
  end
18
18
 
19
- def get_query_options(_)
19
+ def get_query_options(&_block)
20
20
  # do not allow any custom options
21
21
  ActsAsRecursiveTree::Options::QueryOptions.new
22
22
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'securerandom'
4
+
3
5
  module ActsAsRecursiveTree
4
6
  module Builders
5
7
  #
@@ -12,40 +14,42 @@ module ActsAsRecursiveTree
12
14
 
13
15
  class_attribute :traversal_strategy, instance_writer: false
14
16
 
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
18
18
 
19
19
  # 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
21
21
  delegate :depth_present?, :depth, :condition, :ensure_ordering, to: :@query_opts
22
22
 
23
23
  def initialize(klass, ids, exclude_ids: false, &block)
24
24
  @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)
27
26
  @without_ids = exclude_ids
28
27
 
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
30
41
 
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
34
44
  end
35
45
 
36
46
  #
37
47
  # Constructs a new QueryOptions and yield it to the proc if one is present.
38
48
  # Subclasses may override this method to provide sane defaults.
39
49
  #
40
- # @param proc [Proc] a proc or nil
41
- #
42
50
  # @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)
49
53
  end
50
54
 
51
55
  def base_table
@@ -71,11 +75,7 @@ module ActsAsRecursiveTree
71
75
  end
72
76
 
73
77
  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
79
79
 
80
80
  select_mgr = travers_loc_table.project(projections).with(:recursive, build_cte_table)
81
81
 
@@ -85,10 +85,24 @@ module ActsAsRecursiveTree
85
85
  def build_cte_table
86
86
  Arel::Nodes::As.new(
87
87
  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")
89
101
  )
90
102
  end
91
103
 
104
+ # Builds SQL:
105
+ # SELECT id, parent_id, 0 AS depth FROM base_table WHERE id = 123
92
106
  def build_base_select
93
107
  id_node = base_table[primary_key]
94
108
 
@@ -5,13 +5,14 @@ module ActsAsRecursiveTree
5
5
  # Stores the configuration of one Model class
6
6
  #
7
7
  class Config
8
- attr_reader :parent_key, :parent_type_column, :depth_column
8
+ attr_reader :parent_key, :parent_type_column, :depth_column, :dependent
9
9
 
10
- def initialize(model_class:, parent_key:, parent_type_column:, depth_column: :recursive_depth)
10
+ def initialize(model_class:, parent_key:, parent_type_column:, depth_column: :recursive_depth, dependent: nil)
11
11
  @model_class = model_class
12
12
  @parent_key = parent_key
13
13
  @parent_type_column = parent_type_column
14
14
  @depth_column = depth_column
15
+ @dependent = dependent
15
16
  end
16
17
 
17
18
  #
@@ -20,5 +21,15 @@ module ActsAsRecursiveTree
20
21
  def primary_key
21
22
  @primary_key ||= @model_class.primary_key.to_sym
22
23
  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
23
34
  end
24
35
  end
@@ -5,6 +5,12 @@ module ActsAsRecursiveTree
5
5
  class QueryOptions
6
6
  STRATEGIES = %i[subselect join].freeze
7
7
 
8
+ def self.from
9
+ options = new
10
+ yield(options) if block_given?
11
+ options
12
+ end
13
+
8
14
  attr_accessor :condition
9
15
  attr_reader :ensure_ordering, :query_strategy
10
16
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActsAsRecursiveTree
4
- VERSION = '3.4.0'
4
+ VERSION = '3.5.0'
5
5
  end
@@ -3,15 +3,15 @@
3
3
  require 'spec_helper'
4
4
 
5
5
  RSpec.describe ActsAsRecursiveTree::Builders::Ancestors do
6
- context 'basic' do
6
+ context 'without additional setup' do
7
7
  it_behaves_like 'build recursive query'
8
8
  it_behaves_like 'ancestor query'
9
- include_context 'context with ordering'
9
+ include_context 'with ordering'
10
10
  end
11
11
 
12
12
  context 'with options' do
13
- include_context 'setup with enforced ordering' do
14
- it_behaves_like 'with ordering'
13
+ include_context 'with enforced ordering setup' do
14
+ it_behaves_like 'is adding ordering'
15
15
  end
16
16
  end
17
17
  end
@@ -3,16 +3,16 @@
3
3
  require 'spec_helper'
4
4
 
5
5
  RSpec.describe ActsAsRecursiveTree::Builders::Descendants do
6
- context 'basic' do
6
+ context 'without additional setup' do
7
7
  it_behaves_like 'build recursive query'
8
8
  it_behaves_like 'descendant query'
9
- include_context 'context without ordering'
9
+ include_context 'without ordering'
10
10
  end
11
11
 
12
12
  context 'with options' do
13
- include_context 'setup with enforced ordering' do
13
+ include_context 'with enforced ordering setup' do
14
14
  let(:ordering) { true }
15
- it_behaves_like 'with ordering'
15
+ it_behaves_like 'is adding ordering'
16
16
  end
17
17
  end
18
18
  end
@@ -3,16 +3,16 @@
3
3
  require 'spec_helper'
4
4
 
5
5
  RSpec.describe ActsAsRecursiveTree::Builders::Leaves do
6
- context 'basic' do
6
+ context 'without additional setup' do
7
7
  it_behaves_like 'build recursive query'
8
8
  it_behaves_like 'descendant query'
9
- include_context 'context without ordering'
9
+ include_context 'without ordering'
10
10
  end
11
11
 
12
12
  context 'with options' do
13
- include_context 'setup with enforced ordering' do
13
+ include_context 'with enforced ordering setup' do
14
14
  let(:ordering) { true }
15
- it_behaves_like 'without ordering'
15
+ it_behaves_like 'not adding ordering'
16
16
  end
17
17
  end
18
18
  end
@@ -20,13 +20,13 @@ RSpec.describe ActsAsRecursiveTree::Options::Values do
20
20
  let(:table) { Arel::Table.new('test_table') }
21
21
  let(:attribute) { table['test_attr'] }
22
22
 
23
- context 'invalid agurment' do
23
+ context 'with invalid agurment' do
24
24
  it 'raises exception' do
25
25
  expect { described_class.create(nil) }.to raise_exception(/is not supported/)
26
26
  end
27
27
  end
28
28
 
29
- context 'single value' do
29
+ context 'with single value' do
30
30
  let(:single_value) { 3 }
31
31
 
32
32
  it_behaves_like 'single values' do
@@ -38,8 +38,8 @@ RSpec.describe ActsAsRecursiveTree::Options::Values do
38
38
  end
39
39
  end
40
40
 
41
- context 'multi value' do
42
- context 'Array' do
41
+ context 'with multi value' do
42
+ context 'with Array' do
43
43
  subject(:value) { described_class.create(array) }
44
44
 
45
45
  let(:array) { [1, 2, 3] }
@@ -55,7 +55,7 @@ RSpec.describe ActsAsRecursiveTree::Options::Values do
55
55
  end
56
56
  end
57
57
 
58
- context 'Range' do
58
+ context 'with Range' do
59
59
  subject(:value) { described_class.create(range) }
60
60
 
61
61
  let(:range) { 1..3 }
@@ -71,7 +71,7 @@ RSpec.describe ActsAsRecursiveTree::Options::Values do
71
71
  end
72
72
  end
73
73
 
74
- context 'Relation' do
74
+ context 'with Relation' do
75
75
  subject(:value) { described_class.create(relation, double) }
76
76
 
77
77
  let(:relation) { Node.where(name: 'test') }
@@ -1,11 +1,13 @@
1
- RSpec.shared_context 'setup with enforced ordering' do
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.shared_context 'with enforced ordering setup' do
2
4
  let(:ordering) { false }
3
- include_context 'base_setup' do
5
+ include_context 'with base_setup' do
4
6
  let(:proc) { ->(config) { config.ensure_ordering! } }
5
7
  end
6
8
  end
7
9
 
8
- RSpec.shared_context 'base_setup' do
10
+ RSpec.shared_context 'with base_setup' do
9
11
  subject(:query) { builder.build.to_sql }
10
12
 
11
13
  let(:model_id) { 1 }
@@ -32,30 +34,30 @@ RSpec.shared_examples 'basic recursive examples' do
32
34
  end
33
35
 
34
36
  RSpec.shared_examples 'build recursive query' do
35
- context 'simple id' do
37
+ context 'with simple id' do
36
38
  context 'with simple class' do
37
- include_context 'base_setup' do
39
+ include_context 'with base_setup' do
38
40
  let(:model_class) { Node }
39
41
  it_behaves_like 'basic recursive examples'
40
42
  end
41
43
  end
42
44
 
43
45
  context 'with class with different parent key' do
44
- include_context 'base_setup' do
46
+ include_context 'with base_setup' do
45
47
  let(:model_class) { NodeWithOtherParentKey }
46
48
  it_behaves_like 'basic recursive examples'
47
49
  end
48
50
  end
49
51
 
50
52
  context 'with Subclass' do
51
- include_context 'base_setup' do
53
+ include_context 'with base_setup' do
52
54
  let(:model_class) { Floor }
53
55
  it_behaves_like 'basic recursive examples'
54
56
  end
55
57
  end
56
58
 
57
59
  context 'with polymorphic parent relation' do
58
- include_context 'base_setup' do
60
+ include_context 'with base_setup' do
59
61
  let(:model_class) { NodeWithPolymorphicParent }
60
62
  it_behaves_like 'basic recursive examples'
61
63
  end
@@ -64,34 +66,34 @@ RSpec.shared_examples 'build recursive query' do
64
66
  end
65
67
 
66
68
  RSpec.shared_examples 'ancestor query' do
67
- include_context 'base_setup'
69
+ include_context 'with base_setup'
68
70
 
69
71
  it { is_expected.to match(/"#{builder.travers_loc_table.name}"."#{model_class._recursive_tree_config.parent_key}" = "#{model_class.table_name}"."#{model_class.primary_key}"/) }
70
72
  end
71
73
 
72
74
  RSpec.shared_examples 'descendant query' do
73
- include_context 'base_setup'
75
+ include_context 'with base_setup'
74
76
 
75
77
  it { is_expected.to match(/"#{model_class.table_name}"."#{model_class._recursive_tree_config.parent_key}" = "#{builder.travers_loc_table.name}"."#{model_class.primary_key}"/) }
76
78
  it { is_expected.to match(/#{Regexp.escape(builder.travers_loc_table.project(builder.travers_loc_table[model_class.primary_key]).to_sql)}/) }
77
79
  end
78
80
 
79
- RSpec.shared_context 'context with ordering' do
80
- include_context 'base_setup' do
81
- it_behaves_like 'with ordering'
81
+ RSpec.shared_context 'with ordering' do
82
+ include_context 'with base_setup' do
83
+ it_behaves_like 'is adding ordering'
82
84
  end
83
85
  end
84
86
 
85
- RSpec.shared_context 'context without ordering' do
86
- include_context 'base_setup' do
87
- it_behaves_like 'without ordering'
87
+ RSpec.shared_context 'without ordering' do
88
+ include_context 'with base_setup' do
89
+ it_behaves_like 'not adding ordering'
88
90
  end
89
91
  end
90
92
 
91
- RSpec.shared_examples 'with ordering' do
93
+ RSpec.shared_examples 'is adding ordering' do
92
94
  it { is_expected.to match(/ORDER BY #{Regexp.escape(builder.recursive_temp_table[model_class._recursive_tree_config.depth_column].asc.to_sql)}/) }
93
95
  end
94
96
 
95
- RSpec.shared_examples 'without ordering' do
97
+ RSpec.shared_examples 'not adding ordering' do
96
98
  it { is_expected.not_to match(/ORDER BY/) }
97
99
  end
@@ -3,13 +3,10 @@
3
3
  # Helper methods for simple tree creation
4
4
  module TreeMethods
5
5
  def create_tree(max_level, current_level: 0, node: nil, create_node_info: false, stop_at: -1)
6
- node = Node.create!(name: 'root') if node.nil?
6
+ node ||= Node.create!(name: 'root')
7
7
 
8
8
  1.upto(max_level - current_level) do |index|
9
- child = node.children.create!(
10
- name: "child #{index} - level #{current_level}",
11
- active: stop_at > current_level
12
- )
9
+ child = node.children.create!(name: "child #{index} - level #{current_level}", active: stop_at > current_level)
13
10
 
14
11
  child.create_node_info(status: stop_at > current_level ? 'foo' : 'bar') if create_node_info
15
12
 
@@ -21,7 +18,6 @@ module TreeMethods
21
18
  stop_at: stop_at
22
19
  )
23
20
  end
24
-
25
21
  node
26
22
  end
27
23
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: acts_as_recursive_tree
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.4.0
4
+ version: 3.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Wolfgang Wedelich-John
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2023-03-13 00:00:00.000000000 Z
12
+ date: 2023-08-11 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activerecord