Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support T.any for deserialization #107

Merged
merged 2 commits into from
Jul 5, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions lib/typed/serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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:)

Expand Down
10 changes: 10 additions & 0 deletions test/support/enums/diamond_rank.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# typed: true

class DiamondRank < T::Enum
enums do
Excellent = new
Good = new
Fair = new
Poor = new
end
end
7 changes: 4 additions & 3 deletions test/support/structs/person.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let me know if you prefer what you had as ruby_rank and I can just create a different think rather than piggy-backing on what you had

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool with piggy backing on this struct 👍🏻

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)
12 changes: 6 additions & 6 deletions test/typed/coercion/struct_coercer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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:)
)

Expand All @@ -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:)
)

Expand Down
37 changes: 29 additions & 8 deletions test/typed/hash_serializer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
14 changes: 7 additions & 7 deletions test/typed/json_serializer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions test/typed/schema_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down