Skip to content

Commit

Permalink
fix!: deserialize BYTES to StringIO
Browse files Browse the repository at this point in the history
BYTES columns were not deserialized by the Spanner ActiveRecord, and
instead these values were just returned as the underlying Base64 string.
This again would cause the Base64 string to be re-encoded to Base64 if
the same value was saved.

This changes the return type for BYTES columns. To retain the old behavior,
set the environment variable `SPANNER_BYTES_DESERIALIZE_DISABLED=true`

Fixes #341
  • Loading branch information
olavloite committed Jan 10, 2025
1 parent 4bb1fff commit 468cb7b
Show file tree
Hide file tree
Showing 11 changed files with 414 additions and 10 deletions.
87 changes: 87 additions & 0 deletions acceptance/cases/models/binary_identifiers.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Copyright 2025 Google LLC
#
# Use of this source code is governed by an MIT-style
# license that can be found in the LICENSE file or at
# https://opensource.org/licenses/MIT.

# frozen_string_literal: true

require "test_helper"
require "test_helpers/with_separate_database"
require_relative "../../models/user"
require_relative "../../models/binary_project"

module Models
class DefaultValueTest < SpannerAdapter::TestCase
include TestHelpers::WithSeparateDatabase

def setup
super

connection.create_table :users, id: :binary do |t|
t.string :email, null: false
t.string :full_name, null: false
end
connection.create_table :binary_projects, id: :binary do |t|
t.string :name, null: false
t.string :description, null: false
t.binary :owner_id, null: false
t.foreign_key :users, column: :owner_id
end
end

def test_includes_works
user = User.create!(
email: "[email protected]",
full_name: "Test User"
)
3.times do |i|
Project.create!(
name: "Project #{i}",
description: "Description #{i}",
owner: user
)
end

# First verify the association works without includes
projects = Project.all
assert_equal 3, projects.count

# Compare the base64 content instead of the StringIO objects
first_project = projects.first
assert_equal to_base64(user.id), to_base64(first_project.owner_id)

# Now verify includes is working
query_count = count_queries do
loaded_projects = Project.includes(:owner).to_a
loaded_projects.each do |project|
# Access the owner to ensure it's preloaded
assert_equal user.full_name, project.owner.full_name
end
end

# Spanner should execute 2 queries: one for projects and one for users
assert_equal 2, query_count
end

private

def to_base64 buffer
buffer.rewind
value = buffer.read
Base64.strict_encode64 value.force_encoding("ASCII-8BIT")
end

def count_queries(&block)
count = 0
counter_fn = ->(name, started, finished, unique_id, payload) {
unless %w[CACHE SCHEMA].include?(payload[:name])
count += 1
end
}

ActiveSupport::Notifications.subscribed(counter_fn, "sql.active_record", &block)
count
end
end
end
18 changes: 18 additions & 0 deletions acceptance/models/binary_project.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Copyright 2025 Google LLC
#
# Use of this source code is governed by an MIT-style
# license that can be found in the LICENSE file or at
# https://opensource.org/licenses/MIT.

# frozen_string_literal: true

class BinaryProject < ActiveRecord::Base
belongs_to :owner, class_name: 'User'

before_create :set_uuid
private

def set_uuid
self.id ||= StringIO.new(SecureRandom.random_bytes(16))
end
end
18 changes: 18 additions & 0 deletions acceptance/models/user.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Copyright 2025 Google LLC
#
# Use of this source code is governed by an MIT-style
# license that can be found in the LICENSE file or at
# https://opensource.org/licenses/MIT.

# frozen_string_literal: true

class User < ActiveRecord::Base
has_many :binary_projects, foreign_key: :owner_id

before_create :set_uuid
private

def set_uuid
self.id ||= StringIO.new(SecureRandom.random_bytes(16))
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,7 @@ def col.primary_key?
end

def rename_table _table_name, _new_name
raise ActiveRecordSpannerAdapter::NotSupportedError, \
"rename_table is not implemented"
raise ActiveRecordSpannerAdapter::NotSupportedError, "rename_table is not implemented"
end

# Column
Expand Down Expand Up @@ -208,14 +207,12 @@ def change_column_null table_name, column_name, null, _default = nil
end

def change_column_default _table_name, _column_name, _default_or_changes
raise ActiveRecordSpannerAdapter::NotSupportedError, \
"change column with default value not supported."
raise ActiveRecordSpannerAdapter::NotSupportedError, "change column with default value not supported."
end

def rename_column table_name, column_name, new_column_name
if ActiveRecord::Base.connection.ddl_batch?
raise ActiveRecordSpannerAdapter::NotSupportedError, \
"rename_column in a DDL Batch is not supported."
raise ActiveRecordSpannerAdapter::NotSupportedError, "rename_column in a DDL Batch is not supported."
end
column = information_schema do |i|
i.table_column table_name, column_name
Expand Down Expand Up @@ -642,8 +639,7 @@ def execute_schema_statements statements
end

def information_schema
info_schema = \
ActiveRecordSpannerAdapter::Connection.information_schema @config
info_schema = ActiveRecordSpannerAdapter::Connection.information_schema @config

return info_schema unless block_given?

Expand Down
10 changes: 10 additions & 0 deletions lib/active_record/type/spanner/bytes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ module ActiveRecord
module Type
module Spanner
class Bytes < ActiveRecord::Type::Binary
def deserialize value
# Set this environment variable to disable de-serializing BYTES
# to a StringIO instance.
return super if ENV["SPANNER_BYTES_DESERIALIZE_DISABLED"]

return super value if value.nil?
return StringIO.new Base64.strict_decode64(value) if value.is_a? ::String
value
end

def serialize value
return super value if value.nil?

Expand Down
4 changes: 2 additions & 2 deletions lib/activerecord_spanner_adapter/connection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def self.reset_information_schemas!

def self.information_schema config
@information_schemas ||= {}
@information_schemas[database_path(config)] ||= \
@information_schemas[database_path(config)] ||=
ActiveRecordSpannerAdapter::InformationSchema.new new(config)
end

Expand Down Expand Up @@ -204,7 +204,7 @@ def run_batch

def execute_query sql, params: nil, types: nil, single_use_selector: nil, request_options: nil
if params
converted_params, types = \
converted_params, types =
Google::Cloud::Spanner::Convert.to_input_params_and_types(
params, types
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
require_relative "models/table_with_commit_timestamp"
require_relative "models/table_with_sequence"
require_relative "models/versioned_singer"
require_relative "models/user"
require_relative "models/binary_project"

require "securerandom"

Expand Down Expand Up @@ -57,6 +59,15 @@ def setup
MockServerTests::register_table_with_sequence_columns_result @mock
MockServerTests::register_table_with_sequence_primary_key_columns_result @mock
MockServerTests::register_table_with_sequence_primary_and_parent_key_columns_result @mock

MockServerTests::register_users_columns_result @mock
MockServerTests::register_users_primary_key_columns_result @mock
MockServerTests::register_users_primary_and_parent_key_columns_result @mock

MockServerTests::register_binary_projects_columns_result @mock
MockServerTests::register_binary_projects_primary_key_columns_result @mock
MockServerTests::register_binary_projects_primary_and_parent_key_columns_result @mock

# Connect ActiveRecord to the mock server
ActiveRecord::Base.establish_connection(
adapter: "spanner",
Expand Down
148 changes: 148 additions & 0 deletions test/activerecord_spanner_mock_server/model_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,24 @@ def self.register_select_tables_result spanner_mock_server
Value.new(null_value: "NULL_VALUE"),
)
result_set.rows.push row
row = ListValue.new
row.values.push(
Value.new(string_value: ""),
Value.new(string_value: ""),
Value.new(string_value: "users"),
Value.new(null_value: "NULL_VALUE"),
Value.new(null_value: "NULL_VALUE"),
)
result_set.rows.push row
row = ListValue.new
row.values.push(
Value.new(string_value: ""),
Value.new(string_value: ""),
Value.new(string_value: "binary_projects"),
Value.new(null_value: "NULL_VALUE"),
Value.new(null_value: "NULL_VALUE"),
)
result_set.rows.push row

spanner_mock_server.put_statement_result sql, StatementResult.new(result_set)
end
Expand Down Expand Up @@ -727,6 +745,136 @@ def self.register_table_with_sequence_primary_and_parent_key_columns_result span
register_key_columns_result spanner_mock_server, sql
end

def self.register_users_columns_result spanner_mock_server
register_commit_timestamps_result spanner_mock_server, "users"

sql = table_columns_sql "users"

column_name = Field.new name: "COLUMN_NAME", type: Type.new(code: TypeCode::STRING)
spanner_type = Field.new name: "SPANNER_TYPE", type: Type.new(code: TypeCode::STRING)
is_nullable = Field.new name: "IS_NULLABLE", type: Type.new(code: TypeCode::STRING)
generation_expression = Field.new name: "GENERATION_EXPRESSION", type: Type.new(code: TypeCode::STRING)
column_default = Field.new name: "COLUMN_DEFAULT", type: Type.new(code: TypeCode::STRING)
ordinal_position = Field.new name: "ORDINAL_POSITION", type: Type.new(code: TypeCode::INT64)

metadata = ResultSetMetadata.new row_type: StructType.new
metadata.row_type.fields.push column_name, spanner_type, is_nullable, generation_expression, column_default, ordinal_position
result_set = ResultSet.new metadata: metadata

row = ListValue.new
row.values.push(
Value.new(string_value: "id"),
Value.new(string_value: "BYTES(16)"),
Value.new(string_value: "NO"),
Value.new(null_value: "NULL_VALUE"),
Value.new(null_value: "NULL_VALUE"),
Value.new(string_value: "1")
)
result_set.rows.push row
row = ListValue.new
row.values.push(
Value.new(string_value: "email"),
Value.new(string_value: "STRING(MAX)"),
Value.new(string_value: "NO"),
Value.new(null_value: "NULL_VALUE"),
Value.new(null_value: "NULL_VALUE"),
Value.new(string_value: "2")
)
result_set.rows.push row
row = ListValue.new
row.values.push(
Value.new(string_value: "full_name"),
Value.new(string_value: "STRING(MAX)"),
Value.new(string_value: "NO"),
Value.new(null_value: "NULL_VALUE"),
Value.new(null_value: "NULL_VALUE"),
Value.new(string_value: "3")
)
result_set.rows.push row

spanner_mock_server.put_statement_result sql, StatementResult.new(result_set)
end

def self.register_users_primary_key_columns_result spanner_mock_server
sql = primary_key_columns_sql "users", parent_keys: false
register_key_columns_result spanner_mock_server, sql
end

def self.register_users_primary_and_parent_key_columns_result spanner_mock_server
sql = primary_key_columns_sql "users", parent_keys: true
register_key_columns_result spanner_mock_server, sql
end

def self.register_binary_projects_columns_result spanner_mock_server
register_commit_timestamps_result spanner_mock_server, "binary_projects"

sql = table_columns_sql "binary_projects"

column_name = Field.new name: "COLUMN_NAME", type: Type.new(code: TypeCode::STRING)
spanner_type = Field.new name: "SPANNER_TYPE", type: Type.new(code: TypeCode::STRING)
is_nullable = Field.new name: "IS_NULLABLE", type: Type.new(code: TypeCode::STRING)
generation_expression = Field.new name: "GENERATION_EXPRESSION", type: Type.new(code: TypeCode::STRING)
column_default = Field.new name: "COLUMN_DEFAULT", type: Type.new(code: TypeCode::STRING)
ordinal_position = Field.new name: "ORDINAL_POSITION", type: Type.new(code: TypeCode::INT64)

metadata = ResultSetMetadata.new row_type: StructType.new
metadata.row_type.fields.push column_name, spanner_type, is_nullable, generation_expression, column_default, ordinal_position
result_set = ResultSet.new metadata: metadata

row = ListValue.new
row.values.push(
Value.new(string_value: "id"),
Value.new(string_value: "BYTES(16)"),
Value.new(string_value: "NO"),
Value.new(null_value: "NULL_VALUE"),
Value.new(null_value: "NULL_VALUE"),
Value.new(string_value: "1")
)
result_set.rows.push row
row = ListValue.new
row.values.push(
Value.new(string_value: "name"),
Value.new(string_value: "STRING(MAX)"),
Value.new(string_value: "NO"),
Value.new(null_value: "NULL_VALUE"),
Value.new(null_value: "NULL_VALUE"),
Value.new(string_value: "2")
)
result_set.rows.push row
row = ListValue.new
row.values.push(
Value.new(string_value: "description"),
Value.new(string_value: "STRING(MAX)"),
Value.new(string_value: "NO"),
Value.new(null_value: "NULL_VALUE"),
Value.new(null_value: "NULL_VALUE"),
Value.new(string_value: "3")
)
result_set.rows.push row
row = ListValue.new
row.values.push(
Value.new(string_value: "owner_id"),
Value.new(string_value: "BYTES(16)"),
Value.new(string_value: "NO"),
Value.new(null_value: "NULL_VALUE"),
Value.new(null_value: "NULL_VALUE"),
Value.new(string_value: "4")
)
result_set.rows.push row

spanner_mock_server.put_statement_result sql, StatementResult.new(result_set)
end

def self.register_binary_projects_primary_key_columns_result spanner_mock_server
sql = primary_key_columns_sql "binary_projects", parent_keys: false
register_key_columns_result spanner_mock_server, sql
end

def self.register_binary_projects_primary_and_parent_key_columns_result spanner_mock_server
sql = primary_key_columns_sql "binary_projects", parent_keys: true
register_key_columns_result spanner_mock_server, sql
end

def self.register_empty_select_indexes_result spanner_mock_server, sql
col_index_name = Field.new name: "INDEX_NAME", type: Type.new(code: TypeCode::STRING)
col_index_type = Field.new name: "INDEX_TYPE", type: Type.new(code: TypeCode::STRING)
Expand Down
Loading

0 comments on commit 468cb7b

Please sign in to comment.