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.
- checksums.yaml +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +356 -0
- data/lib/active_record/hierarchical_query.rb +53 -0
- data/lib/active_record/hierarchical_query/cte/columns.rb +26 -0
- data/lib/active_record/hierarchical_query/cte/cycle_detector.rb +54 -0
- data/lib/active_record/hierarchical_query/cte/non_recursive_term.rb +55 -0
- data/lib/active_record/hierarchical_query/cte/query_builder.rb +88 -0
- data/lib/active_record/hierarchical_query/cte/recursive_term.rb +47 -0
- data/lib/active_record/hierarchical_query/cte/union_term.rb +35 -0
- data/lib/active_record/hierarchical_query/join_builder.rb +107 -0
- data/lib/active_record/hierarchical_query/orderings.rb +119 -0
- data/lib/active_record/hierarchical_query/query.rb +333 -0
- data/lib/active_record/hierarchical_query/version.rb +5 -0
- data/lib/activerecord-hierarchical_query.rb +1 -0
- data/lib/arel/nodes/postgresql.rb +60 -0
- data/spec/active_record/hierarchical_query_spec.rb +338 -0
- data/spec/database.travis.yml +5 -0
- data/spec/database.yml +8 -0
- data/spec/schema.rb +21 -0
- data/spec/spec_helper.rb +54 -0
- data/spec/support/models.rb +50 -0
- metadata +127 -0
@@ -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 @@
|
|
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
|