Skip to content

Commit 26e53f4

Browse files
authored
Merge pull request rails#51601 from ClearlyClaire/features/with-recursive
Add support for recursive CTEs in ActiveRecord
2 parents 6b67657 + fc26e44 commit 26e53f4

File tree

5 files changed

+110
-17
lines changed

5 files changed

+110
-17
lines changed

activerecord/CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,27 @@
1+
* Added support for recursive commont table expressions.
2+
3+
```ruby
4+
Post.with_recursive(
5+
post_and_replies: [
6+
Post.where(id: 42),
7+
Post.joins('JOIN post_and_replies ON posts.in_reply_to_id = post_and_replies.id'),
8+
]
9+
)
10+
```
11+
12+
Generates the following SQL:
13+
14+
```sql
15+
WITH RECURSIVE "post_and_replies" AS (
16+
(SELECT "posts".* FROM "posts" WHERE "posts"."id" = 42)
17+
UNION ALL
18+
(SELECT "posts".* FROM "posts" JOIN post_and_replies ON posts.in_reply_to_id = post_and_replies.id)
19+
)
20+
SELECT "posts".* FROM "posts"
21+
```
22+
23+
*ClearlyClaire*
24+
125
* `validate_constraint` can be called in a `change_table` block.
226

327
ex:

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, :async_ids, :strict_loading, :excluding, :without, :with,
20+
:pluck, :pick, :ids, :async_ids, :strict_loading, :excluding, :without, :with, :with_recursive,
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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def exec_explain(&block)
6060
:reverse_order, :distinct, :create_with, :skip_query_cache]
6161

6262
CLAUSE_METHODS = [:where, :having, :from]
63-
INVALID_METHODS_FOR_DELETE_ALL = [:distinct, :with]
63+
INVALID_METHODS_FOR_DELETE_ALL = [:distinct, :with, :with_recursive]
6464

6565
VALUE_METHODS = MULTI_VALUE_METHODS + SINGLE_VALUE_METHODS + CLAUSE_METHODS
6666

activerecord/lib/active_record/relation/query_methods.rb

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,17 @@ def _select!(*fields) # :nodoc:
435435
# # )
436436
# # SELECT * FROM posts
437437
#
438+
# You can also pass an array of sub-queries to be joined in a +UNION ALL+.
439+
#
440+
# Post.with(posts_with_tags_or_comments: [Post.where("tags_count > ?", 0), Post.where("comments_count > ?", 0)])
441+
# # => ActiveRecord::Relation
442+
# # WITH posts_with_tags_or_comments AS (
443+
# # (SELECT * FROM posts WHERE (tags_count > 0))
444+
# # UNION ALL
445+
# # (SELECT * FROM posts WHERE (comments_count > 0))
446+
# # )
447+
# # SELECT * FROM posts
448+
#
438449
# Once you define Common Table Expression you can use custom +FROM+ value or +JOIN+ to reference it.
439450
#
440451
# Post.with(posts_with_tags: Post.where("tags_count > ?", 0)).from("posts_with_tags AS posts")
@@ -475,7 +486,12 @@ def _select!(*fields) # :nodoc:
475486
def with(*args)
476487
raise ArgumentError, "ActiveRecord::Relation#with does not accept a block" if block_given?
477488
check_if_method_has_arguments!(__callee__, args)
478-
spawn.with!(*args)
489+
490+
if args.empty?
491+
WithChain.new(spawn)
492+
else
493+
spawn.with!(*args)
494+
end
479495
end
480496

481497
# Like #with, but modifies relation in place.
@@ -484,6 +500,30 @@ def with!(*args) # :nodoc:
484500
self
485501
end
486502

503+
# Add a recursive Common Table Expression (CTE) that you can then reference within another SELECT statement.
504+
#
505+
# Post.with_recursive(post_and_replies: [Post.where(id: 42), Post.joins('JOIN post_and_replies ON posts.in_reply_to_id = post_and_replies.id')])
506+
# # => ActiveRecord::Relation
507+
# # WITH post_and_replies AS (
508+
# # (SELECT * FROM posts WHERE id = 42)
509+
# # UNION ALL
510+
# # (SELECT * FROM posts JOIN posts_and_replies ON posts.in_reply_to_id = posts_and_replies.id)
511+
# # )
512+
# # SELECT * FROM posts
513+
#
514+
# See `#with` for more information.
515+
def with_recursive(*args)
516+
check_if_method_has_arguments!(__callee__, args)
517+
spawn.with_recursive!(*args)
518+
end
519+
520+
# Like #with_recursive but modifies the relation in place.
521+
def with_recursive!(*args) # :nodoc:
522+
self.with_values += args
523+
@with_is_recursive = true
524+
self
525+
end
526+
487527
# Allows you to change a previously set select statement.
488528
#
489529
# Post.select(:title, :body)
@@ -1846,20 +1886,23 @@ def build_with(arel)
18461886
build_with_value_from_hash(with_value)
18471887
end
18481888

1849-
arel.with(with_statements)
1889+
@with_is_recursive ? arel.with(:recursive, with_statements) : arel.with(with_statements)
18501890
end
18511891

18521892
def build_with_value_from_hash(hash)
18531893
hash.map do |name, value|
1854-
expression =
1855-
case value
1856-
when Arel::Nodes::SqlLiteral then Arel::Nodes::Grouping.new(value)
1857-
when ActiveRecord::Relation then value.arel
1858-
when Arel::SelectManager then value
1859-
else
1860-
raise ArgumentError, "Unsupported argument type: `#{value}` #{value.class}"
1861-
end
1862-
Arel::Nodes::TableAlias.new(expression, name)
1894+
Arel::Nodes::TableAlias.new(build_with_expression_from_value(value), name)
1895+
end
1896+
end
1897+
1898+
def build_with_expression_from_value(value)
1899+
case value
1900+
when Arel::Nodes::SqlLiteral then Arel::Nodes::Grouping.new(value)
1901+
when ActiveRecord::Relation then value.arel
1902+
when Arel::SelectManager then value
1903+
when Array then value.map { |q| build_with_expression_from_value(q) }.reduce { |result, value| result.union(:all, value) }
1904+
else
1905+
raise ArgumentError, "Unsupported argument type: `#{value}` #{value.class}"
18631906
end
18641907
end
18651908

activerecord/test/cases/relation/with_test.rb

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
require "cases/helper"
44
require "models/comment"
55
require "models/post"
6+
require "models/company"
67

78
module ActiveRecord
89
class WithTest < ActiveRecord::TestCase
9-
fixtures :comments
10-
fixtures :posts
10+
fixtures :comments, :posts, :companies
1111

1212
POSTS_WITH_TAGS = [1, 2, 7, 8, 9, 10, 11].freeze
1313
POSTS_WITH_COMMENTS = [1, 2, 4, 5, 7].freeze
@@ -57,9 +57,35 @@ def test_with_when_called_from_active_record_scope
5757
end
5858

5959
def test_with_when_invalid_params_are_passed
60-
assert_raise(ArgumentError) { Post.with }
6160
assert_raise(ArgumentError) { Post.with(posts_with_tags: nil).load }
62-
assert_raise(ArgumentError) { Post.with(posts_with_tags: [Post.where("tags_count > 0")]).load }
61+
assert_raise(ArgumentError) { Post.with(posts_with_tags: [Post.where("tags_count > 0"), 5]).load }
62+
end
63+
64+
def test_with_when_passing_arrays
65+
relation = Post
66+
.with(posts_with_tags_or_comments: [
67+
Post.where("tags_count > 0"),
68+
Post.where("legacy_comments_count > 0")
69+
])
70+
.from("posts_with_tags_or_comments AS posts")
71+
72+
assert_equal (POSTS_WITH_TAGS + POSTS_WITH_COMMENTS).sort, relation.order(:id).pluck(:id)
73+
end
74+
75+
def test_with_recursive
76+
top_companies = Company.where(firm_id: nil).to_a
77+
child_companies = Company.where(firm_id: top_companies).to_a
78+
top_companies_and_children = (top_companies.map(&:id) + child_companies.map(&:id)).sort
79+
80+
relation = Company.with_recursive(
81+
top_companies_and_children: [
82+
Company.where(firm_id: nil),
83+
Company.joins("JOIN top_companies_and_children ON companies.firm_id = top_companies_and_children.id"),
84+
]
85+
).from("top_companies_and_children AS companies")
86+
87+
assert_equal top_companies_and_children, relation.order(:id).pluck(:id)
88+
assert_match "WITH RECURSIVE", relation.to_sql
6389
end
6490

6591
def test_with_joins

0 commit comments

Comments
 (0)