Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading