From b7922833c668547ca767278747f5564f1fcf2a4f Mon Sep 17 00:00:00 2001 From: Antoine Saliba Date: Tue, 2 Jul 2024 22:15:22 -0400 Subject: [PATCH] feat: support T.any for deserialization --- lib/typed/serializer.rb | 20 ++++++++++++ test/support/enums/diamond_rank.rb | 10 ++++++ test/support/structs/person.rb | 7 ++-- test/typed/coercion/struct_coercer_test.rb | 12 +++---- test/typed/hash_serializer_test.rb | 37 +++++++++++++++++----- test/typed/json_serializer_test.rb | 14 ++++---- test/typed/schema_test.rb | 6 ++-- 7 files changed, 79 insertions(+), 27 deletions(-) create mode 100644 test/support/enums/diamond_rank.rb diff --git a/lib/typed/serializer.rb b/lib/typed/serializer.rb index b07263e..c44f3bf 100644 --- a/lib/typed/serializer.rb +++ b/lib/typed/serializer.rb @@ -39,6 +39,26 @@ def deserialize_from_creation_params(creation_params) Success.new(Validations::ValidatedValue.new(field.default)) elsif value.nil? || field.works_with?(value) field.validate(value) + elsif field.type.class <= T::Types::Union + errors = [] + validated_value = T.let(nil, T.nilable(Typed::Result[Typed::Validations::ValidatedValue, Typed::Validations::ValidationError])) + + T.cast(field.type, T::Types::Union).types.each do |sub_type| + # the if clause took care of cases where value is nil so we can skip NilClass + next if sub_type.raw_type.equal?(NilClass) + + coercion_result = Coercion.coerce(type: sub_type, value: value) + + if coercion_result.success? + validated_value = field.validate(coercion_result.payload) + + break + else + errors << Validations::ValidationError.new(coercion_result.error.message) + end + end + + validated_value.nil? ? Failure.new(Validations::ValidationError.new(errors.map(&:message).join(", "))) : validated_value else coercion_result = Coercion.coerce(type: field.type, value:) diff --git a/test/support/enums/diamond_rank.rb b/test/support/enums/diamond_rank.rb new file mode 100644 index 0000000..6546c56 --- /dev/null +++ b/test/support/enums/diamond_rank.rb @@ -0,0 +1,10 @@ +# typed: true + +class DiamondRank < T::Enum + enums do + Excellent = new + Good = new + Fair = new + Poor = new + end +end diff --git a/test/support/structs/person.rb b/test/support/structs/person.rb index 3754ab8..83cf107 100644 --- a/test/support/structs/person.rb +++ b/test/support/structs/person.rb @@ -2,15 +2,16 @@ require_relative "job" require_relative "../enums/ruby_rank" +require_relative "../enums/diamond_rank" class Person < T::Struct include ActsAsComparable const :name, String const :age, Integer - const :ruby_rank, RubyRank + const :stone_rank, T.any(RubyRank, DiamondRank) const :job, T.nilable(Job) end -MAX_PERSON = Person.new(name: "Max", age: 29, ruby_rank: RubyRank::Luminary) -ALEX_PERSON = Person.new(name: "Alex", age: 31, ruby_rank: RubyRank::Brilliant, job: DEVELOPER_JOB) +MAX_PERSON = Person.new(name: "Max", age: 29, stone_rank: RubyRank::Luminary) +ALEX_PERSON = Person.new(name: "Alex", age: 31, stone_rank: RubyRank::Brilliant, job: DEVELOPER_JOB) diff --git a/test/typed/coercion/struct_coercer_test.rb b/test/typed/coercion/struct_coercer_test.rb index e81ddea..f1a03c3 100644 --- a/test/typed/coercion/struct_coercer_test.rb +++ b/test/typed/coercion/struct_coercer_test.rb @@ -58,17 +58,17 @@ def test_when_struct_cannot_be_coerced_returns_failure def test_when_struct_has_nested_struct_and_all_values_passed_for_nested_struct name = "Alex" age = 31 - ruby_rank = "pretty" + stone_rank = "pretty" salary = 90_000_00 title = "Software Developer" start_date = Date.new(2024, 3, 1) - result = @coercer.coerce(type: @type_with_nested_struct, value: {name:, age:, ruby_rank:, job: {title:, salary:, start_date:}}) + result = @coercer.coerce(type: @type_with_nested_struct, value: {name:, age:, stone_rank:, job: {title:, salary:, start_date:}}) person = Person.new( name:, age:, - ruby_rank: RubyRank.deserialize(ruby_rank), + stone_rank: RubyRank.deserialize(stone_rank), job: Job.new(title:, salary:, start_date:) ) @@ -79,16 +79,16 @@ def test_when_struct_has_nested_struct_and_all_values_passed_for_nested_struct def test_when_struct_has_nested_struct_and_optional_start_date_not_passed_for_nested_struct name = "Alex" age = 31 - ruby_rank = "pretty" + stone_rank = "pretty" salary = 90_000_00 title = "Software Developer" - result = @coercer.coerce(type: @type_with_nested_struct, value: {name:, age:, ruby_rank:, job: {title:, salary:}}) + result = @coercer.coerce(type: @type_with_nested_struct, value: {name:, age:, stone_rank:, job: {title:, salary:}}) person = Person.new( name:, age:, - ruby_rank: RubyRank.deserialize(ruby_rank), + stone_rank: RubyRank.deserialize(stone_rank), job: Job.new(title:, salary:) ) diff --git a/test/typed/hash_serializer_test.rb b/test/typed/hash_serializer_test.rb index 4813ef7..58cdd65 100644 --- a/test/typed/hash_serializer_test.rb +++ b/test/typed/hash_serializer_test.rb @@ -11,14 +11,14 @@ def test_it_can_simple_serialize result = @serializer.serialize(MAX_PERSON) assert_success(result) - assert_payload({name: "Max", age: 29, ruby_rank: RubyRank::Luminary}, result) + assert_payload({name: "Max", age: 29, stone_rank: RubyRank::Luminary}, result) end def test_it_can_serialize_with_nested_struct result = @serializer.serialize(ALEX_PERSON) assert_success(result) - assert_payload({name: "Alex", age: 31, ruby_rank: RubyRank::Brilliant, job: DEVELOPER_JOB}, result) + assert_payload({name: "Alex", age: 31, stone_rank: RubyRank::Brilliant, job: DEVELOPER_JOB}, result) end def test_it_can_deep_serialize @@ -27,7 +27,7 @@ def test_it_can_deep_serialize result = serializer.serialize(ALEX_PERSON) assert_success(result) - assert_payload({name: "Alex", age: 31, ruby_rank: "pretty", job: {title: "Software Developer", salary: 90_000_00, needs_credential: false}}, result) + assert_payload({name: "Alex", age: 31, stone_rank: "pretty", job: {title: "Software Developer", salary: 90_000_00, needs_credential: false}}, result) end def test_with_boolean_it_can_serialize @@ -68,14 +68,14 @@ def test_will_use_inline_serializers # Deserialize Tests def test_it_can_simple_deserialize - result = @serializer.deserialize({name: "Max", age: 29, ruby_rank: RubyRank::Luminary}) + result = @serializer.deserialize({name: "Max", age: 29, stone_rank: RubyRank::Luminary}) assert_success(result) assert_payload(MAX_PERSON, result) end def test_it_can_simple_deserialize_from_string_keys - result = @serializer.deserialize({"name" => "Max", "age" => 29, "ruby_rank" => RubyRank::Luminary}) + result = @serializer.deserialize({"name" => "Max", "age" => 29, "stone_rank" => RubyRank::Luminary}) assert_success(result) assert_payload(MAX_PERSON, result) @@ -103,14 +103,35 @@ def test_with_array_it_can_deep_deserialize end def test_it_can_deserialize_with_nested_object - result = @serializer.deserialize({name: "Alex", age: 31, ruby_rank: "pretty", job: {title: "Software Developer", salary: 90_000_00}}) + result = @serializer.deserialize({name: "Alex", age: 31, stone_rank: "pretty", job: {title: "Software Developer", salary: 90_000_00}}) assert_success(result) assert_payload(ALEX_PERSON, result) end + def test_it_can_deserialize_something_that_is_the_first_of_multiple_types + result = Typed::HashSerializer.new(schema: Typed::Schema.from_struct(Person)).deserialize({name: "Max", age: 29, stone_rank: "shiny"}) + + assert_success(result) + assert_payload(MAX_PERSON, result) + end + + def test_it_can_deserialize_something_that_is_the_second_of_multiple_types + result = Typed::HashSerializer.new(schema: Typed::Schema.from_struct(Person)).deserialize({name: "Max", age: 29, stone_rank: "good"}) + + assert_success(result) + assert_payload(Person.new(name: "Max", age: 29, stone_rank: DiamondRank::Good), result) + end + + def test_if_it_cannot_be_deserialized_against_something_with_multiple_types_it_will_fail + result = Typed::HashSerializer.new(schema: Typed::Schema.from_struct(Person)).deserialize({name: "Max", age: 29, stone_rank: "not valid"}) + + assert_failure(result) + assert_error(Typed::Validations::ValidationError.new('Enum RubyRank key not found: "not valid", Enum DiamondRank key not found: "not valid"'), result) + end + def test_it_reports_validation_errors_on_deserialize - result = @serializer.deserialize({name: "Max", ruby_rank: RubyRank::Luminary}) + result = @serializer.deserialize({name: "Max", stone_rank: RubyRank::Luminary}) assert_failure(result) assert_error(Typed::Validations::RequiredFieldError.new(field_name: :age), result) @@ -125,7 +146,7 @@ def test_it_reports_multiple_validation_errors_on_deserialize errors: [ Typed::Validations::RequiredFieldError.new(field_name: :name), Typed::Validations::RequiredFieldError.new(field_name: :age), - Typed::Validations::RequiredFieldError.new(field_name: :ruby_rank) + Typed::Validations::RequiredFieldError.new(field_name: :stone_rank) ] ), result diff --git a/test/typed/json_serializer_test.rb b/test/typed/json_serializer_test.rb index 174b8e1..b417c18 100644 --- a/test/typed/json_serializer_test.rb +++ b/test/typed/json_serializer_test.rb @@ -13,14 +13,14 @@ def test_it_can_simple_serialize result = @serializer.serialize(MAX_PERSON) assert_success(result) - assert_payload('{"name":"Max","age":29,"ruby_rank":"shiny"}', result) + assert_payload('{"name":"Max","age":29,"stone_rank":"shiny"}', result) end def test_it_can_serialize_with_nested_struct result = @serializer.serialize(ALEX_PERSON) assert_success(result) - assert_payload('{"name":"Alex","age":31,"ruby_rank":"pretty","job":{"title":"Software Developer","salary":9000000,"needs_credential":false}}', result) + assert_payload('{"name":"Alex","age":31,"stone_rank":"pretty","job":{"title":"Software Developer","salary":9000000,"needs_credential":false}}', result) end def test_with_boolean_it_can_serialize @@ -47,7 +47,7 @@ def test_will_use_inline_serializers # Deserialize Tests def test_it_can_simple_deserialize - result = @serializer.deserialize('{"name":"Max","age":29,"ruby_rank":"shiny"}') + result = @serializer.deserialize('{"name":"Max","age":29,"stone_rank":"shiny"}') assert_success(result) assert_payload(MAX_PERSON, result) @@ -68,21 +68,21 @@ def test_with_array_it_can_deep_deserialize end def test_it_can_deserialize_with_nested_object - result = @serializer.deserialize('{"name":"Alex","age":31,"ruby_rank":"pretty","job":{"title":"Software Developer","salary":9000000}}') + result = @serializer.deserialize('{"name":"Alex","age":31,"stone_rank":"pretty","job":{"title":"Software Developer","salary":9000000}}') assert_success(result) assert_payload(ALEX_PERSON, result) end def test_it_reports_on_parse_errors_on_deserialize - result = @serializer.deserialize('{"name": "Max", age": 29, "ruby_rank": "shiny"}') # Missing quotation + result = @serializer.deserialize('{"name": "Max", age": 29, "stone_rank": "shiny"}') # Missing quotation assert_failure(result) assert_error(Typed::ParseError.new(format: :json), result) end def test_it_reports_validation_errors_on_deserialize - result = @serializer.deserialize('{"name": "Max", "ruby_rank": "shiny"}') + result = @serializer.deserialize('{"name": "Max", "stone_rank": "shiny"}') assert_failure(result) assert_error(Typed::Validations::RequiredFieldError.new(field_name: :age), result) @@ -99,7 +99,7 @@ def test_it_reports_multiple_validation_errors_on_deserialize errors: [ Typed::Validations::RequiredFieldError.new(field_name: :name), Typed::Validations::RequiredFieldError.new(field_name: :age), - Typed::Validations::RequiredFieldError.new(field_name: :ruby_rank) + Typed::Validations::RequiredFieldError.new(field_name: :stone_rank) ] ), result diff --git a/test/typed/schema_test.rb b/test/typed/schema_test.rb index ce237c1..74693d4 100644 --- a/test/typed/schema_test.rb +++ b/test/typed/schema_test.rb @@ -6,7 +6,7 @@ def setup fields: [ Typed::Field.new(name: :name, type: String), Typed::Field.new(name: :age, type: Integer), - Typed::Field.new(name: :ruby_rank, type: RubyRank), + Typed::Field.new(name: :stone_rank, type: T::Utils.coerce(T.any(RubyRank, DiamondRank))), Typed::Field.new(name: :job, type: Job, optional: true) ], target: Person @@ -18,14 +18,14 @@ def test_from_struct_returns_schema end def test_from_hash_create_struct - result = @schema.from_hash({name: "Max", age: 29, ruby_rank: RubyRank::Luminary}) + result = @schema.from_hash({name: "Max", age: 29, stone_rank: RubyRank::Luminary}) assert_success(result) assert_payload(MAX_PERSON, result) end def test_from_json_creates_struct - result = @schema.from_json('{"name": "Max", "age": 29, "ruby_rank": "shiny"}') + result = @schema.from_json('{"name": "Max", "age": 29, "stone_rank": "shiny"}') assert_success(result) assert_payload(MAX_PERSON, result)