Skip to content

Commit

Permalink
fix!: Fix mis-serializing hash keys that were suppose to be strings (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
maxveldink authored Jul 8, 2024
1 parent 162f17d commit 485a6c7
Show file tree
Hide file tree
Showing 11 changed files with 88 additions and 91 deletions.
3 changes: 2 additions & 1 deletion lib/sorbet-schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@
)
loader.setup

# We don't want to place this in the `Typed` module.
# We don't want to place these in the `Typed` module.
# `sorbet-schema` is a directory that is not autoloaded
# but contains extensions, so we need to manually require it.
require_relative "sorbet-schema/hash_transformer"
require_relative "sorbet-schema/serialize_value"

# We want to add a default `schema` method to structs
# that will guarentee a schema can be created for use
Expand Down
47 changes: 12 additions & 35 deletions lib/sorbet-schema/hash_transformer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,44 +4,21 @@
# This is a simplified version of ActiveSupport's Key Hash extension
# https://github.com/rails/rails/blob/main/activesupport/lib/active_support/core_ext/hash/keys.rb
class HashTransformer
extend T::Sig
class << self
extend T::Sig

sig { params(should_serialize_values: T::Boolean).void }
def initialize(should_serialize_values: false)
@should_serialize_values = should_serialize_values
end

sig { params(hash: T::Hash[T.untyped, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
def deep_symbolize_keys(hash)
hash.each_with_object({}) do |(key, value), result|
result[key.to_sym] = transform_value(value, hash_transformation_method: :deep_symbolize_keys)
end
end

sig { params(hash: T::Hash[T.untyped, T.untyped]).returns(T::Hash[String, T.untyped]) }
def deep_stringify_keys(hash)
hash.each_with_object({}) do |(key, value), result|
result[key.to_s] = transform_value(value, hash_transformation_method: :deep_stringify_keys)
sig { params(hash: T::Hash[T.untyped, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
def symbolize_keys(hash)
hash.each_with_object({}) do |(key, value), result|
result[key.to_sym] = value
end
end
end

private

sig { returns(T::Boolean) }
attr_reader :should_serialize_values

sig { params(value: T.untyped, hash_transformation_method: Symbol).returns(T.untyped) }
def transform_value(value, hash_transformation_method:)
if value.is_a?(Hash)
send(hash_transformation_method, value)
elsif value.is_a?(Array)
value.map { |inner_val| transform_value(inner_val, hash_transformation_method: hash_transformation_method) }
elsif value.is_a?(T::Struct) && should_serialize_values
deep_symbolize_keys(value.serialize)
elsif value.respond_to?(:serialize) && should_serialize_values
value.serialize
else
value
sig { params(hash: T::Hash[T.untyped, T.untyped]).returns(T::Hash[T.untyped, T.untyped]) }
def serialize_values(hash)
hash.each_with_object({}) do |(key, value), result|
result[key] = SerializeValue.serialize(value)
end
end
end
end
20 changes: 20 additions & 0 deletions lib/sorbet-schema/serialize_value.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# typed: strict

class SerializeValue
extend T::Sig

sig { params(value: T.untyped).returns(T.untyped) }
def self.serialize(value)
if value.is_a?(Hash)
HashTransformer.serialize_values(value)
elsif value.is_a?(Array)
value.map { |item| serialize(item) }
elsif value.is_a?(T::Struct)
value.serialize_to(:hash).payload_or(value)
elsif value.respond_to?(:serialize)
value.serialize
else
value
end
end
end
4 changes: 2 additions & 2 deletions lib/typed/hash_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@ def initialize(schema:, should_serialize_values: false)

sig { override.params(source: Input).returns(Result[T::Struct, DeserializeError]) }
def deserialize(source)
deserialize_from_creation_params(HashTransformer.new(should_serialize_values: should_serialize_values).deep_symbolize_keys(source))
deserialize_from_creation_params(HashTransformer.symbolize_keys(source))
end

sig { override.params(struct: T::Struct).returns(Result[Output, SerializeError]) }
def serialize(struct)
return Failure.new(SerializeError.new("'#{struct.class}' cannot be serialized to target type of '#{schema.target}'.")) if struct.class != schema.target

Success.new(serialize_from_struct(struct: struct, should_serialize_values: should_serialize_values))
Success.new(serialize_from_struct(struct:, should_serialize_values:))
end

private
Expand Down
2 changes: 1 addition & 1 deletion lib/typed/json_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def deserialize(source)
def serialize(struct)
return Failure.new(SerializeError.new("'#{struct.class}' cannot be serialized to target type of '#{schema.target}'.")) if struct.class != schema.target

Success.new(JSON.generate(serialize_from_struct(struct: struct, should_serialize_values: true)))
Success.new(JSON.generate(serialize_from_struct(struct:, should_serialize_values: true)))
end
end
end
6 changes: 5 additions & 1 deletion lib/typed/serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,11 @@ def deserialize_from_creation_params(creation_params)
def serialize_from_struct(struct:, should_serialize_values: false)
hsh = schema.fields.each_with_object({}) { |field, hsh| hsh[field.name] = field.serialize(struct.send(field.name)) }.compact

HashTransformer.new(should_serialize_values: should_serialize_values).deep_symbolize_keys(hsh)
if should_serialize_values
hsh = HashTransformer.serialize_values(hsh)
end

hsh
end
end
end
60 changes: 10 additions & 50 deletions test/hash_transformer_test.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# typed: true

require "test_helper"

class HashTransformerTest < Minitest::Test
def setup
@test_hash = {
Expand All @@ -15,54 +17,14 @@ def setup
}
}
}
# standard:enable Style/HashSyntax
@transformer = HashTransformer.new
end

def test_deep_symbolize_keys_symbolizes_all_keys
def test_symbolize_keys_symbolizes_first_level_keys
expected_hash = {
test: TestEnums::EnumOne,
another: 1,
deeper: {
anotherhash: 2,
deeperagain: {
value: TestEnums::EnumThree,
boolean: false,
date: Date.new(1776, 7, 4),
array: [1, TestEnums::EnumOne, {verydeep: 1}]
}
}
}

assert_equal(expected_hash, @transformer.deep_symbolize_keys(@test_hash))
end

def test_deep_symbolize_keys_serialize_values_symbolizes_all_keys_and_serializes_values
transformer = HashTransformer.new(should_serialize_values: true)

expected_hash = {
test: "1",
another: 1,
deeper: {
anotherhash: 2,
deeperagain: {
value: "3",
boolean: false,
date: Date.new(1776, 7, 4),
array: [1, "1", {verydeep: 1}]
}
}
}

assert_equal(expected_hash, transformer.deep_symbolize_keys(@test_hash))
end

def test_deep_stringify_keys_stringifies_all_keys
expected_hash = {
"test" => TestEnums::EnumOne,
"another" => 1,
"deeper" => {
"anotherhash" => 2,
:anotherhash => 2,
"deeperagain" => {
"value" => TestEnums::EnumThree,
"boolean" => false,
Expand All @@ -72,17 +34,15 @@ def test_deep_stringify_keys_stringifies_all_keys
}
}

assert_equal(expected_hash, @transformer.deep_stringify_keys(@test_hash))
assert_equal(expected_hash, HashTransformer.symbolize_keys(@test_hash))
end

def test_deep_stringify_keys_serialize_values_stringifies_all_keys_and_serializes_values
transformer = HashTransformer.new(should_serialize_values: true)

def test_serialize_values_serializes_values
expected_hash = {
"test" => "1",
"another" => 1,
"deeper" => {
"anotherhash" => 2,
:another => 1,
:deeper => {
:anotherhash => 2,
"deeperagain" => {
"value" => "3",
"boolean" => false,
Expand All @@ -92,6 +52,6 @@ def test_deep_stringify_keys_serialize_values_stringifies_all_keys_and_serialize
}
}

assert_equal(expected_hash, transformer.deep_stringify_keys(@test_hash))
assert_equal(expected_hash, HashTransformer.serialize_values(@test_hash))
end
end
25 changes: 25 additions & 0 deletions test/serialize_value_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# typed: true

require "test_helper"

class SerializeValueTest < Minitest::Test
def test_when_value_is_a_hash_returns_each_value_serialized
assert_equal({:one => "1", "two" => "2"}, SerializeValue.serialize({:one => TestEnums::EnumOne, "two" => TestEnums::EnumTwo}))
end

def test_when_value_is_an_array_returns_each_value_serialized
assert_equal(["1", "2"], SerializeValue.serialize([TestEnums::EnumOne, TestEnums::EnumTwo]))
end

def test_when_value_is_a_struct_returns_serialized_struct
assert_equal({name: "DC", capital: true}, SerializeValue.serialize(DC_CITY))
end

def test_when_value_implements_serialize_returns_serialized_value
assert_equal("1", SerializeValue.serialize(TestEnums::EnumOne))
end

def test_when_value_doesnt_serialize_returns_value
assert_equal(1, SerializeValue.serialize(1))
end
end
2 changes: 2 additions & 0 deletions test/support/structs/city.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ class City < T::Struct

const :name, String
const :capital, T::Boolean
const :data, T.nilable(T::Hash[String, Integer])
end

NEW_YORK_CITY = City.new(name: "New York", capital: false)
DC_CITY = City.new(name: "DC", capital: true)
OVIEDO_CITY = City.new(name: "Oviedo", capital: false, data: {"how many maxes live here?" => 1})
3 changes: 2 additions & 1 deletion test/t/struct_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ def test_schema_can_be_derived_from_struct
expected_schema = Typed::Schema.new(
fields: [
Typed::Field.new(name: :name, type: String),
Typed::Field.new(name: :capital, type: T::Utils.coerce(T::Boolean))
Typed::Field.new(name: :capital, type: T::Utils.coerce(T::Boolean)),
Typed::Field.new(name: :data, type: T::Utils.coerce(T::Hash[String, Integer]), optional: true)
],
target: City
)
Expand Down
7 changes: 7 additions & 0 deletions test/typed/hash_serializer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@ def test_will_use_inline_serializers
assert_payload({title: "Software Developer", salary: 90_000_00, start_date: "061 March"}, result)
end

def test_with_hash_field_with_string_keys_serializes
result = Typed::HashSerializer.new(schema: City.schema).serialize(OVIEDO_CITY)

assert_success(result)
assert_payload({name: "Oviedo", capital: false, data: {"how many maxes live here?" => 1}}, result)
end

# Deserialize Tests

def test_it_can_simple_deserialize
Expand Down

0 comments on commit 485a6c7

Please sign in to comment.