Skip to content
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ gemspec
ar_version = ENV.fetch("AR_VERSION", "~> 7.1.0")
gem "activerecord", ar_version
gem "ostruct"
# Rails 7 and docker-api still use JSON options removed in JSON 3.
gem "json", "< 3"
gem "minitest", "~> 5.27.0"
gem "minitest-rg", "~> 5.4.0"
gem "pry", "~> 0.14.2"
Expand Down
2 changes: 1 addition & 1 deletion acceptance/cases/migration/change_schema_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ def test_add_column_with_timestamp_type

column = connection.columns(:testings).find { |c| c.name == "foo" }

assert_equal :time, column.type
assert_equal :datetime, column.type
assert_equal "TIMESTAMP", column.sql_type
end

Expand Down
2 changes: 1 addition & 1 deletion acceptance/cases/migration/schema_dumper_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ def test_dump_schema_contains_commit_timestamp
connection = pool_or_connection
schema = StringIO.new
ActiveRecord::SchemaDumper.dump connection, schema
assert schema.string.include?("t.time \"last_updated\", allow_commit_timestamp: true"), schema.string
assert_match(/t\.datetime "last_updated", .*allow_commit_timestamp: true/, schema.string)
end

def test_dump_schema_contains_virtual_column
Expand Down
6 changes: 3 additions & 3 deletions acceptance/cases/type/time_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,9 @@ def test_date_time_with_string_value_with_non_iso_format
assert_equal record, TestTypeModel.find_by(start_time: string_value)
end

def test_default_year_is_correct
expected_time = ::Time.utc(2000, 1, 1, 10, 30, 0)
record = TestTypeModel.new start_time: { 4 => 10, 5 => 30 }
def test_multiparameter_assignment_preserves_date
expected_time = ::Time.utc(2026, 9, 9, 10, 30, 0)
record = TestTypeModel.new start_time: { 1 => 2026, 2 => 9, 3 => 9, 4 => 10, 5 => 30 }

assert_equal expected_time, record.start_time

Expand Down
16 changes: 12 additions & 4 deletions examples/snippets/bin/create_emulator_instance.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,18 @@
require "google/cloud/spanner"

spanner = Google::Cloud::Spanner.new project: "test-project", emulator_host: "localhost:9010"
job = spanner.create_instance "test-instance",
name: "Test Instance",
config: "emulator-config",
nodes: 1
# Container startup completes before the emulator accepts RPCs.
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30
begin
job = spanner.create_instance "test-instance",
name: "Test Instance",
config: "emulator-config",
nodes: 1
rescue Google::Cloud::UnavailableError
raise if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
sleep 1
retry
end
job.wait_until_done!

instance = spanner.instance "test-instance"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ def write_query? sql
end

def execute_ddl statements
log "MIGRATION", "SCHEMA" do
log [statements].flatten.join(";"), nil do
ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
@connection.execute_ddl statements
end
Expand Down 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
Comment on lines +422 to +432

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The untyped_bind_type method currently checks if the value is an instance of ::Time or ::DateTime. However, in Rails applications, timezone-aware time values are typically instances of ActiveSupport::TimeWithZone, which does not inherit from ::Time or ::DateTime. As a result, any untyped bind containing an ActiveSupport::TimeWithZone object (such as Time.zone.now or Time.current) will not be recognized, defaulting to :INT64 and causing serialization or type mismatch errors at runtime.

To fix this, we should use Rails' idiomatic acts_like?(:time) and acts_like?(:date) helpers to correctly identify time-like and date-like objects, including ActiveSupport::TimeWithZone.

        def untyped_bind_type value
          if value.respond_to?(:acts_like?)
            if value.acts_like?(:time)
              return ActiveRecord::Type::Spanner::Time.new
            elsif value.acts_like?(:date)
              return ActiveModel::Type::Date.new
            end
          end

          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
35 changes: 35 additions & 0 deletions lib/active_record/tasks/spanner_database_tasks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,27 @@ def collation
nil
end

# Rails 8.1 moved the protected-environment guard onto the per-adapter
# tasks object (ActiveRecord::Tasks::AbstractTasks#check_current_protected_environment!).
# This class predates AbstractTasks and doesn't inherit it, so DatabaseTasks
# calls this on the Spanner tasks object and would otherwise NoMethodError.
# Mirror the AbstractTasks implementation.
def check_current_protected_environment! db_config, migration_class
with_temporary_pool db_config, migration_class do |pool|
migration_context = pool.migration_context
current = migration_context.current_environment
stored = migration_context.last_stored_environment

raise ActiveRecord::ProtectedEnvironmentError, stored if migration_context.protected_environment?

if stored && stored != current
raise ActiveRecord::EnvironmentMismatchError.new(current: current, stored: stored)
end
rescue ActiveRecord::NoDatabaseError
# No database yet — nothing to protect.
end
end

def structure_dump filename, _extra_flags
file = File.open filename, "w"
ignore_tables = ActiveRecord::SchemaDumper.ignore_tables
Expand Down Expand Up @@ -78,6 +99,20 @@ def structure_load filename, _extra_flags
dmls.each { |dml| tx.execute_query dml }
end
end

private

# Mirrors ActiveRecord::Tasks::AbstractTasks#with_temporary_pool (Rails 8.1),
# used by check_current_protected_environment! to inspect the migration context
# under the target db_config without disturbing the caller's connection.
def with_temporary_pool db_config, migration_class, clobber: false
original_db_config = migration_class.connection_db_config
pool = migration_class.connection_handler.establish_connection db_config, clobber: clobber

yield pool
ensure
migration_class.connection_handler.establish_connection original_db_config, clobber: clobber
end
end

DatabaseTasks.register_task(
Expand Down
2 changes: 1 addition & 1 deletion lib/active_record/type/spanner/time.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
module ActiveRecord
module Type
module Spanner
class Time < ActiveRecord::Type::Time
class Time < ActiveRecord::Type::DateTime
def serialize_with_isolation_level value, isolation_level
if value == :commit_timestamp
return "PENDING_COMMIT_TIMESTAMP()" if isolation_level == :dml
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,57 @@ 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
skip "Bound SQL literals require Rails version 7.1 or higher" if ActiveRecord.version < Gem::Version.create("7.1.0")

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