Skip to content

Commit 82cfd9a

Browse files
authored
Merge pull request rails#54722 from brunodccarvalho/repeat-in-join-clause
Use a self-join for UPDATE with outer joins on PostgreSQL and SQLite
2 parents 31ed8ab + 478712e commit 82cfd9a

File tree

4 files changed

+99
-79
lines changed

4 files changed

+99
-79
lines changed

activerecord/lib/arel/visitors/postgresql.rb

Lines changed: 24 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -12,46 +12,21 @@ def visit_Arel_Nodes_UpdateStatement(o, collector)
1212

1313
# UPDATE with JOIN is in the form of:
1414
#
15-
# UPDATE t1
15+
# UPDATE t1 AS __active_record_update_alias
1616
# SET ..
17-
# FROM t2
18-
# WHERE t1.join_id = t2.join_id
19-
#
20-
# Or if more than one join is present:
21-
#
22-
# UPDATE t1
23-
# SET ..
24-
# FROM t2
25-
# JOIN t3 ON t2.join_id = t3.join_id
26-
# WHERE t1.join_id = t2.join_id
17+
# FROM t1 JOIN t2 ON t2.join_id = t1.join_id ..
18+
# WHERE t1.id = __active_record_update_alias.id AND ..
2719
if has_join_sources?(o)
28-
visit o.relation.left, collector
20+
collector = visit o.relation.left, collector
2921
collect_nodes_for o.values, collector, " SET "
3022
collector << " FROM "
31-
first_join, *remaining_joins = o.relation.right
32-
from_items = remaining_joins.extract! do |join|
33-
join.right.expr.right.relation == o.relation.left
34-
end
35-
36-
from_where = [first_join.left] + from_items.map(&:left)
37-
collect_nodes_for from_where, collector, " ", ", "
38-
39-
if remaining_joins && !remaining_joins.empty?
40-
collector << " "
41-
remaining_joins.each do |join|
42-
visit join, collector
43-
collector << " "
44-
end
45-
end
46-
47-
from_where = [first_join.right.expr] + from_items.map { |i| i.right.expr }
48-
collect_nodes_for from_where + o.wheres, collector, " WHERE ", " AND "
23+
collector = inject_join o.relation.right, collector, " "
4924
else
5025
collector = visit o.relation, collector
5126
collect_nodes_for o.values, collector, " SET "
52-
collect_nodes_for o.wheres, collector, " WHERE ", " AND "
5327
end
5428

29+
collect_nodes_for o.wheres, collector, " WHERE ", " AND "
5530
collect_nodes_for o.orders, collector, " ORDER BY "
5631
maybe_visit o.limit, collector
5732
end
@@ -60,12 +35,18 @@ def visit_Arel_Nodes_UpdateStatement(o, collector)
6035
# query. However, this does not allow for LIMIT, OFFSET and ORDER. To support
6136
# these, we must use a subquery.
6237
def prepare_update_statement(o)
63-
if has_join_sources?(o) && !has_limit_or_offset_or_orders?(o) && !has_group_by_and_having?(o) &&
64-
# The PostgreSQL dialect isn't flexible enough to allow anything other than a inner join
65-
# for the first join:
66-
# UPDATE table SET .. FROM joined_table WHERE ...
67-
(o.relation.right.all? { |join| join.is_a?(Arel::Nodes::InnerJoin) || join.right.expr.right.relation != o.relation.left })
68-
o
38+
if o.key && has_join_sources?(o) && !has_group_by_and_having?(o) && !has_limit_or_offset_or_orders?(o)
39+
# Join clauses cannot reference the target table, so alias the
40+
# updated table, place the entire relation in the FROM clause, and
41+
# add a self-join (which requires the primary key)
42+
stmt = o.clone
43+
stmt.relation, stmt.wheres = o.relation.clone, o.wheres.clone
44+
stmt.relation.right = [stmt.relation.left, *stmt.relation.right]
45+
stmt.relation.left = stmt.relation.left.alias("__active_record_update_alias")
46+
Array.wrap(o.key).each do |key|
47+
stmt.wheres << key.eq(stmt.relation.left[key.name])
48+
end
49+
stmt
6950
else
7051
super
7152
end
@@ -133,6 +114,12 @@ def visit_Arel_Nodes_Lateral(o, collector)
133114
grouping_parentheses o.expr, collector
134115
end
135116

117+
def visit_Arel_Nodes_InnerJoin(o, collector)
118+
return super if o.right
119+
collector << "CROSS JOIN "
120+
visit o.left, collector
121+
end
122+
136123
def visit_Arel_Nodes_IsNotDistinctFrom(o, collector)
137124
collector = visit o.left, collector
138125
collector << " IS NOT DISTINCT FROM "

activerecord/lib/arel/visitors/sqlite.rb

Lines changed: 25 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -12,65 +12,52 @@ def visit_Arel_Nodes_UpdateStatement(o, collector)
1212

1313
# UPDATE with JOIN is in the form of:
1414
#
15-
# UPDATE t1
15+
# UPDATE t1 AS __active_record_update_alias
1616
# SET ..
17-
# FROM t2
18-
# WHERE t1.join_id = t2.join_id
19-
#
20-
# Or if more than one join is present:
21-
#
22-
# UPDATE t1
23-
# SET ..
24-
# FROM t2
25-
# JOIN t3 ON t2.join_id = t3.join_id
26-
# WHERE t1.join_id = t2.join_id
17+
# FROM t1 JOIN t2 ON t2.join_id = t1.join_id ..
18+
# WHERE t1.id = __active_record_update_alias.id AND ..
2719
if has_join_sources?(o)
28-
visit o.relation.left, collector
20+
collector = visit o.relation.left, collector
2921
collect_nodes_for o.values, collector, " SET "
30-
3122
collector << " FROM "
32-
first_join, *remaining_joins = o.relation.right
33-
from_items = remaining_joins.extract! do |join|
34-
join.right.expr.right.relation == o.relation.left
35-
end
36-
37-
from_where = [first_join.left] + from_items.map(&:left)
38-
collect_nodes_for from_where, collector, " ", ", "
39-
40-
if remaining_joins && !remaining_joins.empty?
41-
collector << " "
42-
remaining_joins.each do |join|
43-
visit join, collector
44-
collector << " "
45-
end
46-
end
47-
48-
from_where = [first_join.right.expr] + from_items.map { |i| i.right.expr }
49-
collect_nodes_for from_where + o.wheres, collector, " WHERE ", " AND "
23+
collector = inject_join o.relation.right, collector, " "
5024
else
5125
collector = visit o.relation, collector
5226
collect_nodes_for o.values, collector, " SET "
53-
collect_nodes_for o.wheres, collector, " WHERE ", " AND "
5427
end
5528

29+
collect_nodes_for o.wheres, collector, " WHERE ", " AND "
5630
collect_nodes_for o.orders, collector, " ORDER BY "
5731
maybe_visit o.limit, collector
5832
end
5933

6034
def prepare_update_statement(o)
6135
# Sqlite need to be built with the SQLITE_ENABLE_UPDATE_DELETE_LIMIT compile-time option
6236
# to support LIMIT/OFFSET/ORDER in UPDATE and DELETE statements.
63-
if has_join_sources?(o) && !has_limit_or_offset_or_orders?(o) && !has_group_by_and_having?(o) &&
64-
# The SQLite3 dialect isn't flexible enough to allow anything other than a inner join
65-
# for the first join:
66-
# UPDATE table SET .. FROM joined_table WHERE ...
67-
(o.relation.right.all? { |join| join.is_a?(Arel::Nodes::InnerJoin) || join.right.expr.right.relation != o.relation.left })
68-
o
37+
if o.key && has_join_sources?(o) && !has_group_by_and_having?(o) && !has_limit_or_offset_or_orders?(o)
38+
# Join clauses cannot reference the target table, so alias the
39+
# updated table, place the entire relation in the FROM clause, and
40+
# add a self-join (which requires the primary key)
41+
stmt = o.clone
42+
stmt.relation, stmt.wheres = o.relation.clone, o.wheres.clone
43+
stmt.relation.right = [stmt.relation.left, *stmt.relation.right]
44+
stmt.relation.left = stmt.relation.left.alias("__active_record_update_alias")
45+
Array.wrap(o.key).each do |key|
46+
stmt.wheres << key.eq(stmt.relation.left[key.name])
47+
end
48+
stmt
6949
else
7050
super
7151
end
7252
end
7353

54+
def visit_Arel_Nodes_TableAlias(o, collector)
55+
# "AS" is not optional in "{UPDATE | DELETE} table AS alias ..."
56+
collector = visit o.relation, collector
57+
collector << " AS "
58+
collector << quote_table_name(o.name)
59+
end
60+
7461
# Locks are not supported in SQLite
7562
def visit_Arel_Nodes_Lock(o, collector)
7663
collector

activerecord/test/cases/relation/update_all_test.rb

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -171,11 +171,57 @@ def test_update_all_with_left_joins
171171
assert_equal pets.count, pets.update_all(name: "Bob")
172172
end
173173

174-
def test_update_all_with_left_outer_joins
175-
pets = Pet.left_outer_joins(:toys)
174+
def test_update_all_with_left_outer_joins_can_reference_joined_table
175+
pets = Pet.left_outer_joins(:toys).where(toys: { name: ["Bone", nil] })
176176

177177
assert_equal true, pets.exists?
178-
assert_equal pets.count, pets.update_all(name: "Boby")
178+
assert_equal pets.count, pets.update_all(name: Arel.sql("COALESCE(toys.name, 'Toyless')"))
179+
assert_equal "Toyless", Pet.where.missing(:toys).first.name
180+
assert_not_equal "Toyless", Pet.joins(:toys).first.name
181+
end
182+
183+
def test_update_all_with_string_joins_can_reference_joined_table
184+
join = current_adapter?(:Mysql2Adapter, :TrilogyAdapter) ? "LEFT OUTER JOIN" : "FULL OUTER JOIN"
185+
pets = Pet.joins("#{join} toys ON toys.pet_id = pets.pet_id").where(toys: { name: ["Bone", nil] })
186+
187+
assert_equal true, pets.exists?
188+
assert_equal pets.count, pets.update_all(name: Arel.sql("COALESCE(toys.name, 'Toyless')"))
189+
assert_equal "Toyless", Pet.where.missing(:toys).first.name
190+
assert_not_equal "Toyless", Pet.joins(:toys).first.name
191+
end
192+
193+
def test_update_all_with_self_left_joins_can_reference_joined_table
194+
lvl2 = Comment.left_joins(parent: :parent).joins(:post).where(parent: { parent: nil }).where.not(parent: nil)
195+
196+
assert_equal true, lvl2.exists?
197+
assert_equal lvl2.count, lvl2.update_all(body: Arel.sql("COALESCE(parent.body, posts.title)"))
198+
end
199+
200+
def test_update_all_with_left_joins_composite_primary_key_can_reference_joined_table
201+
orders = Cpk::Order.left_joins(:order_agreements).where(order_agreements: { order_id: nil })
202+
203+
assert_equal true, orders.exists?
204+
assert_equal orders.count, orders.update_all(status: Arel.sql("COALESCE(order_agreements.signature, 'orphan')"))
205+
assert_equal orders.count, Cpk::Order.where(status: "orphan").count
206+
end
207+
208+
# Limitations of the implementation
209+
if current_adapter?(:SQLite3Adapter, :PostgreSQLAdapter)
210+
def test_update_all_with_left_joins_unqualified_set_reference_is_ambiguous
211+
orders = Cpk::Order.left_joins(:order_agreements).where(order_agreements: { order_id: nil })
212+
213+
assert_raises(ActiveRecord::StatementInvalid, match: /ambiguous/) do
214+
orders.update_all(status: Arel.sql("CONCAT(\"status\", 'orphan')"))
215+
end
216+
end
217+
218+
def test_update_all_with_left_joins_unqualified_where_reference_is_ambiguous
219+
orders = Cpk::Order.left_joins(:order_agreements).where(order_agreements: { order_id: nil })
220+
221+
assert_raises(ActiveRecord::StatementInvalid, match: /ambiguous/) do
222+
orders.where(Arel.sql("\"status\" != '123'")).update_all(status: "123")
223+
end
224+
end
179225
end
180226

181227
def test_update_all_with_includes

activerecord/test/cases/relation_test.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ def test_relation_merging_with_merged_symbol_joins_is_aliased
268268
assert_equal 3, nb_inner_join, "Wrong amount of INNER JOIN in query"
269269

270270
# using `\W` as the column separator
271-
assert queries.any? { |sql| %r[INNER\s+JOIN\s+#{Regexp.escape(Author.quoted_table_name)}\s+\Wauthors_categorizations\W]i.match?(sql) }, "Should be aliasing the child INNER JOINs in query"
271+
assert queries.any? { |sql| %r[INNER\s+JOIN\s+#{Regexp.escape(Author.quoted_table_name)}\s+(AS\s+)?\Wauthors_categorizations\W]i.match?(sql) }, "Should be aliasing the child INNER JOINs in query"
272272
end
273273

274274
def test_relation_with_merged_joins_aliased_works

0 commit comments

Comments
 (0)