From 03e8f448f99b2406bd53c33a69a0a905925777d3 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Tue, 20 Feb 2024 11:30:27 +0100 Subject: [PATCH] Relation#where build BoundSqlLiteral rather than eagerly interpolate Ref: https://github.com/rails/rails/pull/50793 To make not caching connection checkout viable, we need to reduced the amount of places where we need a connection. Once big source of this is query/relation building, where in many cases it eagerly quote and interpolation bound values in SQL fragments. Doing this requires an active connection because both MySQL and Postgres may quote values differently based on the connection settings. Instead of eagerly doing all this, we can instead just insert these as bound values in the Arel AST. For adapters with prepared statements this is better anyway as it will avoid leaking statements, and for those that don't support it, it will simply delay the quoting to just before the query is executed. For now now only a subset of the API is migrated over, namely the `where("title = ?", something)` form. There is also `where("title = %s", something)` that I'm afraid won't be able to be fixed and probably should be deprecated. As well as `where("title = :title")` which I think is doable in a followup. --- .../connection_adapters/abstract/quoting.rb | 3 ++ .../connection_adapters/postgresql/quoting.rb | 5 +++ .../connection_adapters/sqlite3/quoting.rb | 5 ++- .../active_record/relation/query_methods.rb | 32 +++++++++++++++++-- .../lib/arel/nodes/bound_sql_literal.rb | 12 ++++--- activerecord/lib/arel/update_manager.rb | 3 +- .../bind_parameter_test.rb | 10 +++--- .../postgresql/bind_parameter_test.rb | 26 +++++++-------- .../adapters/sqlite3/bind_parameter_test.rb | 10 +++--- .../test/cases/bind_parameter_test.rb | 2 +- activerecord/test/cases/finder_test.rb | 2 +- activerecord/test/cases/quoting_test.rb | 6 ++-- .../test/cases/relation/merging_test.rb | 32 +++++++++---------- activerecord/test/cases/relation_test.rb | 2 +- activerecord/test/cases/relations_test.rb | 4 +-- activerecord/test/cases/sanitize_test.rb | 14 ++++++-- 16 files changed, 112 insertions(+), 56 deletions(-) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/quoting.rb b/activerecord/lib/active_record/connection_adapters/abstract/quoting.rb index 71ef69063f6f5..48a69914302f8 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/quoting.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/quoting.rb @@ -44,6 +44,9 @@ def type_cast(value) when nil, Numeric, String then value when Type::Time::Value then quoted_time(value) when Date, Time then quoted_date(value) + when ActiveSupport::Duration + warn_quote_duration_deprecated + value.to_i else raise TypeError, "can't cast #{value.class.name}" end end diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/quoting.rb b/activerecord/lib/active_record/connection_adapters/postgresql/quoting.rb index c967b074c7b9c..ba590e23ccf89 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/quoting.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/quoting.rb @@ -141,6 +141,11 @@ def type_cast(value) # :nodoc: encode_array(value) when Range encode_range(value) + when Rational + value.to_f + when ActiveSupport::Duration + warn_quote_duration_deprecated + value.to_i else super end diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3/quoting.rb b/activerecord/lib/active_record/connection_adapters/sqlite3/quoting.rb index d28787b5cf5ae..fcca1eeba8561 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3/quoting.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/quoting.rb @@ -63,7 +63,7 @@ def quote_default_expression(value, column) # :nodoc: def type_cast(value) # :nodoc: case value - when BigDecimal + when BigDecimal, Rational value.to_f when String if value.encoding == Encoding::ASCII_8BIT @@ -71,6 +71,9 @@ def type_cast(value) # :nodoc: else super end + when ActiveSupport::Duration + warn_quote_duration_deprecated + value.to_i else super end diff --git a/activerecord/lib/active_record/relation/query_methods.rb b/activerecord/lib/active_record/relation/query_methods.rb index b9dc7392323c1..3c734f0e22d4d 100644 --- a/activerecord/lib/active_record/relation/query_methods.rb +++ b/activerecord/lib/active_record/relation/query_methods.rb @@ -1512,9 +1512,17 @@ def build_subquery(subquery_alias, select_value) # :nodoc: def build_where_clause(opts, rest = []) # :nodoc: opts = sanitize_forbidden_attributes(opts) + if opts.is_a?(Array) + opts, *rest = opts + end + case opts - when String, Array - parts = [klass.sanitize_sql(rest.empty? ? opts : [opts, *rest])] + when String + if opts.include?("?") + parts = [build_bound_sql_literal(opts, rest)] + else + parts = [klass.sanitize_sql(rest.empty? ? opts : [opts, *rest])] + end when Hash opts = opts.transform_keys do |key| if key.is_a?(Array) @@ -1550,6 +1558,26 @@ def async spawn.async! end + def build_bound_sql_literal(statement, values) + bound_values = values.map do |value| + if ActiveRecord::Relation === value + Arel.sql(value.to_sql) + elsif value.respond_to?(:map) && !value.acts_like?(:string) + values = value.map { |v| v.respond_to?(:id_for_database) ? v.id_for_database : v } + values.empty? ? nil : values + else + value = value.id_for_database if value.respond_to?(:id_for_database) + value + end + end + + begin + Arel::Nodes::BoundSqlLiteral.new("(#{statement})", bound_values, nil) + rescue Arel::BindError => error + raise ActiveRecord::PreparedStatementInvalid, error.message + end + end + def lookup_table_klass_from_join_dependencies(table_name) each_join_dependencies do |join| return join.base_klass if table_name == join.table_name diff --git a/activerecord/lib/arel/nodes/bound_sql_literal.rb b/activerecord/lib/arel/nodes/bound_sql_literal.rb index d3e266f67052e..52dc7c6e026de 100644 --- a/activerecord/lib/arel/nodes/bound_sql_literal.rb +++ b/activerecord/lib/arel/nodes/bound_sql_literal.rb @@ -6,13 +6,17 @@ class BoundSqlLiteral < NodeExpression attr_reader :sql_with_placeholders, :positional_binds, :named_binds def initialize(sql_with_placeholders, positional_binds, named_binds) - if !positional_binds.empty? && !named_binds.empty? - raise BindError.new("cannot mix positional and named binds", sql_with_placeholders) - elsif !positional_binds.empty? + has_positional = !(positional_binds.nil? || positional_binds.empty?) + has_named = !(named_binds.nil? || named_binds.empty?) + + if has_positional + if has_named + raise BindError.new("cannot mix positional and named binds", sql_with_placeholders) + end if positional_binds.size != (expected = sql_with_placeholders.count("?")) raise BindError.new("wrong number of bind variables (#{positional_binds.size} for #{expected})", sql_with_placeholders) end - elsif !named_binds.empty? + elsif has_named tokens_in_string = sql_with_placeholders.scan(/:(?