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,54 @@
|
|
1
|
+
module ActiveRecord
|
2
|
+
module HierarchicalQuery
|
3
|
+
module CTE
|
4
|
+
class CycleDetector
|
5
|
+
COLUMN_NAME = '__path'.freeze
|
6
|
+
|
7
|
+
delegate :klass, :table, to: :@query
|
8
|
+
|
9
|
+
# @param [ActiveRecord::HierarchicalQuery::Query] query
|
10
|
+
def initialize(query)
|
11
|
+
@query = query
|
12
|
+
end
|
13
|
+
|
14
|
+
def apply_to_non_recursive(arel)
|
15
|
+
if enabled?
|
16
|
+
arel.project Arel::Nodes::PostgresArray.new([primary_key]).as(column_name)
|
17
|
+
end
|
18
|
+
|
19
|
+
arel
|
20
|
+
end
|
21
|
+
|
22
|
+
def apply_to_recursive(arel)
|
23
|
+
if enabled?
|
24
|
+
arel.project Arel::Nodes::ArrayConcat.new(parent_column, primary_key)
|
25
|
+
arel.constraints << Arel::Nodes::Not.new(primary_key.eq(any(parent_column)))
|
26
|
+
end
|
27
|
+
|
28
|
+
arel
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
def enabled?
|
33
|
+
@query.nocycle_value
|
34
|
+
end
|
35
|
+
|
36
|
+
def column_name
|
37
|
+
COLUMN_NAME
|
38
|
+
end
|
39
|
+
|
40
|
+
def parent_column
|
41
|
+
@query.recursive_table[column_name]
|
42
|
+
end
|
43
|
+
|
44
|
+
def primary_key
|
45
|
+
table[klass.primary_key]
|
46
|
+
end
|
47
|
+
|
48
|
+
def any(argument)
|
49
|
+
Arel::Nodes::NamedFunction.new('ANY', [argument])
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
|
3
|
+
module ActiveRecord
|
4
|
+
module HierarchicalQuery
|
5
|
+
module CTE
|
6
|
+
class NonRecursiveTerm
|
7
|
+
DISALLOWED_CLAUSES = :order, :limit, :offset, :group, :having
|
8
|
+
|
9
|
+
attr_reader :builder
|
10
|
+
delegate :query, to: :builder
|
11
|
+
|
12
|
+
# @param [ActiveRecord::HierarchicalQuery::CTE::QueryBuilder] builder
|
13
|
+
def initialize(builder)
|
14
|
+
@builder = builder
|
15
|
+
end
|
16
|
+
|
17
|
+
def bind_values
|
18
|
+
scope.bound_attributes
|
19
|
+
end
|
20
|
+
|
21
|
+
def arel
|
22
|
+
arel = scope.arel
|
23
|
+
|
24
|
+
builder.cycle_detector.apply_to_non_recursive(arel)
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
def scope
|
29
|
+
@scope ||= query.
|
30
|
+
start_with_value.
|
31
|
+
select(columns).
|
32
|
+
except(*DISALLOWED_CLAUSES).
|
33
|
+
reorder(nil)
|
34
|
+
end
|
35
|
+
|
36
|
+
def columns
|
37
|
+
columns = builder.columns.to_a
|
38
|
+
|
39
|
+
if query.orderings.any?
|
40
|
+
columns << ordering
|
41
|
+
end
|
42
|
+
|
43
|
+
columns
|
44
|
+
end
|
45
|
+
|
46
|
+
def ordering
|
47
|
+
value = query.orderings.row_number_expression
|
48
|
+
column_name = query.ordering_column_name
|
49
|
+
|
50
|
+
Arel::Nodes::PostgresArray.new([value]).as(column_name)
|
51
|
+
end
|
52
|
+
end # class NonRecursiveTerm
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
|
3
|
+
require 'active_record/hierarchical_query/cte/columns'
|
4
|
+
require 'active_record/hierarchical_query/cte/cycle_detector'
|
5
|
+
require 'active_record/hierarchical_query/cte/union_term'
|
6
|
+
|
7
|
+
module ActiveRecord
|
8
|
+
module HierarchicalQuery
|
9
|
+
module CTE
|
10
|
+
# CTE query builder
|
11
|
+
class QueryBuilder
|
12
|
+
attr_reader :query,
|
13
|
+
:columns,
|
14
|
+
:cycle_detector,
|
15
|
+
:options
|
16
|
+
|
17
|
+
delegate :klass, :table, :recursive_table, to: :query
|
18
|
+
|
19
|
+
# @param [ActiveRecord::HierarchicalQuery::Query] query
|
20
|
+
def initialize(query, options: {})
|
21
|
+
@query = query
|
22
|
+
@columns = Columns.new(@query)
|
23
|
+
@cycle_detector = CycleDetector.new(@query)
|
24
|
+
@options = options
|
25
|
+
end
|
26
|
+
|
27
|
+
def bind_values
|
28
|
+
union_term.bind_values
|
29
|
+
end
|
30
|
+
|
31
|
+
# @return [Arel::SelectManager]
|
32
|
+
def build_arel
|
33
|
+
build_manager
|
34
|
+
build_select
|
35
|
+
build_limits
|
36
|
+
build_order
|
37
|
+
|
38
|
+
@arel
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
def build_manager
|
43
|
+
@arel = Arel::SelectManager.new(table).
|
44
|
+
with(:recursive, with_query).
|
45
|
+
from(recursive_table)
|
46
|
+
end
|
47
|
+
|
48
|
+
# "categories__recursive" AS (
|
49
|
+
# SELECT ... FROM "categories"
|
50
|
+
# UNION ALL
|
51
|
+
# SELECT ... FROM "categories"
|
52
|
+
# INNER JOIN "categories__recursive" ON ...
|
53
|
+
# )
|
54
|
+
def with_query
|
55
|
+
Arel::Nodes::As.new(recursive_table, union_term.arel)
|
56
|
+
end
|
57
|
+
|
58
|
+
def union_term
|
59
|
+
@union_term ||= UnionTerm.new(self, @options)
|
60
|
+
end
|
61
|
+
|
62
|
+
def build_select
|
63
|
+
if @query.distinct_value
|
64
|
+
@arel.project(recursive_table[Arel.star]).distinct
|
65
|
+
else
|
66
|
+
@arel.project(recursive_table[Arel.star])
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def build_limits
|
71
|
+
@arel.take(query.limit_value).skip(query.offset_value)
|
72
|
+
end
|
73
|
+
|
74
|
+
def build_order
|
75
|
+
@arel.order(order_column.asc) if should_order?
|
76
|
+
end
|
77
|
+
|
78
|
+
def should_order?
|
79
|
+
query.orderings.any? && (query.limit_value || query.offset_value)
|
80
|
+
end
|
81
|
+
|
82
|
+
def order_column
|
83
|
+
recursive_table[query.ordering_column_name]
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
module ActiveRecord
|
2
|
+
module HierarchicalQuery
|
3
|
+
module CTE
|
4
|
+
class RecursiveTerm
|
5
|
+
# @return [ActiveRecord::HierarchicalQuery::CTE::Query]
|
6
|
+
attr_reader :builder
|
7
|
+
|
8
|
+
delegate :query, to: :builder
|
9
|
+
|
10
|
+
# @param [ActiveRecord::HierarchicalQuery::CTE::QueryBuilder] builder
|
11
|
+
def initialize(builder)
|
12
|
+
@builder = builder
|
13
|
+
end
|
14
|
+
|
15
|
+
def bind_values
|
16
|
+
scope.bound_attributes
|
17
|
+
end
|
18
|
+
|
19
|
+
def arel
|
20
|
+
arel = scope.arel
|
21
|
+
.join(query.recursive_table).on(query.join_conditions)
|
22
|
+
|
23
|
+
builder.cycle_detector.apply_to_recursive(arel)
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
def scope
|
28
|
+
@scope ||= query.child_scope_value.select(columns).reorder(nil)
|
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)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
require 'active_record/hierarchical_query/cte/non_recursive_term'
|
2
|
+
require 'active_record/hierarchical_query/cte/recursive_term'
|
3
|
+
|
4
|
+
module ActiveRecord
|
5
|
+
module HierarchicalQuery
|
6
|
+
module CTE
|
7
|
+
class UnionTerm
|
8
|
+
# @param [ActiveRecord::HierarchicalQuery::CTE::QueryBuilder] builder
|
9
|
+
def initialize(builder, options = {})
|
10
|
+
@builder = builder
|
11
|
+
@union_type = options.fetch(:union_type, :all)
|
12
|
+
end
|
13
|
+
|
14
|
+
def bind_values
|
15
|
+
non_recursive_term.bind_values + recursive_term.bind_values
|
16
|
+
end
|
17
|
+
|
18
|
+
def arel
|
19
|
+
non_recursive_term.arel.union(union_type, recursive_term.arel)
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
attr_reader :union_type
|
24
|
+
|
25
|
+
def recursive_term
|
26
|
+
@rt ||= RecursiveTerm.new(@builder)
|
27
|
+
end
|
28
|
+
|
29
|
+
def non_recursive_term
|
30
|
+
@nrt ||= NonRecursiveTerm.new(@builder)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
require 'active_record/hierarchical_query/cte/query_builder'
|
2
|
+
|
3
|
+
module ActiveRecord
|
4
|
+
module HierarchicalQuery
|
5
|
+
class JoinBuilder
|
6
|
+
# @param [ActiveRecord::HierarchicalQuery::Query] query
|
7
|
+
# @param [ActiveRecord::Relation] join_to
|
8
|
+
# @param [#to_s] subquery_alias
|
9
|
+
# @param [Hash] options (:outer_join_hierarchical, :union_type)
|
10
|
+
def initialize(query, join_to, subquery_alias, options = {})
|
11
|
+
@query = query
|
12
|
+
@builder = CTE::QueryBuilder.new(query, options: options)
|
13
|
+
@relation = join_to
|
14
|
+
@alias = Arel::Table.new(subquery_alias)
|
15
|
+
@options = options
|
16
|
+
end
|
17
|
+
|
18
|
+
def build
|
19
|
+
if ActiveRecord.gem_version >= Gem::Version.new('5.2.0')
|
20
|
+
relation = @relation
|
21
|
+
|
22
|
+
# add ordering by "__order_column"
|
23
|
+
relation.order_values += order_columns if ordered?
|
24
|
+
|
25
|
+
relation = relation.joins(joined_arel_node)
|
26
|
+
|
27
|
+
relation
|
28
|
+
else
|
29
|
+
relation = @relation
|
30
|
+
|
31
|
+
# add ordering by "__order_column"
|
32
|
+
relation.order_values += order_columns if ordered?
|
33
|
+
|
34
|
+
relation = relation.joins(joined_arel_node)
|
35
|
+
|
36
|
+
# copy bound variables from inner subquery
|
37
|
+
relation.bind_values += bind_values
|
38
|
+
|
39
|
+
relation
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
def joined_arel_node
|
45
|
+
@options[:outer_join_hierarchical] == true ? outer_join : inner_join
|
46
|
+
end
|
47
|
+
|
48
|
+
def inner_join
|
49
|
+
Arel::Nodes::InnerJoin.new(aliased_subquery, constraint)
|
50
|
+
end
|
51
|
+
|
52
|
+
def outer_join
|
53
|
+
Arel::Nodes::OuterJoin.new(aliased_subquery, constraint)
|
54
|
+
end
|
55
|
+
|
56
|
+
def aliased_subquery
|
57
|
+
SubqueryAlias.new(subquery, @alias)
|
58
|
+
end
|
59
|
+
|
60
|
+
def subquery
|
61
|
+
Arel::Nodes::Grouping.new(cte_arel.ast)
|
62
|
+
end
|
63
|
+
|
64
|
+
def cte_arel
|
65
|
+
@cte_arel ||= @builder.build_arel
|
66
|
+
end
|
67
|
+
|
68
|
+
def constraint
|
69
|
+
Arel::Nodes::On.new(primary_key.eq(foreign_key))
|
70
|
+
end
|
71
|
+
|
72
|
+
def primary_key
|
73
|
+
@relation.table[@relation.klass.primary_key]
|
74
|
+
end
|
75
|
+
|
76
|
+
def custom_foreign_key
|
77
|
+
@options[:foreign_key]
|
78
|
+
end
|
79
|
+
|
80
|
+
def foreign_key
|
81
|
+
custom_foreign_key ? @alias[custom_foreign_key] : @alias[@query.klass.primary_key]
|
82
|
+
end
|
83
|
+
|
84
|
+
def bind_values
|
85
|
+
@builder.bind_values
|
86
|
+
end
|
87
|
+
|
88
|
+
def ordered?
|
89
|
+
@query.orderings.any?
|
90
|
+
end
|
91
|
+
|
92
|
+
def order_columns
|
93
|
+
[@query.recursive_table[@query.ordering_column_name].asc]
|
94
|
+
end
|
95
|
+
|
96
|
+
# This node is required to support joins to aliased Arel nodes
|
97
|
+
class SubqueryAlias < Arel::Nodes::As
|
98
|
+
attr_reader :table_name
|
99
|
+
|
100
|
+
def initialize(subquery, alias_node)
|
101
|
+
super
|
102
|
+
@table_name = alias_node.name
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
@@ -0,0 +1,119 @@
|
|
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
|
+
engine = table.class.engine
|
104
|
+
if engine == ActiveRecord::Base
|
105
|
+
columns =
|
106
|
+
if engine.connection.respond_to?(:schema_cache)
|
107
|
+
engine.connection.schema_cache.columns_hash(table.name)
|
108
|
+
else
|
109
|
+
engine.connection_pool.columns_hash[table.name]
|
110
|
+
end
|
111
|
+
else
|
112
|
+
columns = engine.columns_hash
|
113
|
+
end
|
114
|
+
|
115
|
+
columns[order_attribute.name.to_s]
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|