Skip to content

Commit c50d860

Browse files
authored
Merge pull request rails#53950 from byroot/update-column-alias
Support joins in `update_all` for Postgresql and SQlite
2 parents 4e5a13a + a6bc4b2 commit c50d860

File tree

5 files changed

+168
-4
lines changed

5 files changed

+168
-4
lines changed

activerecord/CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,18 @@
1+
* Better support UPDATE with JOIN for Postgresql and SQLite3
2+
3+
Previously when generating update queries with one or more JOIN clauses,
4+
Active Record would use a sub query which would prevent to reference the joined
5+
tables in the `SET` clause, for instance:
6+
7+
```ruby
8+
Comment.joins(:post).update_all("title = posts.title")
9+
```
10+
11+
This is now supported as long as the relation doesn't also use a `LIMIT`, `ORDER` or
12+
`GROUP BY` clause. This was supported by the MySQL adapter for a long time.
13+
14+
*Jean Boussier*
15+
116
* Introduce a before-fork hook in `ActiveSupport::Testing::Parallelization` to clear existing
217
connections, to avoid fork-safety issues with the mysql2 adapter.
318

activerecord/lib/active_record/relation.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1407,7 +1407,7 @@ def _substitute_values(values)
14071407

14081408
def _increment_attribute(attribute, value = 1)
14091409
bind = predicate_builder.build_bind_attribute(attribute.name, value.abs)
1410-
expr = table.coalesce(Arel::Nodes::UnqualifiedColumn.new(attribute), 0)
1410+
expr = table.coalesce(attribute, 0)
14111411
expr = value < 0 ? expr - bind : expr + bind
14121412
expr.expr
14131413
end

activerecord/lib/arel/visitors/postgresql.rb

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,62 @@ module Arel # :nodoc: all
44
module Visitors
55
class PostgreSQL < Arel::Visitors::ToSql
66
private
7+
def visit_Arel_Nodes_UpdateStatement(o, collector)
8+
collector.retryable = false
9+
o = prepare_update_statement(o)
10+
11+
collector << "UPDATE "
12+
13+
# UPDATE with JOIN is in the form of:
14+
#
15+
# UPDATE t1
16+
# 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
27+
if has_join_sources?(o)
28+
visit o.relation.left, collector
29+
collect_nodes_for o.values, collector, " SET "
30+
collector << " FROM "
31+
first_join, *remaining_joins = o.relation.right
32+
visit first_join.left, collector
33+
34+
if remaining_joins && !remaining_joins.empty?
35+
collector << " "
36+
remaining_joins.each do |join|
37+
visit join, collector
38+
end
39+
end
40+
41+
collect_nodes_for [first_join.right.expr] + o.wheres, collector, " WHERE ", " AND "
42+
else
43+
collector = visit o.relation, collector
44+
collect_nodes_for o.values, collector, " SET "
45+
collect_nodes_for o.wheres, collector, " WHERE ", " AND "
46+
end
47+
48+
collect_nodes_for o.orders, collector, " ORDER BY "
49+
maybe_visit o.limit, collector
50+
end
51+
52+
# In the simple case, PostgreSQL allows us to place FROM or JOINs directly into the UPDATE
53+
# query. However, this does not allow for LIMIT, OFFSET and ORDER. To support
54+
# these, we must use a subquery.
55+
def prepare_update_statement(o)
56+
if has_join_sources?(o) && !has_limit_or_offset_or_orders?(o) && !has_group_by_and_having?(o)
57+
o
58+
else
59+
super
60+
end
61+
end
62+
763
def visit_Arel_Nodes_Matches(o, collector)
864
op = o.case_sensitive ? " LIKE " : " ILIKE "
965
collector = infix_value o, collector, op

activerecord/lib/arel/visitors/sqlite.rb

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,62 @@ module Arel # :nodoc: all
44
module Visitors
55
class SQLite < Arel::Visitors::ToSql
66
private
7+
def visit_Arel_Nodes_UpdateStatement(o, collector)
8+
collector.retryable = false
9+
o = prepare_update_statement(o)
10+
11+
collector << "UPDATE "
12+
13+
# UPDATE with JOIN is in the form of:
14+
#
15+
# UPDATE t1
16+
# 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
27+
if has_join_sources?(o)
28+
visit o.relation.left, collector
29+
collect_nodes_for o.values, collector, " SET "
30+
31+
collector << " FROM "
32+
first_join, *remaining_joins = o.relation.right
33+
visit first_join.left, collector
34+
35+
if remaining_joins && !remaining_joins.empty?
36+
collector << " "
37+
remaining_joins.each do |join|
38+
visit join, collector
39+
end
40+
end
41+
42+
collect_nodes_for [first_join.right.expr] + o.wheres, collector, " WHERE ", " AND "
43+
else
44+
collector = visit o.relation, collector
45+
collect_nodes_for o.values, collector, " SET "
46+
collect_nodes_for o.wheres, collector, " WHERE ", " AND "
47+
end
48+
49+
collect_nodes_for o.orders, collector, " ORDER BY "
50+
maybe_visit o.limit, collector
51+
end
52+
53+
def prepare_update_statement(o)
54+
# Sqlite need to be built with the SQLITE_ENABLE_UPDATE_DELETE_LIMIT compile-time option
55+
# to support LIMIT/OFFSET/ORDER in UPDATE and DELETE statements.
56+
if has_join_sources?(o) && !has_limit_or_offset_or_orders?(o) && !has_group_by_and_having?(o)
57+
o
58+
else
59+
super
60+
end
61+
end
62+
763
# Locks are not supported in SQLite
864
def visit_Arel_Nodes_Lock(o, collector)
965
collector

activerecord/test/cases/relation/update_all_test.rb

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
require "models/comment"
77
require "models/computer"
88
require "models/developer"
9+
require "models/owner"
910
require "models/post"
1011
require "models/person"
1112
require "models/pet"
@@ -17,7 +18,7 @@
1718
require "models/cpk"
1819

1920
class UpdateAllTest < ActiveRecord::TestCase
20-
fixtures :authors, :author_addresses, :comments, :developers, :posts, :people, :pets, :toys, :tags,
21+
fixtures :authors, :author_addresses, :comments, :developers, :owners, :posts, :people, :pets, :toys, :tags,
2122
:taggings, "warehouse-things", :cpk_orders, :cpk_order_agreements
2223

2324
class TopicWithCallbacks < ActiveRecord::Base
@@ -60,8 +61,8 @@ def test_update_all_with_group_by
6061
assert_not_equal "ig", post.title
6162
end
6263

63-
def test_update_all_with_joins
64-
pets = Pet.joins(:toys).where(toys: { name: "Bone" })
64+
def test_update_all_with_joins_and_limit
65+
pets = Pet.joins(:toys).where(toys: { name: "Bone" }).limit(2)
6566

6667
assert_equal true, pets.exists?
6768
sqls = capture_sql do
@@ -85,6 +86,42 @@ def test_update_all_with_unpermitted_relation_raises_error
8586
end
8687
end
8788

89+
def test_dynamic_update_all_with_one_joined_table
90+
update_fragment = if current_adapter?(:TrilogyAdapter, :Mysql2Adapter)
91+
"toys.name = pets.name"
92+
elsif current_adapter?(:PostgreSQLAdapter, :SQLite3Adapter)
93+
"name = pets.name"
94+
else
95+
raise NotImplementedError
96+
end
97+
98+
toys = Toy.joins(:pet)
99+
assert_equal 3, toys.count
100+
assert_equal 3, toys.update_all(update_fragment)
101+
102+
toys.each do |toy|
103+
assert_equal toy.pet.name, toy.name
104+
end
105+
end
106+
107+
def test_dynamic_update_all_with_two_joined_table
108+
update_fragment = if current_adapter?(:TrilogyAdapter, :Mysql2Adapter)
109+
"toys.name = owners.name"
110+
elsif current_adapter?(:PostgreSQLAdapter, :SQLite3Adapter)
111+
"name = owners.name"
112+
else
113+
raise NotImplementedError
114+
end
115+
116+
toys = Toy.joins(pet: [:owner])
117+
assert_equal 3, toys.count
118+
assert_equal 3, toys.update_all(update_fragment)
119+
120+
toys.each do |toy|
121+
assert_equal toy.pet.owner.name, toy.name
122+
end
123+
end
124+
88125
def test_update_all_with_left_joins
89126
pets = Pet.left_joins(:toys).where(toys: { name: "Bone" })
90127

0 commit comments

Comments
 (0)