From 9216d028fec806540ec6763ec941e1422eb30357 Mon Sep 17 00:00:00 2001 From: Tom Conroy <7821185+tbconroy@users.noreply.github.com> Date: Fri, 19 Jul 2024 13:24:37 -0400 Subject: [PATCH] fix!: Ensure that nested structs will deeply serialize (#118) --- lib/sorbet-schema/serialize_value.rb | 2 +- lib/sorbet-schema/t/struct.rb | 2 ++ lib/typed/hash_serializer.rb | 8 +++----- test/support/enums/currency.rb | 7 +++++++ test/support/structs/job.rb | 7 ++++--- test/support/structs/money.rb | 10 ++++++++++ test/t/struct_test.rb | 14 ++++++++++++-- test/typed/coercion/struct_coercer_test.rb | 6 +++--- test/typed/hash_serializer_test.rb | 6 +++--- test/typed/json_serializer_test.rb | 6 +++--- 10 files changed, 48 insertions(+), 20 deletions(-) create mode 100644 test/support/enums/currency.rb create mode 100644 test/support/structs/money.rb diff --git a/lib/sorbet-schema/serialize_value.rb b/lib/sorbet-schema/serialize_value.rb index 87afa71..e39c387 100644 --- a/lib/sorbet-schema/serialize_value.rb +++ b/lib/sorbet-schema/serialize_value.rb @@ -10,7 +10,7 @@ def self.serialize(value) elsif value.is_a?(Array) value.map { |item| serialize(item) } elsif value.is_a?(T::Struct) - value.serialize_to(:hash).payload_or(value) + value.serialize_to(:deeply_nested_hash).payload_or(value) elsif value.respond_to?(:serialize) value.serialize else diff --git a/lib/sorbet-schema/t/struct.rb b/lib/sorbet-schema/t/struct.rb index d5a96c5..75de9df 100644 --- a/lib/sorbet-schema/t/struct.rb +++ b/lib/sorbet-schema/t/struct.rb @@ -15,6 +15,8 @@ def schema sig { params(type: Symbol).returns(Typed::Serializer[T.untyped, T.untyped]) } def serializer(type) case type + when :deeply_nested_hash + Typed::HashSerializer.new(schema:, should_serialize_values: true) when :hash Typed::HashSerializer.new(schema:) when :json diff --git a/lib/typed/hash_serializer.rb b/lib/typed/hash_serializer.rb index 207fb34..e1ad3b4 100644 --- a/lib/typed/hash_serializer.rb +++ b/lib/typed/hash_serializer.rb @@ -6,6 +6,9 @@ class HashSerializer < Serializer Input = type_member { {fixed: InputHash} } Output = type_member { {fixed: Params} } + sig { returns(T::Boolean) } + attr_reader :should_serialize_values + sig { params(schema: Schema, should_serialize_values: T::Boolean).void } def initialize(schema:, should_serialize_values: false) @should_serialize_values = should_serialize_values @@ -24,10 +27,5 @@ def serialize(struct) Success.new(serialize_from_struct(struct:, should_serialize_values:)) end - - private - - sig { returns(T::Boolean) } - attr_reader :should_serialize_values end end diff --git a/test/support/enums/currency.rb b/test/support/enums/currency.rb new file mode 100644 index 0000000..c2c5d09 --- /dev/null +++ b/test/support/enums/currency.rb @@ -0,0 +1,7 @@ +# typed: true + +class Currency < T::Enum + enums do + USD = new("USD") + end +end diff --git a/test/support/structs/job.rb b/test/support/structs/job.rb index 37e53f5..830c819 100644 --- a/test/support/structs/job.rb +++ b/test/support/structs/job.rb @@ -1,12 +1,13 @@ # typed: true require "date" +require_relative "money" class Job < T::Struct include ActsAsComparable const :title, String - const :salary, Integer + const :salary, Money const :start_date, T.nilable(Date) const :needs_credential, T::Boolean, default: false end @@ -19,5 +20,5 @@ class Job < T::Struct Typed::Field.new(name: :start_date, type: T::Utils.coerce(T.nilable(Date)), inline_serializer: ->(start_date) { start_date.strftime("%j %B") }) ] ) -DEVELOPER_JOB = Job.new(title: "Software Developer", salary: 90_000_00) -DEVELOPER_JOB_WITH_START_DATE = Job.new(title: "Software Developer", salary: 90_000_00, start_date: Date.new(2024, 3, 1)) +DEVELOPER_JOB = Job.new(title: "Software Developer", salary: Money.new(cents: 90_000_00)) +DEVELOPER_JOB_WITH_START_DATE = Job.new(title: "Software Developer", salary: Money.new(cents: 90_000_00), start_date: Date.new(2024, 3, 1)) diff --git a/test/support/structs/money.rb b/test/support/structs/money.rb new file mode 100644 index 0000000..b0f5a31 --- /dev/null +++ b/test/support/structs/money.rb @@ -0,0 +1,10 @@ +# typed: false + +require_relative "../enums/currency" + +class Money < T::Struct + include ActsAsComparable + + const :cents, Integer + const :currency, Currency, default: Currency::USD +end diff --git a/test/t/struct_test.rb b/test/t/struct_test.rb index 4e06568..cb48f9e 100644 --- a/test/t/struct_test.rb +++ b/test/t/struct_test.rb @@ -18,7 +18,7 @@ def test_schema_can_be_derived_from_struct_with_default expected_schema = Typed::Schema.new( fields: [ Typed::Field.new(name: :title, type: String), - Typed::Field.new(name: :salary, type: Integer), + Typed::Field.new(name: :salary, type: Money), Typed::Field.new(name: :start_date, type: Date, optional: true), Typed::Field.new(name: :needs_credential, type: T::Utils.coerce(T::Boolean), default: false, optional: true) ], @@ -28,8 +28,18 @@ def test_schema_can_be_derived_from_struct_with_default assert_equal(expected_schema, Job.schema) end + def test_serializer_returns_deeply_nested_hash_serializer + serializer = City.serializer(:deeply_nested_hash) + + assert_kind_of(Typed::HashSerializer, serializer) + assert(T.cast(serializer, Typed::HashSerializer).should_serialize_values) + end + def test_serializer_returns_hash_serializer - assert_kind_of(Typed::HashSerializer, City.serializer(:hash)) + serializer = City.serializer(:hash) + + assert_kind_of(Typed::HashSerializer, serializer) + refute(T.cast(serializer, Typed::HashSerializer).should_serialize_values) end def test_serializer_returns_json_serializer diff --git a/test/typed/coercion/struct_coercer_test.rb b/test/typed/coercion/struct_coercer_test.rb index d157a16..827babd 100644 --- a/test/typed/coercion/struct_coercer_test.rb +++ b/test/typed/coercion/struct_coercer_test.rb @@ -35,7 +35,7 @@ def test_when_struct_of_incorrect_type_given_returns_failure end def test_when_struct_can_be_coerced_returns_success - result = @coercer.coerce(type: @type, value: {"title" => "Software Developer", :salary => 90_000_00}) + result = @coercer.coerce(type: @type, value: {"title" => "Software Developer", :salary => Money.new(cents: 90_000_00)}) assert_success(result) assert_payload(DEVELOPER_JOB, result) @@ -59,7 +59,7 @@ def test_when_struct_has_nested_struct_and_all_values_passed_for_nested_struct name = "Alex" age = 31 stone_rank = "pretty" - salary = 90_000_00 + salary = Money.new(cents: 90_000_00) title = "Software Developer" start_date = Date.new(2024, 3, 1) @@ -80,7 +80,7 @@ def test_when_struct_has_nested_struct_and_optional_start_date_not_passed_for_ne name = "Alex" age = 31 stone_rank = "pretty" - salary = 90_000_00 + salary = Money.new(cents: 90_000_00) title = "Software Developer" result = @coercer.coerce(type: @type_with_nested_struct, value: {name:, age:, stone_rank:, job: {title:, salary:}}) diff --git a/test/typed/hash_serializer_test.rb b/test/typed/hash_serializer_test.rb index 2ebcb82..49c3f1b 100644 --- a/test/typed/hash_serializer_test.rb +++ b/test/typed/hash_serializer_test.rb @@ -41,7 +41,7 @@ def test_it_can_deep_serialize result = serializer.serialize(ALEX_PERSON) assert_success(result) - assert_payload({name: "Alex", age: 31, stone_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: {cents: 9000000, currency: "USD"}, needs_credential: false}}, result) end def test_with_boolean_it_can_serialize @@ -76,7 +76,7 @@ def test_will_use_inline_serializers result = Typed::HashSerializer.new(schema: JOB_SCHEMA_WITH_INLINE_SERIALIZER, should_serialize_values: true).serialize(DEVELOPER_JOB_WITH_START_DATE) assert_success(result) - assert_payload({title: "Software Developer", salary: 90_000_00, start_date: "061 March"}, result) + assert_payload({title: "Software Developer", salary: {cents: 90_000_00, currency: "USD"}, start_date: "061 March"}, result) end def test_with_hash_field_with_string_keys_serializes @@ -124,7 +124,7 @@ def test_with_array_it_can_deep_deserialize end def test_it_can_deserialize_with_nested_object - result = @serializer.deserialize({name: "Alex", age: 31, stone_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: Money.new(cents: 90_000_00)}}) assert_success(result) assert_payload(ALEX_PERSON, result) diff --git a/test/typed/json_serializer_test.rb b/test/typed/json_serializer_test.rb index abe690a..ac04529 100644 --- a/test/typed/json_serializer_test.rb +++ b/test/typed/json_serializer_test.rb @@ -20,7 +20,7 @@ def test_it_can_serialize_with_nested_struct result = @serializer.serialize(ALEX_PERSON) assert_success(result) - assert_payload('{"name":"Alex","age":31,"stone_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":{"cents":9000000,"currency":"USD"},"needs_credential":false}}', result) end def test_with_boolean_it_can_serialize @@ -41,7 +41,7 @@ def test_will_use_inline_serializers result = Typed::JSONSerializer.new(schema: JOB_SCHEMA_WITH_INLINE_SERIALIZER).serialize(DEVELOPER_JOB_WITH_START_DATE) assert_success(result) - assert_payload('{"title":"Software Developer","salary":9000000,"start_date":"061 March"}', result) + assert_payload('{"title":"Software Developer","salary":{"cents":9000000,"currency":"USD"},"start_date":"061 March"}', result) end # Deserialize Tests @@ -68,7 +68,7 @@ def test_with_array_it_can_deep_deserialize end def test_it_can_deserialize_with_nested_object - result = @serializer.deserialize('{"name":"Alex","age":31,"stone_rank":"pretty","job":{"title":"Software Developer","salary":9000000}}') + result = @serializer.deserialize('{"name":"Alex","age":31,"stone_rank":"pretty","job":{"title":"Software Developer","salary":{"cents":9000000,"currency":"USD"}}}') assert_success(result) assert_payload(ALEX_PERSON, result)