Skip to content

Commit 23f8a56

Browse files
authored
refactor: convert SQL queries to Arel where feasible (#453)
1 parent f56f2e1 commit 23f8a56

File tree

6 files changed

+261
-83
lines changed

6 files changed

+261
-83
lines changed

lib/closure_tree/arel_helpers.rb

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# frozen_string_literal: true
2+
3+
module ClosureTree
4+
module ArelHelpers
5+
# Get model's arel table
6+
def model_table
7+
@model_table ||= model_class.arel_table
8+
end
9+
10+
# Get hierarchy table from a model class
11+
# This method should be called from instance methods where hierarchy_class is available
12+
def hierarchy_table_for(model)
13+
if model.respond_to?(:hierarchy_class)
14+
model.hierarchy_class.arel_table
15+
elsif model.class.respond_to?(:hierarchy_class)
16+
model.class.hierarchy_class.arel_table
17+
else
18+
raise ArgumentError, "Cannot find hierarchy_class for #{model}"
19+
end
20+
end
21+
22+
# Get hierarchy table using the model_class
23+
# This is for Support class methods
24+
def hierarchy_table
25+
@hierarchy_table ||= begin
26+
hierarchy_class_name = options[:hierarchy_class_name] || "#{model_class}Hierarchy"
27+
hierarchy_class_name.constantize.arel_table
28+
end
29+
end
30+
31+
# Helper to create an Arel node for a table with an alias
32+
def aliased_table(table, alias_name)
33+
table.alias(alias_name)
34+
end
35+
36+
# Build Arel queries for hierarchy operations
37+
def build_hierarchy_insert_query(hierarchy_table, node_id, parent_id)
38+
x = aliased_table(hierarchy_table, 'x')
39+
40+
# Build the SELECT subquery - use SelectManager
41+
select_query = Arel::SelectManager.new(x)
42+
select_query.project(
43+
x[:ancestor_id],
44+
Arel.sql(quote(node_id)),
45+
x[:generations] + 1
46+
)
47+
select_query.where(x[:descendant_id].eq(parent_id))
48+
49+
# Build the INSERT statement
50+
insert_manager = Arel::InsertManager.new
51+
insert_manager.into(hierarchy_table)
52+
insert_manager.columns << hierarchy_table[:ancestor_id]
53+
insert_manager.columns << hierarchy_table[:descendant_id]
54+
insert_manager.columns << hierarchy_table[:generations]
55+
insert_manager.select(select_query)
56+
57+
insert_manager
58+
end
59+
60+
def build_hierarchy_delete_query(hierarchy_table, id)
61+
# Build the innermost subquery
62+
inner_subquery_manager = Arel::SelectManager.new(hierarchy_table)
63+
inner_subquery_manager.project(hierarchy_table[:descendant_id])
64+
inner_subquery_manager.where(
65+
hierarchy_table[:ancestor_id].eq(id)
66+
.or(hierarchy_table[:descendant_id].eq(id))
67+
)
68+
inner_subquery = inner_subquery_manager.as('x')
69+
70+
# Build the middle subquery with DISTINCT
71+
middle_subquery = Arel::SelectManager.new
72+
middle_subquery.from(inner_subquery)
73+
middle_subquery.project(inner_subquery[:descendant_id]).distinct
74+
75+
# Build the DELETE statement
76+
delete_manager = Arel::DeleteManager.new
77+
delete_manager.from(hierarchy_table)
78+
delete_manager.where(hierarchy_table[:descendant_id].in(middle_subquery))
79+
80+
delete_manager
81+
end
82+
end
83+
end

lib/closure_tree/finders.rb

Lines changed: 72 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,24 @@ def find_or_create_by_path(path, attributes = {})
3737
end
3838

3939
def find_all_by_generation(generation_level)
40-
s = _ct.base_class.joins(<<-SQL.squish)
41-
INNER JOIN (
42-
SELECT descendant_id
43-
FROM #{_ct.quoted_hierarchy_table_name}
44-
WHERE ancestor_id = #{_ct.quote(id)}
45-
GROUP BY descendant_id
46-
HAVING MAX(#{_ct.quoted_hierarchy_table_name}.generations) = #{generation_level.to_i}
47-
) #{_ct.t_alias_keyword} descendants ON (#{_ct.quoted_table_name}.#{_ct.base_class.primary_key} = descendants.descendant_id)
48-
SQL
40+
hierarchy_table = self.class.hierarchy_class.arel_table
41+
model_table = self.class.arel_table
42+
43+
# Build the subquery
44+
descendants_subquery = hierarchy_table
45+
.project(hierarchy_table[:descendant_id])
46+
.where(hierarchy_table[:ancestor_id].eq(id))
47+
.group(hierarchy_table[:descendant_id])
48+
.having(hierarchy_table[:generations].maximum.eq(generation_level.to_i))
49+
.as('descendants')
50+
51+
# Build the join
52+
join_source = model_table
53+
.join(descendants_subquery)
54+
.on(model_table[_ct.base_class.primary_key].eq(descendants_subquery[:descendant_id]))
55+
.join_sources
56+
57+
s = _ct.base_class.joins(join_source)
4958
_ct.scope_with_order(s)
5059
end
5160

@@ -72,14 +81,23 @@ def root
7281
end
7382

7483
def leaves
75-
s = joins(<<-SQL.squish)
76-
INNER JOIN (
77-
SELECT ancestor_id
78-
FROM #{_ct.quoted_hierarchy_table_name}
79-
GROUP BY ancestor_id
80-
HAVING MAX(#{_ct.quoted_hierarchy_table_name}.generations) = 0
81-
) #{_ct.t_alias_keyword} leaves ON (#{_ct.quoted_table_name}.#{primary_key} = leaves.ancestor_id)
82-
SQL
84+
hierarchy_table = hierarchy_class.arel_table
85+
model_table = arel_table
86+
87+
# Build the subquery for leaves (nodes with no children)
88+
leaves_subquery = hierarchy_table
89+
.project(hierarchy_table[:ancestor_id])
90+
.group(hierarchy_table[:ancestor_id])
91+
.having(hierarchy_table[:generations].maximum.eq(0))
92+
.as('leaves')
93+
94+
# Build the join
95+
join_source = model_table
96+
.join(leaves_subquery)
97+
.on(model_table[primary_key].eq(leaves_subquery[:ancestor_id]))
98+
.join_sources
99+
100+
s = joins(join_source)
83101
_ct.scope_with_order(s.readonly(false))
84102
end
85103

@@ -123,22 +141,41 @@ def lowest_common_ancestor(*descendants)
123141
end
124142

125143
def find_all_by_generation(generation_level)
126-
s = joins(<<-SQL.squish)
127-
INNER JOIN (
128-
SELECT #{primary_key} as root_id
129-
FROM #{_ct.quoted_table_name}
130-
WHERE #{_ct.quoted_parent_column_name} IS NULL
131-
) #{_ct.t_alias_keyword} roots ON (1 = 1)
132-
INNER JOIN (
133-
SELECT ancestor_id, descendant_id
134-
FROM #{_ct.quoted_hierarchy_table_name}
135-
GROUP BY ancestor_id, descendant_id
136-
HAVING MAX(generations) = #{generation_level.to_i}
137-
) #{_ct.t_alias_keyword} descendants ON (
138-
#{_ct.quoted_table_name}.#{primary_key} = descendants.descendant_id
139-
AND roots.root_id = descendants.ancestor_id
140-
)
141-
SQL
144+
hierarchy_table = hierarchy_class.arel_table
145+
model_table = arel_table
146+
147+
# Build the roots subquery
148+
roots_subquery = model_table
149+
.project(model_table[primary_key].as('root_id'))
150+
.where(model_table[_ct.parent_column_sym].eq(nil))
151+
.as('roots')
152+
153+
# Build the descendants subquery
154+
descendants_subquery = hierarchy_table
155+
.project(
156+
hierarchy_table[:ancestor_id],
157+
hierarchy_table[:descendant_id]
158+
)
159+
.group(hierarchy_table[:ancestor_id], hierarchy_table[:descendant_id])
160+
.having(hierarchy_table[:generations].maximum.eq(generation_level.to_i))
161+
.as('descendants')
162+
163+
# Build the joins
164+
# Note: We intentionally use a cartesian product join (CROSS JOIN) here.
165+
# This allows us to find all nodes at a specific generation level across all root nodes.
166+
# The 1=1 condition creates this cartesian product in a database-agnostic way.
167+
join_roots = model_table
168+
.join(roots_subquery)
169+
.on(Arel.sql('1 = 1'))
170+
171+
join_descendants = join_roots
172+
.join(descendants_subquery)
173+
.on(
174+
model_table[primary_key].eq(descendants_subquery[:descendant_id])
175+
.and(roots_subquery[:root_id].eq(descendants_subquery[:ancestor_id]))
176+
)
177+
178+
s = joins(join_descendants.join_sources)
142179
_ct.scope_with_order(s)
143180
end
144181

@@ -151,6 +188,7 @@ def find_by_path(path, attributes = {}, parent_id = nil)
151188

152189
scope = where(path.pop)
153190
last_joined_table = _ct.table_name
191+
154192
path.reverse.each_with_index do |ea, idx|
155193
next_joined_table = "p#{idx}"
156194
scope = scope.joins(<<-SQL.squish)
@@ -161,6 +199,7 @@ def find_by_path(path, attributes = {}, parent_id = nil)
161199
scope = _ct.scoped_attributes(scope, ea, next_joined_table)
162200
last_joined_table = next_joined_table
163201
end
202+
164203
scope.where("#{last_joined_table}.#{_ct.parent_column_name}" => parent_id).readonly(false).first
165204
end
166205

lib/closure_tree/hash_tree_support.rb

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,40 @@ module HashTreeSupport
55
def default_tree_scope(scope, limit_depth = nil)
66
# Deepest generation, within limit, for each descendant
77
# NOTE: Postgres requires HAVING clauses to always contains aggregate functions (!!)
8-
having_clause = limit_depth ? "HAVING MAX(generations) <= #{limit_depth - 1}" : ''
9-
generation_depth = <<-SQL.squish
10-
INNER JOIN (
11-
SELECT descendant_id, MAX(generations) as depth
12-
FROM #{quoted_hierarchy_table_name}
13-
GROUP BY descendant_id
14-
#{having_clause}
15-
) #{t_alias_keyword} generation_depth
16-
ON #{quoted_table_name}.#{model_class.primary_key} = generation_depth.descendant_id
17-
SQL
18-
scope_with_order(scope.joins(generation_depth), 'generation_depth.depth')
8+
9+
# Get the hierarchy table for the scope's model class
10+
hierarchy_table_arel = if scope.respond_to?(:hierarchy_class)
11+
scope.hierarchy_class.arel_table
12+
elsif scope.klass.respond_to?(:hierarchy_class)
13+
scope.klass.hierarchy_class.arel_table
14+
else
15+
hierarchy_table
16+
end
17+
18+
model_table_arel = scope.klass.arel_table
19+
20+
# Build the subquery using Arel
21+
subquery = hierarchy_table_arel
22+
.project(
23+
hierarchy_table_arel[:descendant_id],
24+
hierarchy_table_arel[:generations].maximum.as('depth')
25+
)
26+
.group(hierarchy_table_arel[:descendant_id])
27+
28+
# Add HAVING clause if limit_depth is specified
29+
subquery = subquery.having(hierarchy_table_arel[:generations].maximum.lteq(limit_depth - 1)) if limit_depth
30+
31+
generation_depth_alias = subquery.as('generation_depth')
32+
33+
# Build the join
34+
join_condition = model_table_arel[scope.klass.primary_key].eq(generation_depth_alias[:descendant_id])
35+
36+
join_source = model_table_arel
37+
.join(generation_depth_alias)
38+
.on(join_condition)
39+
.join_sources
40+
41+
scope_with_order(scope.joins(join_source), 'generation_depth.depth')
1942
end
2043

2144
def hash_tree(tree_scope, limit_depth = nil)

lib/closure_tree/hierarchy_maintenance.rb

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -90,16 +90,10 @@ def delete_hierarchy_references
9090
# It shouldn't affect performance of postgresql.
9191
# See http://dev.mysql.com/doc/refman/5.0/en/subquery-errors.html
9292
# Also: PostgreSQL doesn't support INNER JOIN on DELETE, so we can't use that.
93-
_ct.connection.execute <<-SQL.squish
94-
DELETE FROM #{_ct.quoted_hierarchy_table_name}
95-
WHERE descendant_id IN (
96-
SELECT DISTINCT descendant_id
97-
FROM (SELECT descendant_id
98-
FROM #{_ct.quoted_hierarchy_table_name}
99-
WHERE ancestor_id = #{_ct.quote(id)}
100-
OR descendant_id = #{_ct.quote(id)}
101-
) #{_ct.t_alias_keyword} x )
102-
SQL
93+
94+
hierarchy_table = hierarchy_class.arel_table
95+
delete_query = _ct.build_hierarchy_delete_query(hierarchy_table, id)
96+
_ct.connection.execute(delete_query.to_sql)
10397
end
10498
end
10599

0 commit comments

Comments
 (0)