Skip to content

Add support for materialized CTEs#54322

Open
Jell wants to merge 3 commits intorails:mainfrom
Jell:add-support-for-materialized-ctes
Open

Add support for materialized CTEs#54322
Jell wants to merge 3 commits intorails:mainfrom
Jell:add-support-for-materialized-ctes

Conversation

@Jell
Copy link

@Jell Jell commented Jan 21, 2025

Motivation / Background

This Pull Request has been created because I had the needed to add a MATERIALIZED hint to a Common Table Expression (CTE) in one of my rails applications, and that is not currently supported by the current implementation of ActiveRecord::QueryMethods#with.

Detail

The proposed change is split into 3 steps (I made a separate commit for each)

  1. Use Arel::Nodes::Cte instead of Arel::Nodes::TableAlias when building a CTE query
  2. Allow passing in an instance of Arel::Nodes::TableAlias to .with
  3. Add a .as_cte query method for convenience

This change allows for CTEs to be written like this:

Post.with(Post.where("comments_count > ?", 0).as_cte(:post_with_comment))
Post.with(Post.where("comments_count > ?", 0).as_cte(:post_with_comment, materialized: true))
Post.with(Post.where("comments_count > ?", 0).as_cte(:post_with_comment, materialized: false))
Post.with_recursive(Post.where("comments_count > ?", 0).as_cte(:post_with_comment))
Post.with_recursive(Post.where("comments_count > ?", 0).as_cte(:post_with_comment, materialized: true))
Post.with_recursive(Post.where("comments_count > ?", 0).as_cte(:post_with_comment, materialized: false))

Additional information

I was prompted to make a pull request with this proposal in on this thread: #48305 (comment)

Checklist

Before submitting the PR make sure the following are checked:

  • This Pull Request is related to one change. Unrelated changes should be opened in separate PRs.
  • Commit message has a detailed description of what changed and why. If this PR fixes a related issue include it in the commit message. Ex: [Fix #issue-number]
  • Tests are added or updated if you fix a bug or add a feature.
  • CHANGELOG files are updated for the changed libraries if there is a behavior change or additional feature. Minor bug fixes and documentation changes should not be included.

instead of Arel::Nodes::TableAlias.

Even though Arel::Nodes::Cte and Arel::Nodes::TableAlias appear
functionally equivalent here, Arel::Nodes::Cte is the semantically
correct type and is more flexible for this use case, since it will
allow us to add support for passing the "materialized" option to the CTE.
@Jell Jell force-pushed the add-support-for-materialized-ctes branch 5 times, most recently from a63fc5d to d648481 Compare January 21, 2025 20:48
@Jell
Copy link
Author

Jell commented Jan 21, 2025

@97jaz took me a couple of attempts to get the tests to pass and the docs to look reasonable, but the PR should be ready for review now!

Jell added 2 commits January 21, 2025 22:09
while remaining fully backward compatible.

Example:

```ruby
Post.with(
  Arel::Nodes::Cte.new(:post_with_comment, Post.where("comments_count > ?", 0).arel, materialized: true)
)
```
This allows for a nicer DSL for writting CTEs that is fully backward
compatible:

```ruby
Post.with(
  Post.where("comments_count > ?", 0).as_cte(:post_with_comment, materialized: true),
  posts_without_comments: Post.where("comments_count = ?", 0)
)
```

Also works with `with_recursive`:

```ruby
Post.with(Post.where("comments_count > ?", 0).as_cte(:post_with_comment))
Post.with(Post.where("comments_count > ?", 0).as_cte(:post_with_comment, materialized: true))
Post.with(Post.where("comments_count > ?", 0).as_cte(:post_with_comment, materialized: false))
Post.with_recursive(Post.where("comments_count > ?", 0).as_cte(:post_with_comment))
Post.with_recursive(Post.where("comments_count > ?", 0).as_cte(:post_with_comment, materialized: true))
Post.with_recursive(Post.where("comments_count > ?", 0).as_cte(:post_with_comment, materialized: false))
```
@Jell Jell force-pushed the add-support-for-materialized-ctes branch from d648481 to 2b155e2 Compare January 21, 2025 21:09
@97jaz
Copy link
Contributor

97jaz commented Jan 22, 2025

@Jell Thanks for doing this. In case you don't know, I don't have commit privileges in rails, so while I like this approach, I can't approve it. I do have a few comments that I'll leave inline.

Comment on lines +1925 to +1930
case with_value
when Arel::Nodes::Cte then with_value
when Hash then build_with_value_from_hash(with_value)
else
raise ArgumentError, "Unsupported argument type: #{with_value} #{with_value.class}"
end
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are three arel node types that have #to_cte methods: As, TableAlias, and Cte (which, of course, just returns self). I wonder if we should allow any object that supports #to_cte here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Though, if we are going to support this, I suppose it would be better to do so in #process_with_args instead of here, so that by the time we get to this code, all of those node types would be replaced with Ctes.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be best to do a separate PR for this if we want to support treating other types of arel nodes as valid arguments to with(). The goal of this PR is solely to find a way to have a DSL for materialized CTEs in ActiveRecord, trying to keep it as small and simple as possible.

Comment on lines +1605 to +1609
# Returns a Common Table Expression (CTE) from the relation to be
# used directly as input to +.with+.
def as_cte(subquery_alias, materialized: nil)
Arel::Nodes::Cte.new(subquery_alias, arel, materialized:)
end
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two thoughts:

  1. Given that the #arel method (which returns an Arel::SelectManager) is marked :nodoc:, I'd guess that this should be too.
  2. I wonder if the word "arel" should be in the method name, as in #as_arel_cte or maybe just #arel_cte for consistency with #arel.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rafaelfranca are we okay with moving Arel to the public API in a case like this?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my thinking here was that even though this returns an Arel instance, this is documented as something to pass to with, the fact that the specific type of that value is an Arel node does not make the Arel API public in my opinion.

so yes arel is not documented, but it is only used as an internal helper method here.

@fschwahn
Copy link
Contributor

I backported this into my rails app, and have been running this in production for some time. It's really useful, as I ran into queries which the query planner was picking the wrong path, resulting in extremely slow queries. Using a materialized view solved that.

@matthewd as you also commented on the original PR, maybe you can help push this (or the other PR?) along.

@camol
Copy link

camol commented Oct 29, 2025

It would be great to have this!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants