Repository navigation
Description
Environment details
- Programming language: Ruby
- OS: Linux (also reproduced on Spanner emulator and a real Spanner database)
- Language runtime version: Ruby 3.4.8
- Package version:
activerecord-spanner-adapter2.5.0 (also present onmain— see_set_composite_primary_key_value) - Rails: 8.0.4
- Spanner dialect: GoogleSQL
Steps to reproduce
- Create a table with a composite primary key that includes STRING columns (minimal fictional schema below).
- Outside a DML transaction (default mutation /
InsertOrUpdatepath), callModel.upsert_all([row])orModel.insert_all!([row])with a hash of plain Ruby values. - Read the row back and inspect the primary key columns.
create_table :demo_items, id: false, force: true do |t|
t.integer :account_id, null: false
t.string :resource_uuid, null: false # UUID string
t.string :kind, null: false # e.g. "email"
t.string :value, null: false # e.g. "alice@example.com"
t.string :resource_type, null: false # non-PK column
t.datetime :created_at, null: false
end
execute <<~SQL
ALTER TABLE demo_items
ADD PRIMARY KEY (account_id, resource_uuid, kind, value)
SQL
class DemoItem < ActiveRecord::Base
self.table_name = "demo_items"
self.primary_key = %w[account_id resource_uuid kind value]
end
account_id = 4242
resource_uuid = "89ff73aa-c1cb-475e-bb27-89281c50c5b8"
created_at = Time.utc(2026, 1, 1, 12, 0, 0)
DemoItem.where(account_id: account_id).delete_all
row = {
"account_id" => account_id,
"resource_uuid" => resource_uuid,
"kind" => "email",
"value" => "alice@example.com",
"resource_type" => "house",
"created_at" => created_at,
}
# Mutation path (default when outside a DML transaction)
DemoItem.upsert_all([row])
# DemoItem.insert_all!([row]) # same corruption on the mutation path
puts DemoItem.where(account_id: account_id).map(&:attributes)Actual result
[{
"account_id" => 4242,
"resource_uuid" => "89", # truncated via BigInteger.cast
"kind" => "0", # "email" => 0
"value" => "0", # "alice@example.com" => 0
"resource_type" => "house", # non-PK: OK
"created_at" => 2026-01-01 12:00:00 UTC, # non-PK: OK
}]Lookup by the intended PK returns nil:
DemoItem.find_by(
account_id: account_id,
resource_uuid: resource_uuid,
kind: "email",
value: "alice@example.com"
)
# => nilExpected result
Row persisted with the original STRING PK values (resource_uuid, kind, value unchanged).
Root cause
In lib/activerecord_spanner_adapter/base.rb, _set_composite_primary_key_value always defaults the type to BigInteger unless the caller already wrapped the value in ActiveModel::Attribute:
def self._set_composite_primary_key_value primary_key, values, is_mutation
value = values[primary_key]
type = ActiveModel::Type::BigInteger.new # <-- always, for every composite PK column
if value.is_a? ActiveModel::Attribute
type = value.type
value = value.value
end
# ...
values[primary_key] =
ActiveModel::Attribute.from_database primary_key, value, type
endCast examples:
| Input | BigInteger.cast |
|---|---|
"89ff73aa-c1cb-..." |
89 |
"email" |
0 |
"alice@example.com" |
0 |
4242 |
4242 (integer PK column survives) |
This path is used by _buffer_record for mutation writes (insert / insert_or_update), which is what upsert_all and insert_all! use by default when there is no open DML transaction (or when isolation is :buffered_mutations).
Inside a default ActiveRecord DML transaction, the same call goes through SQL DML and does not hit this cast — so the bug is mutation-path-specific.
insert_all (without bang) uses DML INSERT OR IGNORE and is not affected; under :buffered_mutations the adapter raises NotImplementedError for that method.
Suggested fix
When rewriting composite PK values, use the column's actual ActiveRecord type (e.g. type_for_attribute(primary_key) / columns_hash[primary_key].type) instead of hard-coding ActiveModel::Type::BigInteger, at least when a non-nil client-provided value is present.
Integer / auto-generated PK columns would keep working; STRING (and other non-INT64) composite PK columns would stop being corrupted.
Workaround (application-side)
Wrap each composite PK column with the real column type before upsert_all / insert_all! on the mutation path only:
row = {
"account_id" => 4242,
"resource_uuid" => "89ff73aa-c1cb-475e-bb27-89281c50c5b8",
"kind" => "email",
"value" => "alice@example.com",
"resource_type" => "house",
"created_at" => Time.utc(2026, 1, 1, 12, 0, 0),
}
Array(DemoItem.primary_key).each do |column|
row[column] = ActiveModel::Attribute.from_database(
column,
row[column],
DemoItem.type_for_attribute(column)
)
end
DemoItem.upsert_all([row])
# => persists correct PK values on the mutation pathImportant: do not apply this wrapping inside a DML transaction. In that path, ActiveModel::Attribute wrappers can be persisted as NULL. Only type PK columns when there is no open DML transaction (or when isolation is :buffered_mutations).
stringify_keys alone does not fix it; only pre-wrapping with the correct ActiveModel::Attribute type does.
Notes
- Reproduced on Spanner emulator and on a real Spanner instance (so this is not an emulator-only quirk).
- Happy to contribute a failing test + patch if that would help.
Activity
fix: do not cast non-integer composite primary keys with BigInteger