Skip to content

Commit 478712e

Browse files
brunodccarvalhobyroot
authored andcommitted
Support UPDATE outer joins on PostgreSQL and SQLite with a self-join transform
When an OUTER JOIN references the updated table in the ON clause, the join condition cannot be moved to the WHERE clause. Support this case by duplicating the updated table into the FROM clause with a perfect self-join on the pk. Roughly, transform a hypothetical UPDATE "products" LEFT JOIN "categories" ON "products"."category_id" = "categories"."id" ... other joins ... WHERE ... into UPDATE "products" "alias" FROM "products" LEFT JOIN "categories" ON "products"."category_id" = "categories"."id" ... other joins ... WHERE "alias"."id" = "products"."id" AND ... This reinterprets top-level FULL or RIGHT OUTER JOIN as a LEFT or INNER JOIN. This should be ok since we interpret the internal AST as joining with the updated table, and rows with no "product" can be ignored in this context. Unfortunately this introduces an ambiguity when the SET or WHERE references an unqualified column of "products". It can be qualified as "products"."column", it will reference the introduced table and the problem will be fixed. This shouldn't happen with AR generated nodes. Then PostgreSQL and SQLite use a subquery only when a LIMIT, OFFSET, ORDER BY, GROUP BY, or HAVING is present, like MySQL does.
1 parent 31ed8ab commit 478712e

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)