activerecord-hierarchical_query 0.0.2 → 0.0.5

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