activerecord-hierarchical_query 0.0.2 → 0.0.5

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.
Files changed (28) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +59 -30
  3. data/lib/active_record/hierarchical_query.rb +11 -11
  4. data/lib/active_record/hierarchical_query/cte/columns.rb +2 -15
  5. data/lib/active_record/hierarchical_query/cte/cycle_detector.rb +54 -0
  6. data/lib/active_record/hierarchical_query/cte/non_recursive_term.rb +32 -11
  7. data/lib/active_record/hierarchical_query/cte/query_builder.rb +82 -0
  8. data/lib/active_record/hierarchical_query/cte/recursive_term.rb +27 -13
  9. data/lib/active_record/hierarchical_query/cte/union_term.rb +10 -6
  10. data/lib/active_record/hierarchical_query/join_builder.rb +31 -10
  11. data/lib/active_record/hierarchical_query/orderings.rb +113 -0
  12. data/lib/active_record/hierarchical_query/{builder.rb → query.rb} +66 -36
  13. data/lib/active_record/hierarchical_query/version.rb +1 -1
  14. data/lib/arel/nodes/postgresql.rb +26 -6
  15. data/spec/active_record/hierarchical_query_spec.rb +56 -32
  16. data/spec/database.yml +1 -9
  17. data/spec/schema.rb +2 -2
  18. data/spec/spec_helper.rb +2 -4
  19. data/spec/support/models.rb +4 -4
  20. metadata +28 -33
  21. data/lib/active_record/hierarchical_query/adapters.rb +0 -34
  22. data/lib/active_record/hierarchical_query/adapters/abstract.rb +0 -41
  23. data/lib/active_record/hierarchical_query/adapters/postgresql.rb +0 -19
  24. data/lib/active_record/hierarchical_query/cte/query.rb +0 -65
  25. data/lib/active_record/hierarchical_query/visitors/orderings.rb +0 -87
  26. data/lib/active_record/hierarchical_query/visitors/postgresql/cycle_detector.rb +0 -49
  27. data/lib/active_record/hierarchical_query/visitors/postgresql/orderings.rb +0 -70
  28. data/lib/active_record/hierarchical_query/visitors/visitor.rb +0 -17
@@ -3,29 +3,43 @@ module ActiveRecord
3
3
  module CTE
4
4
  class RecursiveTerm
5
5
  # @return [ActiveRecord::HierarchicalQuery::CTE::Query]
6
- attr_reader :query
6
+ attr_reader :builder
7
7
 
8
- delegate :recursive_table,
9
- :join_conditions,
10
- :adapter,
11
- :to => :query
8
+ delegate :query, to: :builder
12
9
 
13
- # @param [ActiveRecord::HierarchicalQuery::CTE::Query] query
14
- def initialize(query)
15
- @query = query
10
+ # @param [ActiveRecord::HierarchicalQuery::CTE::QueryBuilder] builder
11
+ def initialize(builder)
12
+ @builder = builder
13
+ end
14
+
15
+ def bind_values
16
+ scope.bind_values
16
17
  end
17
18
 
18
19
  def arel
19
- arel = scope.select(query.columns)
20
- .arel
21
- .join(recursive_table).on(join_conditions)
20
+ arel = scope.arel
21
+ .join(query.recursive_table).on(query.join_conditions)
22
22
 
23
- adapter.visit(:recursive, arel)
23
+ builder.cycle_detector.apply_to_recursive(arel)
24
24
  end
25
25
 
26
26
  private
27
27
  def scope
28
- query.builder.child_scope_value
28
+ @scope ||= query.child_scope_value.select(columns)
29
+ end
30
+
31
+ def columns
32
+ columns = builder.columns.to_a
33
+ columns << ordering if query.orderings.any?
34
+ columns
35
+ end
36
+
37
+ def ordering
38
+ column_name = query.ordering_column_name
39
+ left = query.recursive_table[column_name]
40
+ right = query.orderings.row_number_expression
41
+
42
+ Arel::Nodes::ArrayConcat.new(left, right)
29
43
  end
30
44
  end
31
45
  end
@@ -5,22 +5,26 @@ module ActiveRecord
5
5
  module HierarchicalQuery
6
6
  module CTE
7
7
  class UnionTerm
8
- # @param [ActiveRecord::HierarchicalQuery::CTE::Query] query
9
- def initialize(query)
10
- @query = query
8
+ # @param [ActiveRecord::HierarchicalQuery::CTE::QueryBuilder] builder
9
+ def initialize(builder)
10
+ @builder = builder
11
+ end
12
+
13
+ def bind_values
14
+ non_recursive_term.bind_values + recursive_term.bind_values
11
15
  end
12
16
 
13
17
  def arel
14
- non_recursive_term.union(:all, recursive_term)
18
+ non_recursive_term.arel.union(:all, recursive_term.arel)
15
19
  end
16
20
 
17
21
  private
18
22
  def recursive_term
19
- RecursiveTerm.new(@query).arel
23
+ @rt ||= RecursiveTerm.new(@builder)
20
24
  end
21
25
 
22
26
  def non_recursive_term
23
- NonRecursiveTerm.new(@query).arel
27
+ @nrt ||= NonRecursiveTerm.new(@builder)
24
28
  end
25
29
  end
26
30
  end
@@ -1,21 +1,26 @@
1
+ require 'active_record/hierarchical_query/cte/query_builder'
2
+
1
3
  module ActiveRecord
2
4
  module HierarchicalQuery
3
5
  class JoinBuilder
4
- delegate :adapter, :to => :@query
5
-
6
- # @param [ActiveRecord::HierarchicalQuery::CTE::Query] query
6
+ # @param [ActiveRecord::HierarchicalQuery::Query] query
7
7
  # @param [ActiveRecord::Relation] join_to
8
8
  # @param [#to_s] subquery_alias
9
9
  def initialize(query, join_to, subquery_alias)
10
10
  @query = query
11
+ @builder = CTE::QueryBuilder.new(query)
11
12
  @relation = join_to
12
13
  @alias = Arel::Table.new(subquery_alias, ActiveRecord::Base)
13
14
  end
14
15
 
15
16
  def build
16
17
  relation = @relation.joins(inner_join.to_sql)
18
+ # copy bound variables from inner subquery
19
+ relation.bind_values += bind_values
20
+ # add ordering by "__order_column"
21
+ relation.order_values += order_columns if ordered?
17
22
 
18
- adapter.visit(:joined_relation, relation)
23
+ relation
19
24
  end
20
25
 
21
26
  private
@@ -23,6 +28,22 @@ module ActiveRecord
23
28
  Arel::Nodes::InnerJoin.new(aliased_subquery, constraint)
24
29
  end
25
30
 
31
+ def aliased_subquery
32
+ Arel::Nodes::As.new(subquery, @alias)
33
+ end
34
+
35
+ def subquery
36
+ Arel::Nodes::Grouping.new(cte_arel.ast)
37
+ end
38
+
39
+ def cte_arel
40
+ @cte_arel ||= @builder.build_arel
41
+ end
42
+
43
+ def constraint
44
+ Arel::Nodes::On.new(primary_key.eq(foreign_key))
45
+ end
46
+
26
47
  def primary_key
27
48
  @relation.table[@relation.klass.primary_key]
28
49
  end
@@ -31,16 +52,16 @@ module ActiveRecord
31
52
  @alias[@query.klass.primary_key]
32
53
  end
33
54
 
34
- def constraint
35
- Arel::Nodes::On.new(primary_key.eq(foreign_key))
55
+ def bind_values
56
+ @builder.bind_values
36
57
  end
37
58
 
38
- def subquery
39
- Arel::Nodes::Grouping.new(@query.arel.ast)
59
+ def ordered?
60
+ @query.orderings.any?
40
61
  end
41
62
 
42
- def aliased_subquery
43
- Arel::Nodes::As.new(subquery, @alias)
63
+ def order_columns
64
+ [@query.recursive_table[@query.ordering_column_name].asc]
44
65
  end
45
66
  end
46
67
  end
@@ -0,0 +1,113 @@
1
+ module ActiveRecord
2
+ module HierarchicalQuery
3
+ class Orderings
4
+ NATURAL_SORT_TYPES = Set[
5
+ :integer, :float, :decimal,
6
+ :datetime, :timestamp, :time, :date,
7
+ :boolean, :itet, :cidr, :ltree
8
+ ]
9
+
10
+ include Enumerable
11
+
12
+ attr_reader :order_values, :table
13
+
14
+ # @param [Array] order_values
15
+ # @param [Arel::Table] table
16
+ def initialize(order_values, table)
17
+ @order_values, @table = order_values, table
18
+ @values = nil # cached orderings
19
+ end
20
+
21
+ def each(&block)
22
+ return @values.each(&block) if @values
23
+ return enum_for(__method__) unless block_given?
24
+
25
+ @values = []
26
+
27
+ order_values.each do |value|
28
+ Array.wrap(as_orderings(value)).each do |ordering|
29
+ @values << ordering
30
+
31
+ yield ordering
32
+ end
33
+ end
34
+
35
+ @values
36
+ end
37
+
38
+ # Returns order expression to be inserted into SELECT clauses of both
39
+ # non-recursive and recursive terms.
40
+ #
41
+ # @return [Arel::Nodes::Node] order expression
42
+ def row_number_expression
43
+ if raw_ordering?
44
+ order_attribute
45
+ else
46
+ Arel.sql("ROW_NUMBER() OVER (ORDER BY #{map(&:to_sql).join(', ')})")
47
+ end
48
+ end
49
+
50
+ private
51
+ def as_orderings(value)
52
+ case value
53
+ when Arel::Nodes::Ordering
54
+ value
55
+
56
+ when Arel::Nodes::Node, Arel::Attributes::Attribute
57
+ value.asc
58
+
59
+ when Symbol
60
+ table[value].asc
61
+
62
+ when Hash
63
+ value.map { |field, dir| table[field].send(dir) }
64
+
65
+ when String
66
+ value.split(',').map do |expr|
67
+ string_as_ordering(expr)
68
+ end
69
+
70
+ else
71
+ raise 'Unknown expression in ORDER BY SIBLINGS clause'
72
+ end
73
+ end
74
+
75
+ def string_as_ordering(expr)
76
+ expr.strip!
77
+
78
+ if expr.gsub!(/\bdesc\z/i, '')
79
+ Arel.sql(expr).desc
80
+ else
81
+ expr.gsub!(/\basc\z/i, '')
82
+ Arel.sql(expr).asc
83
+ end
84
+ end
85
+
86
+ def raw_ordering?
87
+ ordered_by_attribute? &&
88
+ (column = order_column) &&
89
+ NATURAL_SORT_TYPES.include?(column.type)
90
+ end
91
+
92
+ def ordered_by_attribute?
93
+ one? && first.ascending? && order_attribute.is_a?(Arel::Attributes::Attribute)
94
+ end
95
+
96
+ def order_attribute
97
+ first.expr
98
+ end
99
+
100
+ def order_column
101
+ table = order_attribute.relation
102
+
103
+ if table.engine == ActiveRecord::Base
104
+ columns = table.engine.connection_pool.columns_hash[table.name]
105
+ else
106
+ columns = table.engine.columns_hash
107
+ end
108
+
109
+ columns[order_attribute.name.to_s]
110
+ end
111
+ end
112
+ end
113
+ end
@@ -2,12 +2,16 @@
2
2
 
3
3
  require 'active_support/core_ext/array/extract_options'
4
4
 
5
- require 'active_record/hierarchical_query/cte/query'
5
+ require 'active_record/hierarchical_query/orderings'
6
6
  require 'active_record/hierarchical_query/join_builder'
7
+ require 'arel/nodes/postgresql'
7
8
 
8
9
  module ActiveRecord
9
10
  module HierarchicalQuery
10
- class Builder
11
+ class Query
12
+ # @api private
13
+ ORDERING_COLUMN_NAME = '__order_column'.freeze
14
+
11
15
  # @api private
12
16
  attr_reader :klass,
13
17
  :start_with_value,
@@ -19,15 +23,15 @@ module ActiveRecord
19
23
  :nocycle_value
20
24
 
21
25
  # @api private
22
- CHILD_SCOPE_METHODS = :where, :joins, :group, :having
26
+ CHILD_SCOPE_METHODS = :where, :joins, :group, :having, :bind
23
27
 
24
28
  def initialize(klass)
25
29
  @klass = klass
26
- @query = CTE::Query.new(self)
27
30
 
28
- @start_with_value = nil
31
+ # start with :all
32
+ @start_with_value = klass.__send__(HierarchicalQuery::DELEGATOR_SCOPE)
29
33
  @connect_by_value = nil
30
- @child_scope_value = klass
34
+ @child_scope_value = klass.__send__(HierarchicalQuery::DELEGATOR_SCOPE)
31
35
  @limit_value = nil
32
36
  @offset_value = nil
33
37
  @nocycle_value = false
@@ -38,43 +42,49 @@ module ActiveRecord
38
42
  #
39
43
  # @example When scope given
40
44
  # MyModel.join_recursive do |hierarchy|
41
- # hierarchy.start_with(MyModel.where(:parent_id => nil))
42
- # .connect_by(:id => :parent_id)
45
+ # hierarchy.start_with(MyModel.where(parent_id: nil))
46
+ # .connect_by(id: :parent_id)
43
47
  # end
44
48
  #
45
49
  # @example When Hash given
46
50
  # MyModel.join_recursive do |hierarchy|
47
- # hierarchy.start_with(:parent_id => nil)
48
- # .connect_by(:id => :parent_id)
51
+ # hierarchy.start_with(parent_id: nil)
52
+ # .connect_by(id: :parent_id)
49
53
  # end
50
54
  #
55
+ # @example When String given
56
+ # MyModel.join_recursive do |hierarchy|
57
+ # hierararchy.start_with('parent_id = ?', 1)
58
+ # .connect_by(id: :parent_id)
59
+ # end
60
+ #
51
61
  # @example When block given
52
62
  # MyModel.join_recursive do |hierarchy|
53
- # hierarchy.start_with { |root| root.where(:parent_id => nil) }
54
- # .connect_by(:id => :parent_id)
63
+ # hierarchy.start_with { |root| root.where(parent_id: nil) }
64
+ # .connect_by(id: :parent_id)
55
65
  # end
56
66
  #
57
67
  # @example When block with arity=0 given
58
68
  # MyModel.join_recursive do |hierarchy|
59
- # hierarchy.start_with { where(:parent_id => nil) }
60
- # .connect_by(:id => :parent_id)
69
+ # hierarchy.start_with { where(parent_id: nil) }
70
+ # .connect_by(id: :parent_id)
61
71
  # end
62
72
  #
63
73
  # @example Specify columns for root relation (PostgreSQL-specific)
64
74
  # MyModel.join_recursive do |hierarchy|
65
75
  # hierarchy.start_with { select('ARRAY[id] AS _path') }
66
- # .connect_by(:id => :parent_id)
67
- # .select('_path || id', :start_with => false) # `:start_with => false` tells not to include this expression into START WITH clause
76
+ # .connect_by(id: :parent_id)
77
+ # .select('_path || id', start_with: false) # `start_with: false` tells not to include this expression into START WITH clause
68
78
  # end
69
79
  #
70
- # @param [ActiveRecord::Relation, Hash, nil] scope root scope (optional).
71
- # @return [ActiveRecord::HierarchicalQuery::Builder] self
72
- def start_with(scope = nil, &block)
80
+ # @param [ActiveRecord::Relation, Hash, String, nil] scope root scope (optional).
81
+ # @return [ActiveRecord::HierarchicalQuery::Query] self
82
+ def start_with(scope = nil, *arguments, &block)
73
83
  raise ArgumentError, 'START WITH: scope or block expected, none given' unless scope || block
74
84
 
75
85
  case scope
76
- when Hash
77
- @start_with_value = klass.where(scope)
86
+ when Hash, String
87
+ @start_with_value = klass.where(scope, *arguments)
78
88
 
79
89
  when ActiveRecord::Relation
80
90
  @start_with_value = scope
@@ -103,7 +113,7 @@ module ActiveRecord
103
113
  # @example Specify relationship with Hash (traverse descendants)
104
114
  # MyModel.join_recursive do |hierarchy|
105
115
  # # join child rows with condition `parent.id = child.parent_id`
106
- # hierarchy.connect_by(:id => :parent_id)
116
+ # hierarchy.connect_by(id: :parent_id)
107
117
  # end
108
118
  #
109
119
  # @example Specify relationship with block (traverse descendants)
@@ -117,7 +127,7 @@ module ActiveRecord
117
127
  # @yieldparam [Arel::Table] parent parent rows table instance.
118
128
  # @yieldparam [Arel::Table] child child rows table instance.
119
129
  # @yieldreturn [Arel::Nodes::Node] relationship condition expressed as Arel node.
120
- # @return [ActiveRecord::HierarchicalQuery::Builder] self
130
+ # @return [ActiveRecord::HierarchicalQuery::Query] self
121
131
  def connect_by(conditions = nil, &block)
122
132
  # convert hash to block which returns Arel node
123
133
  if conditions
@@ -136,7 +146,7 @@ module ActiveRecord
136
146
  #
137
147
  # @param [Array<Symbol, String, Arel::Attributes::Attribute, Arel::Nodes::Node>] columns
138
148
  # @option columns [true, false] :start_with include given columns to START WITH clause (true by default)
139
- # @return [ActiveRecord::HierarchicalQuery::Builder] self
149
+ # @return [ActiveRecord::HierarchicalQuery::Query] self
140
150
  def select(*columns)
141
151
  options = columns.extract_options!
142
152
 
@@ -166,6 +176,7 @@ module ActiveRecord
166
176
  # @!method joins(*tables)
167
177
  # @!method group(*values)
168
178
  # @!method having(*conditions)
179
+ # @!method bind(value)
169
180
  CHILD_SCOPE_METHODS.each do |method|
170
181
  define_method(method) do |*args|
171
182
  @child_scope_value = @child_scope_value.public_send(method, *args)
@@ -177,7 +188,7 @@ module ActiveRecord
177
188
  # Specifies a limit for the number of records to retrieve.
178
189
  #
179
190
  # @param [Fixnum] value
180
- # @return [ActiveRecord::HierarchicalQuery::Builder] self
191
+ # @return [ActiveRecord::HierarchicalQuery::Query] self
181
192
  def limit(value)
182
193
  @limit_value = value
183
194
 
@@ -187,7 +198,7 @@ module ActiveRecord
187
198
  # Specifies the number of rows to skip before returning row
188
199
  #
189
200
  # @param [Fixnum] value
190
- # @return [ActiveRecord::HierarchicalQuery::Builder] self
201
+ # @return [ActiveRecord::HierarchicalQuery::Query] self
191
202
  def offset(value)
192
203
  @offset_value = value
193
204
 
@@ -198,18 +209,18 @@ module ActiveRecord
198
209
  #
199
210
  # @example
200
211
  # MyModel.join_recursive do |hierarchy|
201
- # hierarchy.connect_by(:id => :parent_id)
212
+ # hierarchy.connect_by(id: :parent_id)
202
213
  # .order_siblings(:name)
203
214
  # end
204
215
  #
205
216
  # @example
206
217
  # MyModel.join_recursive do |hierarchy|
207
- # hierarchy.connect_by(:id => :parent_id)
218
+ # hierarchy.connect_by(id: :parent_id)
208
219
  # .order_siblings('name DESC, created_at ASC')
209
220
  # end
210
221
  #
211
222
  # @param [<Symbol, String, Arel::Nodes::Node, Arel::Attributes::Attribute>] columns
212
- # @return [ActiveRecord::HierarchicalQuery::Builder] self
223
+ # @return [ActiveRecord::HierarchicalQuery::Query] self
213
224
  def order_siblings(*columns)
214
225
  @order_values += columns
215
226
 
@@ -221,7 +232,7 @@ module ActiveRecord
221
232
  # endless loops if your tree could contain cycles.
222
233
  #
223
234
  # @param [true, false] value
224
- # @return [ActiveRecord::HierarchicalQuery::Builder] self
235
+ # @return [ActiveRecord::HierarchicalQuery::Query] self
225
236
  def nocycle(value = true)
226
237
  @nocycle_value = value
227
238
  self
@@ -232,25 +243,26 @@ module ActiveRecord
232
243
  #
233
244
  # @example
234
245
  # MyModel.join_recursive do |hierarchy|
235
- # hierarchy.connect_by(:id => :parent_id)
236
- # .start_with(:parent_id => nil) { select(:depth) }
246
+ # hierarchy.connect_by(id: :parent_id)
247
+ # .start_with(parent_id: nil) { select(:depth) }
237
248
  # .select(hierarchy.table[:depth])
238
249
  # .where(hierarchy.prior[:depth].lteq 1)
239
250
  # end
240
251
  #
241
252
  # @return [Arel::Table]
242
253
  def prior
243
- @query.recursive_table
254
+ @recursive_table ||= Arel::Table.new("#{table.name}__recursive")
244
255
  end
245
256
  alias_method :previous, :prior
257
+ alias_method :recursive_table, :prior
246
258
 
247
259
  # Returns object representing child rows table,
248
260
  # so it could be used in complex WHEREs.
249
261
  #
250
262
  # @example
251
263
  # MyModel.join_recursive do |hierarchy|
252
- # hierarchy.connect_by(:id => :parent_id)
253
- # .start_with(:parent_id => nil) { select(:depth) }
264
+ # hierarchy.connect_by(id: :parent_id)
265
+ # .start_with(parent_id: nil) { select(:depth) }
254
266
  # .select(hierarchy.table[:depth])
255
267
  # .where(hierarchy.prior[:depth].lteq 1)
256
268
  # end
@@ -258,19 +270,37 @@ module ActiveRecord
258
270
  @klass.arel_table
259
271
  end
260
272
 
273
+ # @return [Arel::Nodes::Node]
274
+ # @api private
275
+ def join_conditions
276
+ connect_by_value.call(recursive_table, table)
277
+ end
278
+
279
+ # @return [ActiveRecord::HierarchicalQuery::Orderings]
280
+ # @api private
281
+ def orderings
282
+ @orderings ||= Orderings.new(order_values, table)
283
+ end
284
+
285
+ # @api private
286
+ def ordering_column_name
287
+ ORDERING_COLUMN_NAME
288
+ end
289
+
261
290
  # Builds recursive query and joins it to given +relation+.
262
291
  #
263
292
  # @api private
264
293
  # @param [ActiveRecord::Relation] relation
265
294
  # @param [Hash] join_options
266
295
  # @option join_options [#to_s] :as joined table alias
296
+ # @api private
267
297
  def join_to(relation, join_options = {})
268
298
  raise 'Recursive query requires CONNECT BY clause, please use #connect_by method' unless
269
299
  connect_by_value
270
300
 
271
301
  table_alias = join_options.fetch(:as, "#{table.name}__recursive")
272
302
 
273
- JoinBuilder.new(@query, relation, table_alias).build
303
+ JoinBuilder.new(self, relation, table_alias).build
274
304
  end
275
305
 
276
306
  private