cuatlan-activerecord-hierarchical_query 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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