Skip to content

Commit

Permalink
feat: implement TypedHashCoercer (#110)
Browse files Browse the repository at this point in the history
  • Loading branch information
maxveldink authored Jul 7, 2024
1 parent 422a995 commit 6d64db7
Show file tree
Hide file tree
Showing 7 changed files with 123 additions and 10 deletions.
3 changes: 2 additions & 1 deletion lib/typed/coercion/coercer_registry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ class CoercerRegistry
DateCoercer,
EnumCoercer,
StructCoercer,
TypedArrayCoercer
TypedArrayCoercer,
TypedHashCoercer
],
Registry
)
Expand Down
50 changes: 50 additions & 0 deletions lib/typed/coercion/typed_hash_coercer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# typed: strict

module Typed
module Coercion
class TypedHashCoercer < Coercer
extend T::Generic

Target = type_member { {fixed: T::Hash[T.untyped, T.untyped]} }

sig { override.params(type: T::Types::Base).returns(T::Boolean) }
def used_for_type?(type)
type.is_a?(T::Types::TypedHash)
end

sig { override.params(type: T::Types::Base, value: Value).returns(Result[Target, CoercionError]) }
def coerce(type:, value:)
return Failure.new(CoercionError.new("Field type must be a T::Hash.")) unless used_for_type?(type)
return Failure.new(CoercionError.new("Value must be a Hash.")) unless value.is_a?(Hash)

return Success.new(value) if type.recursively_valid?(value)

coerced_hash = {}
errors = []

value.each do |k, v|
key_result = Coercion.coerce(type: T::Utils.coerce(T.cast(type, T::Types::TypedHash).type.types.first), value: k)
value_result = Coercion.coerce(type: T::Utils.coerce(T.cast(type, T::Types::TypedHash).type.types.last), value: v)

if key_result.success? && value_result.success?
coerced_hash[key_result.payload] = value_result.payload
else
if key_result.failure?
errors << key_result.error
end

if value_result.failure?
errors << value_result.error
end
end
end

if errors.empty?
Success.new(coerced_hash)
else
Failure.new(CoercionError.new(errors.map(&:message).join(" | ")))
end
end
end
end
end
3 changes: 2 additions & 1 deletion test/support/structs/country.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class Country < T::Struct

const :name, String
const :cities, T::Array[City]
const :national_items, T::Hash[Symbol, String], default: {}
end

US_COUNTRY = Country.new(name: "US", cities: [NEW_YORK_CITY, DC_CITY])
US_COUNTRY = Country.new(name: "US", cities: [NEW_YORK_CITY, DC_CITY], national_items: {bird: "bald eagle", anthem: "The Star-Spangled Banner"})
2 changes: 1 addition & 1 deletion test/typed/coercion/struct_coercer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def test_when_struct_of_correct_type_given_returns_success
end

def test_when_struct_of_incorrect_type_given_returns_failure
result = @coercer.coerce(type: @type, value: Country.new(name: "Canada", cities: []))
result = @coercer.coerce(type: @type, value: Country.new(name: "Canada", cities: [], national_items: {}))

assert_failure(result)
assert_error(Typed::Coercion::CoercionError.new("Value of type 'Country' cannot be coerced to Job Struct."), result)
Expand Down
59 changes: 59 additions & 0 deletions test/typed/coercion/typed_hash_coercer_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# typed: true

require "test_helper"

class TypedHashCoercerTest < Minitest::Test
def setup
@coercer = Typed::Coercion::TypedHashCoercer.new
@type = T::Utils.coerce(T::Hash[String, Integer])
end

def test_used_for_type_works
assert(@coercer.used_for_type?(T::Utils.coerce(T::Hash[String, Integer])))
assert(@coercer.used_for_type?(T::Utils.coerce(T::Hash[String, String])))
assert(@coercer.used_for_type?(T::Utils.coerce(Hash)))
refute(@coercer.used_for_type?(T::Utils.coerce(Integer)))
end

def test_when_non_hash_field_given_returns_failure
result = @coercer.coerce(type: T::Utils.coerce(Integer), value: {"test" => 1})

assert_failure(result)
assert_error(Typed::Coercion::CoercionError.new("Field type must be a T::Hash."), result)
end

def test_when_non_hash_value_given_returns_failure
result = @coercer.coerce(type: @type, value: "testing")

assert_failure(result)
assert_error(Typed::Coercion::CoercionError.new("Value must be a Hash."), result)
end

def test_when_already_of_type_returns_success
result = @coercer.coerce(type: @type, value: {"test" => 1})

assert_success(result)
assert_payload({"test" => 1}, result)
end

def test_when_coercable_hash_can_be_coerced_returns_success
result = @coercer.coerce(type: @type, value: {"test" => "1", 1 => 2})

assert_success(result)
assert_payload({"test" => 1, "1" => 2}, result)
end

def test_when_untyped_hash_returns_success
result = @coercer.coerce(type: T::Utils.coerce(Hash), value: {"test" => 1})

assert_success(result)
assert_payload({"test" => 1}, result)
end

def test_when_array_cannot_be_coerced_returns_failure
result = @coercer.coerce(type: @type, value: {"test" => "test"})

assert_failure(result)
assert_error(Typed::Coercion::CoercionError.new("'test' cannot be coerced into Integer."), result)
end
end
12 changes: 7 additions & 5 deletions test/typed/hash_serializer_test.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# typed: true

require "test_helper"

class HashSerializerTest < Minitest::Test
class StructWithBooleanDefaultSetToTrue < T::Struct
include ActsAsComparable
Expand Down Expand Up @@ -49,18 +51,18 @@ def test_with_boolean_it_can_serialize
assert_payload({name: "New York", capital: false}, result)
end

def test_with_array_it_can_serialize
def test_with_array_and_hash_it_can_serialize
result = Typed::HashSerializer.new(schema: Typed::Schema.from_struct(Country)).serialize(US_COUNTRY)

assert_success(result)
assert_payload({name: "US", cities: [NEW_YORK_CITY, DC_CITY]}, result)
assert_payload({name: "US", cities: [NEW_YORK_CITY, DC_CITY], national_items: {bird: "bald eagle", anthem: "The Star-Spangled Banner"}}, result)
end

def test_with_array_it_can_deep_serialize
result = Typed::HashSerializer.new(schema: Typed::Schema.from_struct(Country), should_serialize_values: true).serialize(US_COUNTRY)

assert_success(result)
assert_payload({name: "US", cities: [{name: "New York", capital: false}, {name: "DC", capital: true}]}, result)
assert_payload({name: "US", cities: [{name: "New York", capital: false}, {name: "DC", capital: true}], national_items: {bird: "bald eagle", anthem: "The Star-Spangled Banner"}}, result)
end

def test_when_struct_given_is_not_of_target_type_returns_failure
Expand Down Expand Up @@ -101,14 +103,14 @@ def test_with_boolean_it_can_deserialize
end

def test_with_array_it_can_deserialize
result = Typed::HashSerializer.new(schema: Typed::Schema.from_struct(Country)).deserialize({name: "US", cities: [NEW_YORK_CITY, DC_CITY]})
result = Typed::HashSerializer.new(schema: Typed::Schema.from_struct(Country)).deserialize({name: "US", cities: [NEW_YORK_CITY, DC_CITY], national_items: {bird: "bald eagle", anthem: "The Star-Spangled Banner"}})

assert_success(result)
assert_payload(US_COUNTRY, result)
end

def test_with_array_it_can_deep_deserialize
result = Typed::HashSerializer.new(schema: Typed::Schema.from_struct(Country)).deserialize({name: "US", cities: [{name: "New York", capital: false}, {name: "DC", capital: true}]})
result = Typed::HashSerializer.new(schema: Typed::Schema.from_struct(Country)).deserialize({name: "US", cities: [{name: "New York", capital: false}, {name: "DC", capital: true}], national_items: {bird: "bald eagle", anthem: "The Star-Spangled Banner"}})

assert_success(result)
assert_payload(US_COUNTRY, result)
Expand Down
4 changes: 2 additions & 2 deletions test/typed/json_serializer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def test_with_array_it_can_serialize
result = Typed::JSONSerializer.new(schema: Typed::Schema.from_struct(Country)).serialize(US_COUNTRY)

assert_success(result)
assert_payload('{"name":"US","cities":[{"name":"New York","capital":false},{"name":"DC","capital":true}]}', result)
assert_payload('{"name":"US","cities":[{"name":"New York","capital":false},{"name":"DC","capital":true}],"national_items":{"bird":"bald eagle","anthem":"The Star-Spangled Banner"}}', result)
end

def test_will_use_inline_serializers
Expand All @@ -61,7 +61,7 @@ def test_with_boolean_it_can_deserialize
end

def test_with_array_it_can_deep_deserialize
result = Typed::JSONSerializer.new(schema: Typed::Schema.from_struct(Country)).deserialize('{"name":"US","cities":[{"name":"New York","capital":false},{"name":"DC","capital":true}]}')
result = Typed::JSONSerializer.new(schema: Typed::Schema.from_struct(Country)).deserialize('{"name":"US","cities":[{"name":"New York","capital":false},{"name":"DC","capital":true}],"national_items":{"bird":"bald eagle","anthem":"The Star-Spangled Banner"}}')

assert_success(result)
assert_payload(US_COUNTRY, result)
Expand Down

0 comments on commit 6d64db7

Please sign in to comment.