cuatlan-activerecord-hierarchical_query 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,333 @@
1
+ # coding: utf-8
2
+
3
+ require 'active_support/core_ext/array/extract_options'
4
+
5
+ require 'active_record/hierarchical_query/orderings'
6
+ require 'active_record/hierarchical_query/join_builder'
7
+ require 'arel/nodes/postgresql'
8
+
9
+ module ActiveRecord
10
+ module HierarchicalQuery
11
+ class Query
12
+ # @api private
13
+ ORDERING_COLUMN_NAME = '__order_column'.freeze
14
+
15
+ # @api private
16
+ attr_reader :klass,
17
+ :start_with_value,
18
+ :connect_by_value,
19
+ :child_scope_value,
20
+ :limit_value,
21
+ :offset_value,
22
+ :order_values,
23
+ :nocycle_value,
24
+ :distinct_value
25
+
26
+ # @api private
27
+ CHILD_SCOPE_METHODS = :where, :joins, :group, :having
28
+
29
+ def initialize(klass)
30
+ @klass = klass
31
+
32
+ # start with :all
33
+ @start_with_value = klass.__send__(HierarchicalQuery::DELEGATOR_SCOPE)
34
+ @connect_by_value = nil
35
+ @child_scope_value = klass.__send__(HierarchicalQuery::DELEGATOR_SCOPE)
36
+ @limit_value = nil
37
+ @offset_value = nil
38
+ @nocycle_value = false
39
+ @order_values = []
40
+ @distinct_value = false
41
+ end
42
+
43
+ # Specify root scope of the hierarchy.
44
+ #
45
+ # @example When scope given
46
+ # MyModel.join_recursive do |hierarchy|
47
+ # hierarchy.start_with(MyModel.where(parent_id: nil))
48
+ # .connect_by(id: :parent_id)
49
+ # end
50
+ #
51
+ # @example When Hash given
52
+ # MyModel.join_recursive do |hierarchy|
53
+ # hierarchy.start_with(parent_id: nil)
54
+ # .connect_by(id: :parent_id)
55
+ # end
56
+ #
57
+ # @example When String given
58
+ # MyModel.join_recursive do |hierarchy|
59
+ # hierararchy.start_with('parent_id = ?', 1)
60
+ # .connect_by(id: :parent_id)
61
+ # end
62
+ #
63
+ # @example When block given
64
+ # MyModel.join_recursive do |hierarchy|
65
+ # hierarchy.start_with { |root| root.where(parent_id: nil) }
66
+ # .connect_by(id: :parent_id)
67
+ # end
68
+ #
69
+ # @example When block with arity=0 given
70
+ # MyModel.join_recursive do |hierarchy|
71
+ # hierarchy.start_with { where(parent_id: nil) }
72
+ # .connect_by(id: :parent_id)
73
+ # end
74
+ #
75
+ # @example Specify columns for root relation (PostgreSQL-specific)
76
+ # MyModel.join_recursive do |hierarchy|
77
+ # hierarchy.start_with { select('ARRAY[id] AS _path') }
78
+ # .connect_by(id: :parent_id)
79
+ # .select('_path || id', start_with: false) # `start_with: false` tells not to include this expression into START WITH clause
80
+ # end
81
+ #
82
+ # @param [ActiveRecord::Relation, Hash, String, nil] scope root scope (optional).
83
+ # @return [ActiveRecord::HierarchicalQuery::Query] self
84
+ def start_with(scope = nil, *arguments, &block)
85
+ raise ArgumentError, 'START WITH: scope or block expected, none given' unless scope || block
86
+
87
+ case scope
88
+ when Hash, String
89
+ @start_with_value = klass.where(scope, *arguments)
90
+
91
+ when ActiveRecord::Relation
92
+ @start_with_value = scope
93
+
94
+ else
95
+ # do nothing if something weird given
96
+ end
97
+
98
+ if block
99
+ object = @start_with_value || @klass
100
+
101
+ @start_with_value = if block.arity == 0
102
+ object.instance_eval(&block)
103
+ else
104
+ block.call(object)
105
+ end
106
+ end
107
+
108
+ self
109
+ end
110
+
111
+ # Specify relationship between parent rows and child rows of the
112
+ # hierarchy. It can be specified with Hash where keys are parent columns
113
+ # names and values are child columns names, or with block (see example below).
114
+ #
115
+ # @example Specify relationship with Hash (traverse descendants)
116
+ # MyModel.join_recursive do |hierarchy|
117
+ # # join child rows with condition `parent.id = child.parent_id`
118
+ # hierarchy.connect_by(id: :parent_id)
119
+ # end
120
+ #
121
+ # @example Specify relationship with block (traverse descendants)
122
+ # MyModel.join_recursive do |hierarchy|
123
+ # hierarchy.connect_by { |parent, child| parent[:id].eq(child[:parent_id]) }
124
+ # end
125
+ #
126
+ # @param [Hash, nil] conditions (optional) relationship between parent rows and
127
+ # child rows map, where keys are parent columns names and values are child columns names.
128
+ # @yield [parent, child] Yields both parent and child tables.
129
+ # @yieldparam [Arel::Table] parent parent rows table instance.
130
+ # @yieldparam [Arel::Table] child child rows table instance.
131
+ # @yieldreturn [Arel::Nodes::Node] relationship condition expressed as Arel node.
132
+ # @return [ActiveRecord::HierarchicalQuery::Query] self
133
+ def connect_by(conditions = nil, &block)
134
+ # convert hash to block which returns Arel node
135
+ if conditions
136
+ block = conditions_to_proc(conditions)
137
+ end
138
+
139
+ raise ArgumentError, 'CONNECT BY: Conditions hash or block expected, none given' unless block
140
+
141
+ @connect_by_value = block
142
+
143
+ self
144
+ end
145
+
146
+ # Specify which columns should be selected in addition to primary key,
147
+ # CONNECT BY columns and ORDER SIBLINGS columns.
148
+ #
149
+ # @param [Array<Symbol, String, Arel::Attributes::Attribute, Arel::Nodes::Node>] columns
150
+ # @option columns [true, false] :start_with include given columns to START WITH clause (true by default)
151
+ # @return [ActiveRecord::HierarchicalQuery::Query] self
152
+ def select(*columns)
153
+ options = columns.extract_options!
154
+
155
+ columns = columns.flatten.map do |column|
156
+ column.is_a?(Symbol) ? table[column] : column
157
+ end
158
+
159
+ # TODO: detect if column already present in START WITH clause and skip it
160
+ if options.fetch(:start_with, true)
161
+ start_with { |scope| scope.select(columns) }
162
+ end
163
+
164
+ @child_scope_value = @child_scope_value.select(columns)
165
+
166
+ self
167
+ end
168
+
169
+ # Generate methods that apply filters to child scope, such as
170
+ # +where+ or +group+.
171
+ #
172
+ # @example Filter child nodes by certain condition
173
+ # MyModel.join_recursive do |hierarchy|
174
+ # hierarchy.where('depth < 5')
175
+ # end
176
+ #
177
+ # @!method where(*conditions)
178
+ # @!method joins(*tables)
179
+ # @!method group(*values)
180
+ # @!method having(*conditions)
181
+ CHILD_SCOPE_METHODS.each do |method|
182
+ define_method(method) do |*args|
183
+ @child_scope_value = @child_scope_value.public_send(method, *args)
184
+
185
+ self
186
+ end
187
+ end
188
+
189
+ # Specifies a limit for the number of records to retrieve.
190
+ #
191
+ # @param [Fixnum] value
192
+ # @return [ActiveRecord::HierarchicalQuery::Query] self
193
+ def limit(value)
194
+ @limit_value = value
195
+
196
+ self
197
+ end
198
+
199
+ # Specifies the number of rows to skip before returning row
200
+ #
201
+ # @param [Fixnum] value
202
+ # @return [ActiveRecord::HierarchicalQuery::Query] self
203
+ def offset(value)
204
+ @offset_value = value
205
+
206
+ self
207
+ end
208
+
209
+ # Specifies hierarchical order of the recursive query results.
210
+ #
211
+ # @example
212
+ # MyModel.join_recursive do |hierarchy|
213
+ # hierarchy.connect_by(id: :parent_id)
214
+ # .order_siblings(:name)
215
+ # end
216
+ #
217
+ # @example
218
+ # MyModel.join_recursive do |hierarchy|
219
+ # hierarchy.connect_by(id: :parent_id)
220
+ # .order_siblings('name DESC, created_at ASC')
221
+ # end
222
+ #
223
+ # @param [<Symbol, String, Arel::Nodes::Node, Arel::Attributes::Attribute>] columns
224
+ # @return [ActiveRecord::HierarchicalQuery::Query] self
225
+ def order_siblings(*columns)
226
+ @order_values += columns
227
+
228
+ self
229
+ end
230
+ alias_method :order, :order_siblings
231
+
232
+ # Turn on/off cycles detection. This option can prevent
233
+ # endless loops if your tree could contain cycles.
234
+ #
235
+ # @param [true, false] value
236
+ # @return [ActiveRecord::HierarchicalQuery::Query] self
237
+ def nocycle(value = true)
238
+ @nocycle_value = value
239
+ self
240
+ end
241
+
242
+ # Returns object representing parent rows table,
243
+ # so it could be used in complex WHEREs.
244
+ #
245
+ # @example
246
+ # MyModel.join_recursive do |hierarchy|
247
+ # hierarchy.connect_by(id: :parent_id)
248
+ # .start_with(parent_id: nil) { select(:depth) }
249
+ # .select(hierarchy.table[:depth])
250
+ # .where(hierarchy.prior[:depth].lteq 1)
251
+ # end
252
+ #
253
+ # @return [Arel::Table]
254
+ def prior
255
+ @recursive_table ||= Arel::Table.new("#{normalized_table_name}__recursive")
256
+ end
257
+ alias_method :previous, :prior
258
+ alias_method :recursive_table, :prior
259
+
260
+ # Returns object representing child rows table,
261
+ # so it could be used in complex WHEREs.
262
+ #
263
+ # @example
264
+ # MyModel.join_recursive do |hierarchy|
265
+ # hierarchy.connect_by(id: :parent_id)
266
+ # .start_with(parent_id: nil) { select(:depth) }
267
+ # .select(hierarchy.table[:depth])
268
+ # .where(hierarchy.prior[:depth].lteq 1)
269
+ # end
270
+ def table
271
+ @klass.arel_table
272
+ end
273
+
274
+ # Turn on select distinct option in the CTE.
275
+ #
276
+ # @return [ActiveRecord::HierarchicalQuery::Query] self
277
+ def distinct
278
+ @distinct_value = true
279
+ self
280
+ end
281
+
282
+ # @return [Arel::Nodes::Node]
283
+ # @api private
284
+ def join_conditions
285
+ connect_by_value.call(recursive_table, table)
286
+ end
287
+
288
+ # @return [ActiveRecord::HierarchicalQuery::Orderings]
289
+ # @api private
290
+ def orderings
291
+ @orderings ||= Orderings.new(order_values, table)
292
+ end
293
+
294
+ # @api private
295
+ def ordering_column_name
296
+ ORDERING_COLUMN_NAME
297
+ end
298
+
299
+ # Builds recursive query and joins it to given +relation+.
300
+ #
301
+ # @api private
302
+ # @param [ActiveRecord::Relation] relation
303
+ # @param [Hash] join_options
304
+ # @option join_options [#to_s] :as joined table alias
305
+ # @api private
306
+ def join_to(relation, join_options = {})
307
+ raise 'Recursive query requires CONNECT BY clause, please use #connect_by method' unless
308
+ connect_by_value
309
+
310
+ table_alias = join_options.fetch(:as, "#{normalized_table_name}__recursive")
311
+
312
+ JoinBuilder.new(self, relation, table_alias, join_options).build
313
+ end
314
+
315
+ private
316
+ # converts conditions given as a hash to proc
317
+ def conditions_to_proc(conditions)
318
+ proc do |parent, child|
319
+ conditions.map do |parent_expression, child_expression|
320
+ parent_expression = parent[parent_expression] if parent_expression.is_a?(Symbol)
321
+ child_expression = child[child_expression] if child_expression.is_a?(Symbol)
322
+
323
+ Arel::Nodes::Equality.new(parent_expression, child_expression)
324
+ end.reduce(:and)
325
+ end
326
+ end
327
+
328
+ def normalized_table_name
329
+ table.name.gsub('.', '_')
330
+ end
331
+ end # class Builder
332
+ end # module HierarchicalQuery
333
+ end # module ActiveRecord
@@ -0,0 +1,5 @@
1
+ module ActiveRecord
2
+ module HierarchicalQuery
3
+ VERSION = '1.0.1'
4
+ end
5
+ end
@@ -0,0 +1 @@
1
+ require 'active_record/hierarchical_query'
@@ -0,0 +1,60 @@
1
+ require 'arel/visitors/to_sql'
2
+
3
+ module Arel
4
+ module Nodes
5
+ class PostgresArray < Node
6
+ include AliasPredication
7
+ attr_accessor :values
8
+
9
+ def initialize(values)
10
+ self.values = values
11
+ end
12
+ end
13
+
14
+ class ArrayConcat < Binary
15
+ end
16
+
17
+ class UnionDistinct < Binary
18
+ end
19
+ end
20
+
21
+ module Visitors
22
+ class ToSql < ToSql.superclass
23
+ private
24
+ ARRAY_OPENING = 'ARRAY['.freeze
25
+ ARRAY_CLOSING = ']'.freeze
26
+ ARRAY_CONCAT = '||'.freeze
27
+
28
+ if Arel::VERSION < '6.0.0'
29
+ def visit_Arel_Nodes_PostgresArray o, *a
30
+ "#{ARRAY_OPENING}#{visit o.values, *a}#{ARRAY_CLOSING}"
31
+ end
32
+
33
+ def visit_Arel_Nodes_ArrayConcat o, *a
34
+ "#{visit o.left, *a} #{ARRAY_CONCAT} #{visit o.right, *a}"
35
+ end
36
+
37
+ def visit_Arel_Nodes_UnionDistinct o, *a
38
+ "( #{visit o.left, *a} UNION DISTINCT #{visit o.right, *a} )"
39
+ end
40
+ else
41
+ def visit_Arel_Nodes_PostgresArray o, collector
42
+ collector << ARRAY_OPENING
43
+ visit o.values, collector
44
+ collector << ARRAY_CLOSING
45
+ end
46
+
47
+ def visit_Arel_Nodes_ArrayConcat o, collector
48
+ visit o.left, collector
49
+ collector << ARRAY_CONCAT
50
+ visit o.right, collector
51
+ end
52
+
53
+ def visit_Arel_Nodes_UnionDistinct o, collector
54
+ collector << "( "
55
+ infix_value(o, collector, " UNION DISTINCT ") << " )"
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,338 @@
1
+ require 'spec_helper'
2
+
3
+ describe ActiveRecord::HierarchicalQuery do
4
+ let(:klass) { Category }
5
+
6
+ let(:trait_id) { 'trait' }
7
+ let!(:root) { klass.create(trait_id: trait_id) }
8
+ let!(:child_1) { klass.create(parent: root, trait_id: trait_id) }
9
+ let!(:child_2) { klass.create(parent: child_1, trait_id: trait_id) }
10
+ let!(:child_3) { klass.create(parent: child_1, trait_id: trait_id) }
11
+ let!(:child_4) { klass.create(parent: root, trait_id: trait_id) }
12
+ let!(:child_5) { klass.create(parent: child_4, trait_id: trait_id) }
13
+
14
+ describe '#join_recursive' do
15
+ describe 'UNION clause' do
16
+ let(:options) { {} }
17
+ subject { klass.join_recursive(options) { connect_by(id: :parent_id) }.to_sql }
18
+
19
+ it 'defaults to UNION ALL' do
20
+ expect(subject).to include('UNION ALL')
21
+ end
22
+
23
+ context 'specifying DISTINCT union type' do
24
+ let(:options) { { union_type: :distinct } }
25
+
26
+ it 'uses UNION DISTINCT' do
27
+ expect(subject).to include('UNION DISTINCT')
28
+ expect(subject).to_not include('UNION ALL')
29
+ end
30
+ end
31
+
32
+ context 'specifying ALL union type' do
33
+ let(:options) { { union_type: :all } }
34
+
35
+ it 'uses UNION ALL' do
36
+ expect(subject).to include('UNION ALL')
37
+ expect(subject).to_not include('UNION DISTINCT')
38
+ end
39
+ end
40
+ end
41
+
42
+ describe 'CONNECT BY clause' do
43
+ it 'throws error if CONNECT BY clause not specified' do
44
+ expect {
45
+ klass.join_recursive {}
46
+ }.to raise_error /CONNECT BY clause/
47
+ end
48
+
49
+ it 'joins parent and child rows by hash map' do
50
+ expect(
51
+ klass.join_recursive { connect_by(id: :parent_id) }
52
+ ).to include root, child_1, child_2, child_3, child_4, child_5
53
+ end
54
+
55
+ it 'joins parent and child rows by block' do
56
+ expect(
57
+ klass.join_recursive do
58
+ connect_by { |parent, child| parent[:id].eq child[:parent_id] }
59
+ end
60
+ ).to include root, child_1, child_2, child_3, child_4, child_5
61
+ end
62
+ end
63
+
64
+ describe 'START WITH clause' do
65
+ def assert_start_with(&block)
66
+ expect(
67
+ klass.join_recursive do |b|
68
+ b.connect_by(id: :parent_id).instance_eval(&block)
69
+ end
70
+ ).to match_array [root, child_1, child_2, child_3, child_4, child_5]
71
+ end
72
+
73
+ it 'filters rows in non-recursive term by hash' do
74
+ assert_start_with { start_with(parent_id: nil) }
75
+ end
76
+
77
+ it 'filters rows in non-recursive term by block with arity > 0' do
78
+ assert_start_with { start_with { |root| root.where(parent_id: nil) } }
79
+ end
80
+
81
+ it 'filters rows in non-recursive term by block with arity = 0' do
82
+ assert_start_with { start_with { where(parent_id: nil) } }
83
+ end
84
+
85
+ it 'filters rows in non-recursive term by scope' do
86
+ assert_start_with { start_with(klass.where(parent_id: nil)) }
87
+ end
88
+ end
89
+
90
+ describe 'ORDER SIBLINGS BY clause' do
91
+ def assert_ordered_by_name_desc(&block)
92
+ expect(
93
+ klass.join_recursive do |b|
94
+ b.connect_by(id: :parent_id).start_with(parent_id: nil).instance_eval(&block)
95
+ end
96
+ ).to eq [root, child_4, child_5, child_1, child_3, child_2]
97
+ end
98
+
99
+ def assert_ordered_by_name_asc(&block)
100
+ expect(
101
+ klass.join_recursive do |b|
102
+ b.connect_by(id: :parent_id).start_with(parent_id: nil).instance_eval(&block)
103
+ end
104
+ ).to eq [root, child_1, child_2, child_3, child_4, child_5]
105
+ end
106
+
107
+ it 'orders rows by Hash' do
108
+ assert_ordered_by_name_desc { order_siblings(name: :desc) }
109
+ end
110
+
111
+ it 'orders rows by String' do
112
+ assert_ordered_by_name_desc { order_siblings('name desc') }
113
+ assert_ordered_by_name_asc { order_siblings('name asc') }
114
+ end
115
+
116
+ it 'orders rows by Arel::Nodes::Ordering' do
117
+ assert_ordered_by_name_desc { order_siblings(table[:name].desc) }
118
+ end
119
+
120
+ it 'orders rows by Arel::Nodes::Node' do
121
+ assert_ordered_by_name_asc { order_siblings(table[:name]) }
122
+ end
123
+
124
+ it 'throws error when something weird given' do
125
+ expect {
126
+ klass.join_recursive { connect_by(id: :parent_id).order_siblings(1) }
127
+ }.to raise_error /ORDER BY SIBLINGS/
128
+ end
129
+
130
+ context 'when one attribute given and this attribute support natural sorting' do
131
+ let(:relation) do
132
+ klass.join_recursive do
133
+ connect_by(id: :parent_id).
134
+ start_with(parent_id: nil).
135
+ order_siblings(:position)
136
+ end
137
+ end
138
+
139
+ it 'orders rows by given attribute' do
140
+ expect(relation).to eq [root, child_1, child_2, child_3, child_4, child_5]
141
+ expect(relation.to_sql).not_to match /row_number/i
142
+ end
143
+ end
144
+ end
145
+
146
+ describe 'LIMIT and OFFSET clauses' do
147
+ let(:ordered_nodes) { [root, child_1, child_2, child_3, child_4, child_5] }
148
+
149
+ it 'limits all rows' do
150
+ expect(
151
+ klass.join_recursive do
152
+ connect_by(id: :parent_id).
153
+ start_with(parent_id: nil).
154
+ limit(2).
155
+ offset(2)
156
+ end.size
157
+ ).to eq 2
158
+ end
159
+
160
+ it 'limits all rows if ordering given' do
161
+ expect(
162
+ klass.join_recursive do
163
+ connect_by(id: :parent_id).
164
+ start_with(parent_id: nil).
165
+ order_siblings(:name).
166
+ limit(2).
167
+ offset(2)
168
+ end
169
+ ).to eq ordered_nodes[2...4]
170
+ end
171
+ end
172
+
173
+ describe 'WHERE clause' do
174
+ it 'filters child rows' do
175
+ expect(
176
+ klass.join_recursive do
177
+ connect_by(id: :parent_id).
178
+ start_with(parent_id: nil).
179
+ where('depth < ?', 2)
180
+ end
181
+ ).to match_array [root, child_1, child_4]
182
+ end
183
+
184
+ it 'allows to use PRIOR relation' do
185
+ expect(
186
+ klass.join_recursive do |b|
187
+ b.connect_by(id: :parent_id)
188
+ .start_with(parent_id: nil)
189
+ .select(:depth)
190
+ .where(b.prior[:depth].lt(1))
191
+ end
192
+ ).to match_array [root, child_1, child_4]
193
+ end
194
+ end
195
+
196
+ describe 'SELECT clause' do
197
+ it 'adds column to both recursive and non-recursive term' do
198
+ expect(
199
+ klass.join_recursive(as: 'categories_r') do
200
+ connect_by(id: :parent_id).
201
+ start_with(parent_id: nil).
202
+ select(:depth)
203
+ end.where('categories_r.depth = 0')
204
+ ).to eq [root]
205
+ end
206
+ end
207
+
208
+ describe 'NOCYCLE clause' do
209
+ before { klass.where(id: child_4.id).update_all(parent_id: child_5.id) }
210
+
211
+ it 'prevents recursive query from endless loops' do
212
+ expect(
213
+ klass.join_recursive do |query|
214
+ query.start_with(id: child_4.id)
215
+ .connect_by(id: :parent_id)
216
+ .nocycle
217
+ end
218
+ ).to match_array [child_4, child_5]
219
+ end
220
+ end
221
+ end
222
+
223
+ describe '#join_recursive options' do
224
+ describe ':as option' do
225
+ it 'builds a join with specified alias' do
226
+ expect(
227
+ klass.join_recursive(as: 'my_table') { connect_by(id: :parent_id) }.to_sql
228
+ ).to match /my_table/
229
+ end
230
+ end
231
+
232
+ describe ':foreign_key' do
233
+ it 'uses described foreign_key when joining table to recursive view' do
234
+ expect(
235
+ klass.join_recursive(foreign_key: 'some_column') { connect_by(id: :parent_id) }.to_sql
236
+ ).to match /categories\"\.\"id\" = \"categories__recursive\"\.\"some_column/
237
+ end
238
+ end
239
+
240
+ describe ':outer_join_hierarchical' do
241
+ subject { klass.join_recursive(outer_join_hierarchical: value) { connect_by(id: :parent_id) }.to_sql }
242
+
243
+ let(:value) { true }
244
+ let(:inner_join) {
245
+ /INNER JOIN \(WITH RECURSIVE \"categories__recursive\"/
246
+ }
247
+
248
+ it 'builds an outer join' do
249
+ expect(subject).to match /LEFT OUTER JOIN \(WITH RECURSIVE \"categories__recursive\"/
250
+ end
251
+
252
+ context 'value is false' do
253
+ let(:value) { false }
254
+
255
+ it 'builds an inner join' do
256
+ expect(subject).to match inner_join
257
+ end
258
+ end
259
+
260
+ context 'value is a string' do
261
+ let(:value) { 'foo' }
262
+
263
+ it 'builds an inner join' do
264
+ expect(subject).to match inner_join
265
+ end
266
+ end
267
+
268
+ context 'key is absent' do
269
+ subject { klass.join_recursive { connect_by(id: :parent_id) }.to_sql }
270
+
271
+ it 'builds an inner join' do
272
+ expect(subject).to match inner_join
273
+ end
274
+ end
275
+ end
276
+ end
277
+
278
+ describe ':distinct query method' do
279
+ subject { klass.join_recursive { connect_by(id: :parent_id).distinct }.to_sql }
280
+
281
+ let(:select) {
282
+ /SELECT \"categories__recursive\"/
283
+ }
284
+
285
+ it 'selects using a distinct option after joining table to recursive view' do
286
+ expect(subject).to match /SELECT DISTINCT \"categories__recursive\"/
287
+ end
288
+
289
+ context 'distinct method is absent' do
290
+ subject { klass.join_recursive { connect_by(id: :parent_id) }.to_sql }
291
+
292
+ it 'selects without using a distinct' do
293
+ expect(subject).to match select
294
+ end
295
+ end
296
+ end
297
+
298
+ describe 'Testing bind variables' do
299
+ let!(:article) { Article.create!(category: child_2, title: 'Alpha') }
300
+
301
+ let(:subquery) do
302
+ klass.join_recursive do |query|
303
+ query.
304
+ start_with(trait_id: trait_id, parent_id: child_1.id).
305
+ connect_by(id: :parent_id).
306
+ where(trait_id: trait_id)
307
+ end
308
+ end
309
+
310
+ let(:outer_query) do
311
+ Article.where(category_id: subquery, title: 'Alpha')
312
+ end
313
+
314
+ subject(:result) { outer_query.to_a }
315
+
316
+ it 'returns result without throwing an error' do
317
+ expect(result).to include(article)
318
+ end
319
+ end
320
+
321
+ describe 'Models with default scope' do
322
+ let!(:scoped_root) { ModelWithDefaultScope.create!(name: '9. Root') }
323
+ let!(:scoped_child_1) { ModelWithDefaultScope.create!(name: '8. Child', parent: scoped_root) }
324
+ let!(:scoped_child_2) { ModelWithDefaultScope.create!(name: '7. Child', parent: scoped_root) }
325
+
326
+ subject(:result) {
327
+ ModelWithDefaultScope.join_recursive do |query|
328
+ query
329
+ .connect_by(id: :parent_id)
330
+ .start_with(id: scoped_root.id)
331
+ end
332
+ }
333
+
334
+ it 'applies default scope to outer query without affecting recursive terms' do
335
+ expect(result).to eq [scoped_child_2, scoped_child_1, scoped_root]
336
+ end
337
+ end
338
+ end