diff --git a/lib/active_record/connection_adapters/spanner/database_statements.rb b/lib/active_record/connection_adapters/spanner/database_statements.rb index 82ac22ee..7bc52a63 100644 --- a/lib/active_record/connection_adapters/spanner/database_statements.rb +++ b/lib/active_record/connection_adapters/spanner/database_statements.rb @@ -378,16 +378,18 @@ def to_types_and_params binds def to_types binds binds.enum_for(:each_with_index).to_h do |bind, i| - type = :INT64 - if bind.respond_to? :type - type = ActiveRecord::Type::Spanner::SpannerActiveRecordConverter - .convert_active_model_type_to_spanner(bind.type) - elsif bind.instance_of? Symbol - # This ensures that for example :environment is sent as the string 'environment' to Cloud Spanner. - type = :STRING - elsif bind.instance_of?(TrueClass) || bind.instance_of?(FalseClass) - type = :BOOL - end + type = if bind.respond_to? :type + ActiveRecord::Type::Spanner::SpannerActiveRecordConverter + .convert_active_model_type_to_spanner(bind.type) + elsif bind.instance_of? Symbol + # This ensures that for example :environment is sent as the string 'environment' to Cloud Spanner. + :STRING + else + # Untyped binds (e.g. from `Arel.sql("name = ?", "abc")`) are bare Ruby values without an + # attached ActiveModel type. Derive the Spanner type from the Ruby class, defaulting to INT64. + ActiveRecord::Type::Spanner::SpannerActiveRecordConverter + .convert_active_model_type_to_spanner(untyped_bind_type(bind)) || :INT64 + end [ # Generates binds for named parameters in the format `@p1, @p2, ...` "p#{i + 1}", type @@ -403,8 +405,8 @@ def to_params binds # This ensures that for example :environment is sent as the string 'environment' to Cloud Spanner. :STRING else - # The Cloud Spanner default type is INT64 if no other type is known. - ActiveModel::Type::Integer + # Untyped bind: pick the serializer that matches the type declared in `to_types`. + untyped_bind_type bind end bind_value = bind.respond_to?(:value) ? bind.value : bind value = ActiveRecord::Type::Spanner::SpannerActiveRecordConverter @@ -414,6 +416,21 @@ def to_params binds end end + # Maps a bare Ruby value (a bind without an attached ActiveModel type) to the ActiveModel type + # that should be used to declare and serialize it. Returns nil for values that are not recognized, + # which keeps the historical behavior: the bind is declared as INT64 and the value is sent as-is. + def untyped_bind_type value + case value + when ::String then ActiveModel::Type::String.new + when true, false then ActiveModel::Type::Boolean.new + when ::Float then ActiveModel::Type::Float.new + when ::BigDecimal then ActiveModel::Type::Decimal.new + # DateTime is a subclass of Date, so it must be matched before Date. + when ::Time, ::DateTime then ActiveRecord::Type::Spanner::Time.new + when ::Date then ActiveModel::Type::Date.new + end + end + # An insert/update/delete statement could use mutations in some specific circumstances. # This method returns an indication whether a specific operation should use mutations instead of DML # based on the operation itself, and the current transaction. diff --git a/test/activerecord_spanner_mock_server/spanner_active_record_with_mock_server_test.rb b/test/activerecord_spanner_mock_server/spanner_active_record_with_mock_server_test.rb index 897315eb..4d24cdfa 100644 --- a/test/activerecord_spanner_mock_server/spanner_active_record_with_mock_server_test.rb +++ b/test/activerecord_spanner_mock_server/spanner_active_record_with_mock_server_test.rb @@ -697,6 +697,55 @@ def test_find_singer_by_last_performance_as_non_iso_string assert_equal timestamp.utc.rfc3339(9), request.params["p1"] end + def test_untyped_binds_from_arel_sql_are_typed_by_ruby_class + select_sql = "SELECT `singers`.* FROM `singers` WHERE first_name = @p1 AND active = @p2 AND weight = @p3 " \ + "AND balance = @p4 AND last_performance = @p5 AND created_at = @p6 AND birth_date = @p7 AND age = @p8" + @mock.put_statement_result select_sql, MockServerTests::create_random_singers_result(1) + + time = ::Time.parse("2021-05-12T10:30:00+02:00") + date_time = ::DateTime.new(2021, 5, 12, 10, 30, 0, "+02:00") + Singer.where( + Arel.sql( + "first_name = ? AND active = ? AND weight = ? AND balance = ? AND last_performance = ? " \ + "AND created_at = ? AND birth_date = ? AND age = ?", + "Alice", true, 1.5, BigDecimal("12.34"), time, date_time, ::Date.new(2021, 5, 12), 42 + ) + ).to_a + + request = @mock.requests.select {|req| req.is_a?(Google::Cloud::Spanner::V1::ExecuteSqlRequest) && req.sql == select_sql }.first + refute_nil request + assert_equal :STRING, request.param_types["p1"].code + assert_equal "Alice", request.params["p1"] + assert_equal :BOOL, request.param_types["p2"].code + assert_equal true, request.params["p2"] + assert_equal :FLOAT64, request.param_types["p3"].code + assert_equal 1.5, request.params["p3"] + assert_equal :NUMERIC, request.param_types["p4"].code + assert_equal "12.34", request.params["p4"] + assert_equal :TIMESTAMP, request.param_types["p5"].code + assert_equal "2021-05-12T08:30:00.000000000Z", request.params["p5"] + assert_equal :TIMESTAMP, request.param_types["p6"].code + assert_equal "2021-05-12T08:30:00.000000000Z", request.params["p6"] + assert_equal :DATE, request.param_types["p7"].code + assert_equal "2021-05-12", request.params["p7"] + assert_equal :INT64, request.param_types["p8"].code + assert_equal "42", request.params["p8"] + end + + def test_where_with_positional_string_placeholder + # Before ActiveRecord 8.1, `where("col = ?", value)` inlines the value into the SQL instead of binding it. + skip "Requires Rails version 8.1 or higher" if ActiveRecord.version < Gem::Version.create("8.1.0") + select_sql = "SELECT `singers`.* FROM `singers` WHERE (first_name = @p1)" + @mock.put_statement_result select_sql, MockServerTests::create_random_singers_result(1) + + Singer.where("first_name = ?", "Alice").to_a + + request = @mock.requests.select {|req| req.is_a?(Google::Cloud::Spanner::V1::ExecuteSqlRequest) && req.sql == select_sql }.first + refute_nil request + assert_equal :STRING, request.param_types["p1"].code + assert_equal "Alice", request.params["p1"] + end + def test_create_singer_with_picture insert_sql = "INSERT INTO `singers` (`first_name`, `last_name`, `picture`, `id`) VALUES (@p1, @p2, @p3, @p4)" @mock.put_statement_result insert_sql, StatementResult.new(1)