Skip to content

Conversation

skipkayhil
Copy link
Member

Motivation / Background

Previously, retryable SqlLiterals passed to #where would lose their retryability because both #build_where_clause and WhereClause would wrap them in non-retryable SqlLiterals.

Detail

To fix this problem, this commit updates #build_where_clause to check for SqlLiterals and updates WhereClause to assume that any predicate passed in will already be wrapped.

Additional information

There were only a few places that passed Strings to WhereClause ("1=0" in PredicateBuilder and sanitized sql strings in #build_where_clause) that needed to be updated to use Arel.sql. The WhereClause tests also had Strings to wrap but they are really unit tests and aren't representative of what can be done with the public API

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.

elsif rest.first.is_a?(Hash) && /:\w+/.match?(opts)
parts = [build_named_bound_sql_literal(opts, rest.first)]
elsif opts.include?("?")
parts = [build_bound_sql_literal(opts, rest)]
else
parts = [model.sanitize_sql(rest.empty? ? opts : [opts, *rest])]
parts = [Arel.sql(model.sanitize_sql([opts, *rest]))]
Copy link
Member Author

Choose a reason for hiding this comment

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

The rest.empty? check appears to have been dead code since 8e6a5de

Comment on lines 1623 to 1627
if Arel.arel_node?(opts)
parts = [opts]
else
parts = [Arel.sql(opts)]
end
Copy link
Member

Choose a reason for hiding this comment

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

Shouldn't we just do that in Arel.sql? Right now it assumes it always receives a String, hence always wrap it, but we could make it return arel nodes unchanged.

Copy link
Member Author

Choose a reason for hiding this comment

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

I tried searching for usages of Arel.sql that would pass a SqlLiteral, but it seems like there aren't many (any?) others than this one (I used ❯ rg '[^rf]\.sql\([^"%]').

I think it could make sense to do if the pattern was more common, but it seems like 90+% of the time a string is passed anyways.

Copy link
Member Author

Choose a reason for hiding this comment

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

For a more definitive answer, I ran ARCONN=sqlite3 bin/test with this patch:

diff --git a/activerecord/lib/arel.rb b/activerecord/lib/arel.rb
index 738e80df35..cf82e60656 100644
--- a/activerecord/lib/arel.rb
+++ b/activerecord/lib/arel.rb
@@ -50,6 +50,7 @@ module Arel
   # Use this option only if the SQL is idempotent, as it could be executed
   # more than once.
   def self.sql(sql_string, *positional_binds, retryable: false, **named_binds)
+    raise if Nodes::SqlLiteral === sql_string
     if positional_binds.empty? && named_binds.empty?
       Arel::Nodes::SqlLiteral.new(sql_string, retryable: retryable)
     else

It fails on main but passes on this branch, so the two places touched by this PR are the only places we pass a SqlLiteral to Arel.sql.

Copy link
Member

Choose a reason for hiding this comment

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

I guess my point was more for the future, and for general API design. e.g. Array([]) # => [].

Copy link
Member

Choose a reason for hiding this comment

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

I feel a bit non-specifically wary of defining Arel.sql to pass-through all Nodes (while conceding that's functionally what we do in several call-sites already)... but regardless of that, I do agree it seems reasonable/sensible for it to pass-through its own results rather than re-wrapping them.

Copy link
Member Author

Choose a reason for hiding this comment

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

Updated to pass through SqlLiterals 👍 I agree passing through other Nodes probably doesn't make sense (and I think Arel.sql doesn't even accept other nodes at the moment since they can't be coerced to String).

skipkayhil added 2 commits May 5, 2025 18:14
Previously, retryable SqlLiterals passed to `#where` would lose their
retryability because both `#build_where_clause` and `WhereClause` would
wrap them in non-retryable `SqlLiteral`s.

To fix this problem, this commit updates `#build_where_clause` to check
for `SqlLiterals` and updates `WhereClause` to assume that any predicate
passed in will already be wrapped.

There were only a few places that passed Strings to `WhereClause`
(`"1=0"` in `PredicateBuilder` and sanitized sql strings in
`#build_where_clause`) that needed to be updated to use `Arel.sql`. The
`WhereClause` tests also had Strings to wrap but they are really unit
tests and aren't representative of what can be done with the public API
Previously, passing a SqlLiteral to Arel.sql would wrap the SqlLiteral
in a new SqlLiteral (which is generally unnecessary since it allocates a
new SqlLiteral/String).

This commit updates Arel.sql to no longer perform the wrapping of the
given "sql_string" is already a SqlLiteral.
@skipkayhil skipkayhil force-pushed the hm-where-sql-literal branch from 204c3f9 to f3763e6 Compare May 5, 2025 22:16
@byroot byroot merged commit 730367e into rails:main May 5, 2025
3 checks passed
@skipkayhil skipkayhil deleted the hm-where-sql-literal branch May 5, 2025 22:37
euglena1215 added a commit to euglena1215/rails that referenced this pull request Aug 31, 2025
This builds upon the retryable SqlLiteral support introduced in rails#54951
by extending Arel.sql to accept a retryable option with bind parameters.
Previously, only plain SqlLiterals could be marked as retryable, but now
developers can create retryable bound SQL literals for parameterized queries.

The retryable option works with both positional (?) and named (:name) bind
parameters and integrates with ActiveRecord's existing retry infrastructure.

Example usage:

```rb
User.where(Arel.sql('name LIKE ?', search_term, retryable: true))
Post.where(Arel.sql('id = :id', retryable: true), { id: 1 })
```
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.

3 participants