Skip to content

Commit b3175b9

Browse files
authored
Merge pull request rails#37944 from vlado/with_cte
Common Table Expression support added "out-of-the-box"
2 parents dadf171 + 098b0eb commit b3175b9

File tree

9 files changed

+191
-4
lines changed

9 files changed

+191
-4
lines changed

activerecord/CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
* `.with` query method added. Construct common table expressions with ease and get `ActiveRecord::Relation` back.
2+
3+
```ruby
4+
Post.with(posts_with_comments: Post.where("comments_count > ?", 0))
5+
# => ActiveRecord::Relation
6+
# WITH posts_with_comments AS (SELECT * FROM posts WHERE (comments_count > 0)) SELECT * FROM posts
7+
```
8+
9+
*Vlado Cingel*
10+
111
* Don't establish a new connection if an identical pool exists already.
212
313
Previously, if `establish_connection` was called on a class that already had an established connection, the existing connection would be removed regardless of whether it was the same config. Now if a pool is found with the same values as the new connection, the existing connection will be returned instead of creating a new one.

activerecord/lib/active_record/querying.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ module Querying
1717
:and, :or, :annotate, :optimizer_hints, :extending,
1818
:having, :create_with, :distinct, :references, :none, :unscope, :merge, :except, :only,
1919
:count, :average, :minimum, :maximum, :sum, :calculate,
20-
:pluck, :pick, :ids, :strict_loading, :excluding, :without,
20+
:pluck, :pick, :ids, :strict_loading, :excluding, :without, :with,
2121
:async_count, :async_average, :async_minimum, :async_maximum, :async_sum, :async_pluck, :async_pick,
2222
].freeze # :nodoc:
2323
delegate(*QUERYING_METHODS, to: :all)

activerecord/lib/active_record/relation.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@ module ActiveRecord
55
class Relation
66
MULTI_VALUE_METHODS = [:includes, :eager_load, :preload, :select, :group,
77
:order, :joins, :left_outer_joins, :references,
8-
:extending, :unscope, :optimizer_hints, :annotate]
8+
:extending, :unscope, :optimizer_hints, :annotate,
9+
:with]
910

1011
SINGLE_VALUE_METHODS = [:limit, :offset, :lock, :readonly, :reordering, :strict_loading,
1112
:reverse_order, :distinct, :create_with, :skip_query_cache]
1213

1314
CLAUSE_METHODS = [:where, :having, :from]
14-
INVALID_METHODS_FOR_DELETE_ALL = [:distinct]
15+
INVALID_METHODS_FOR_DELETE_ALL = [:distinct, :with]
1516

1617
VALUE_METHODS = MULTI_VALUE_METHODS + SINGLE_VALUE_METHODS + CLAUSE_METHODS
1718

activerecord/lib/active_record/relation/query_methods.rb

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,63 @@ def _select!(*fields) # :nodoc:
324324
self
325325
end
326326

327+
# Add a Common Table Expression (CTE) that you can then reference within another SELECT statement.
328+
#
329+
# Post.with(posts_with_tags: Post.where("tags_count > ?", 0))
330+
# # => ActiveRecord::Relation
331+
# # WITH posts_with_tags AS (
332+
# # SELECT * FROM posts WHERE (tags_count > 0)
333+
# # )
334+
# # SELECT * FROM posts
335+
#
336+
# Once you define Common Table Expression you can use custom `FROM` value or `JOIN` to reference it.
337+
#
338+
# Post.with(posts_with_tags: Post.where("tags_count > ?", 0)).from("posts_with_tags AS posts")
339+
# # => ActiveRecord::Relation
340+
# # WITH posts_with_tags AS (
341+
# # SELECT * FROM posts WHERE (tags_count > 0)
342+
# # )
343+
# # SELECT * FROM posts_with_tags AS posts
344+
#
345+
# Post.with(posts_with_tags: Post.where("tags_count > ?", 0)).joins("JOIN posts_with_tags ON posts_with_tags.id = posts.id")
346+
# # => ActiveRecord::Relation
347+
# # WITH posts_with_tags AS (
348+
# # SELECT * FROM posts WHERE (tags_count > 0)
349+
# # )
350+
# # SELECT * FROM posts JOIN posts_with_tags ON posts_with_tags.id = posts.id
351+
#
352+
# It is recommended to pass a query as `ActiveRecord::Relation`. If that is not possible
353+
# and you have verified it is safe for the database, you can pass it as SQL literal
354+
# using `Arel`.
355+
#
356+
# Post.with(popular_posts: Arel.sql("... complex sql to calculate posts popularity ..."))
357+
#
358+
# Great caution should be taken to avoid SQL injection vulnerabilities. This method should not
359+
# be used with unsafe values that include unsanitized input.
360+
#
361+
# To add multiple CTEs just pass multiple key-value pairs
362+
#
363+
# Post.with(
364+
# posts_with_comments: Post.where("comments_count > ?", 0),
365+
# posts_with_tags: Post.where("tags_count > ?", 0)
366+
# )
367+
#
368+
# or chain multiple `.with` calls
369+
#
370+
# Post
371+
# .with(posts_with_comments: Post.where("comments_count > ?", 0))
372+
# .with(posts_with_tags: Post.where("tags_count > ?", 0))
373+
def with(*args)
374+
check_if_method_has_arguments!(__callee__, args)
375+
spawn.with!(*args)
376+
end
377+
378+
# Like #with, but modifies relation in place.
379+
def with!(*args) # :nodoc:
380+
self.with_values += args
381+
self
382+
end
383+
327384
# Allows you to change a previously set select statement.
328385
#
329386
# Post.select(:title, :body)
@@ -1379,6 +1436,7 @@ def build_arel(aliases = nil)
13791436
arel.group(*arel_columns(group_values.uniq)) unless group_values.empty?
13801437

13811438
build_order(arel)
1439+
build_with(arel)
13821440
build_select(arel)
13831441

13841442
arel.optimizer_hints(*optimizer_hints_values) unless optimizer_hints_values.empty?
@@ -1514,6 +1572,32 @@ def build_select(arel)
15141572
end
15151573
end
15161574

1575+
def build_with(arel)
1576+
return if with_values.empty?
1577+
1578+
with_statements = with_values.map do |with_value|
1579+
raise ArgumentError, "Unsupported argument type: #{with_value} #{with_value.class}" unless with_value.is_a?(Hash)
1580+
1581+
build_with_value_from_hash(with_value)
1582+
end
1583+
1584+
arel.with(with_statements)
1585+
end
1586+
1587+
def build_with_value_from_hash(hash)
1588+
hash.map do |name, value|
1589+
expression =
1590+
case value
1591+
when Arel::Nodes::SqlLiteral then Arel::Nodes::Grouping.new(value)
1592+
when ActiveRecord::Relation then value.arel
1593+
when Arel::SelectManager then value
1594+
else
1595+
raise ArgumentError, "Unsupported argument type: `#{value}` #{value.class}"
1596+
end
1597+
Arel::Nodes::TableAlias.new(expression, name)
1598+
end
1599+
end
1600+
15171601
def arel_columns(columns)
15181602
columns.flat_map do |field|
15191603
case field

activerecord/test/cases/relation/delete_all_test.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ def test_delete_all_with_group_by_and_having
7070

7171
def test_delete_all_with_unpermitted_relation_raises_error
7272
assert_raises(ActiveRecord::ActiveRecordError) { Author.distinct.delete_all }
73+
assert_raises(ActiveRecord::ActiveRecordError) { Author.with(limited: Author.limit(2)).delete_all }
7374
end
7475

7576
def test_delete_all_with_joins_and_where_part_is_hash

activerecord/test/cases/relation/merging_test.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,4 +393,30 @@ class MergingDifferentRelationsTest < ActiveRecord::TestCase
393393

394394
assert_equal dev.ratings, [rating_1]
395395
end
396+
397+
test "merging relation with common table expression" do
398+
posts_with_tags = Post.with(posts_with_tags: Post.where("tags_count > 0")).from("posts_with_tags AS posts")
399+
posts_with_comments = Post.where("legacy_comments_count > 0")
400+
relation = posts_with_comments.merge(posts_with_tags)
401+
402+
assert_equal [1, 2, 7], relation.pluck(:id)
403+
end
404+
405+
test "merging multiple relations with common table expression" do
406+
posts_with_tags = Post.with(posts_with_tags: Post.where("tags_count > 0"))
407+
posts_with_comments = Post.with(posts_with_comments: Post.where("legacy_comments_count > 0"))
408+
relation = posts_with_comments.merge(posts_with_tags).joins("JOIN posts_with_tags pwt ON pwt.id = posts.id JOIN posts_with_comments pwc ON pwc.id = posts.id")
409+
410+
assert_equal [1, 2, 7], relation.pluck(:id)
411+
end
412+
413+
test "relation merger leaves to database to decide what to do when multiple CTEs with same alias are passed" do
414+
posts_with_tags = Post.with(popular_posts: Post.where("tags_count > 0"))
415+
posts_with_comments = Post.with(popular_posts: Post.where("legacy_comments_count > 0"))
416+
relation = posts_with_tags.merge(posts_with_comments).joins("JOIN popular_posts pp ON pp.id = posts.id")
417+
418+
assert_raises ActiveRecord::StatementInvalid do
419+
relation.load
420+
end
421+
end
396422
end
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# frozen_string_literal: true
2+
3+
require "cases/helper"
4+
require "models/comment"
5+
require "models/post"
6+
7+
module ActiveRecord
8+
class WithTest < ActiveRecord::TestCase
9+
fixtures :comments
10+
fixtures :posts
11+
12+
POSTS_WITH_TAGS = [1, 2, 7, 8, 9, 10, 11].freeze
13+
POSTS_WITH_COMMENTS = [1, 2, 4, 5, 7].freeze
14+
POSTS_WITH_MULTIPLE_COMMENTS = [1, 4, 5].freeze
15+
POSTS_WITH_TAGS_AND_COMMENTS = (POSTS_WITH_COMMENTS & POSTS_WITH_TAGS).sort.freeze
16+
POSTS_WITH_TAGS_AND_MULTIPLE_COMMENTS = (POSTS_WITH_MULTIPLE_COMMENTS & POSTS_WITH_TAGS).sort.freeze
17+
18+
def test_with_when_hash_is_passed_as_an_argument
19+
relation = Post
20+
.with(posts_with_comments: Post.where("legacy_comments_count > 0"))
21+
.from("posts_with_comments AS posts")
22+
23+
assert_equal POSTS_WITH_COMMENTS, relation.order(:id).pluck(:id)
24+
end
25+
26+
def test_with_when_hash_with_multiple_elements_of_different_type_is_passed_as_an_argument
27+
cte_options = {
28+
posts_with_tags: Post.arel_table.project(Arel.star).where(Post.arel_table[:tags_count].gt(0)),
29+
posts_with_tags_and_comments: Arel.sql("SELECT * FROM posts_with_tags WHERE legacy_comments_count > 0"),
30+
"posts_with_tags_and_multiple_comments" => Post.where("legacy_comments_count > 1").from("posts_with_tags_and_comments AS posts")
31+
}
32+
relation = Post.with(cte_options).from("posts_with_tags_and_multiple_comments AS posts")
33+
34+
assert_equal POSTS_WITH_TAGS_AND_MULTIPLE_COMMENTS, relation.order(:id).pluck(:id)
35+
end
36+
37+
def test_multiple_with_calls
38+
relation = Post
39+
.with(posts_with_tags: Post.where("tags_count > 0"))
40+
.from("posts_with_tags_and_comments AS posts")
41+
.with(posts_with_tags_and_comments: Arel.sql("SELECT * FROM posts_with_tags WHERE legacy_comments_count > 0"))
42+
43+
assert_equal POSTS_WITH_TAGS_AND_COMMENTS, relation.order(:id).pluck(:id)
44+
end
45+
46+
def test_count_after_with_call
47+
relation = Post.with(posts_with_comments: Post.where("legacy_comments_count > 0"))
48+
49+
assert_equal Post.count, relation.count
50+
assert_equal POSTS_WITH_COMMENTS.size, relation.from("posts_with_comments AS posts").count
51+
assert_equal POSTS_WITH_COMMENTS.size, relation.joins("JOIN posts_with_comments ON posts_with_comments.id = posts.id").count
52+
end
53+
54+
def test_with_when_called_from_active_record_scope
55+
assert_equal POSTS_WITH_TAGS, Post.with_tags_cte.order(:id).pluck(:id)
56+
end
57+
58+
def test_with_when_invalid_params_are_passed
59+
assert_raise(ArgumentError) { Post.with }
60+
assert_raise(ArgumentError) { Post.with(posts_with_tags: nil).load }
61+
assert_raise(ArgumentError) { Post.with(posts_with_tags: [Post.where("tags_count > 0")]).load }
62+
end
63+
end
64+
end

activerecord/test/cases/relations_test.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2371,7 +2371,7 @@ def test_find_by_with_take_memoization
23712371
assert_empty authors
23722372
end
23732373

2374-
(ActiveRecord::Relation::MULTI_VALUE_METHODS - [:extending]).each do |method|
2374+
(ActiveRecord::Relation::MULTI_VALUE_METHODS - [:extending, :with]).each do |method|
23752375
test "#{method} with blank value" do
23762376
authors = Author.public_send(method, [""])
23772377
assert_empty authors.public_send(:"#{method}_values")

activerecord/test/models/post.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ def first_comment
6161

6262
scope :with_comments, -> { preload(:comments) }
6363
scope :with_tags, -> { preload(:taggings) }
64+
scope :with_tags_cte, -> { with(posts_with_tags: where("tags_count > 0")).from("posts_with_tags AS posts") }
6465

6566
scope :tagged_with, ->(id) { joins(:taggings).where(taggings: { tag_id: id }) }
6667
scope :tagged_with_comment, ->(comment) { joins(:taggings).where(taggings: { comment: comment }) }

0 commit comments

Comments
 (0)