From 172393c058ec9ab48082cdf9421283de0b01a065 Mon Sep 17 00:00:00 2001 From: Max VelDink Date: Thu, 21 Mar 2024 10:11:30 -0400 Subject: [PATCH 1/2] refactor: Match Job testing struct to other structs and add optional start date --- test/support/structs/job.rb | 6 ++++++ test/support/structs/person.rb | 2 +- test/typed/coercion/struct_coercer_test.rb | 8 +++----- test/typed/hash_serializer_test.rb | 8 ++++---- test/typed/json_serializer_test.rb | 4 ++-- 5 files changed, 16 insertions(+), 12 deletions(-) diff --git a/test/support/structs/job.rb b/test/support/structs/job.rb index 88cea4c..a3d2d5e 100644 --- a/test/support/structs/job.rb +++ b/test/support/structs/job.rb @@ -1,8 +1,14 @@ # typed: true +require "date" + class Job < T::Struct include ActsAsComparable const :title, String const :salary, Integer + const :start_date, T.nilable(Date) end + +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)) diff --git a/test/support/structs/person.rb b/test/support/structs/person.rb index 377533f..3754ab8 100644 --- a/test/support/structs/person.rb +++ b/test/support/structs/person.rb @@ -13,4 +13,4 @@ class Person < T::Struct 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: Job.new(title: "Software Developer", salary: 1_000_000_00)) +ALEX_PERSON = Person.new(name: "Alex", age: 31, ruby_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 02ed046..5db3eaa 100644 --- a/test/typed/coercion/struct_coercer_test.rb +++ b/test/typed/coercion/struct_coercer_test.rb @@ -20,12 +20,10 @@ def test_when_non_struct_type_given_returns_failure end def test_when_struct_of_correct_type_given_returns_success - job = Job.new(title: "Software Developer", salary: 90_000_00) - - result = @coercer.coerce(type: @type, value: job) + result = @coercer.coerce(type: @type, value: DEVELOPER_JOB) assert_success(result) - assert_payload(job, result) + assert_payload(DEVELOPER_JOB, result) end def test_when_struct_of_incorrect_type_given_returns_failure @@ -39,7 +37,7 @@ def test_when_struct_can_be_coerced_returns_success result = @coercer.coerce(type: @type, value: {"title" => "Software Developer", :salary => 90_000_00}) assert_success(result) - assert_payload(Job.new(title: "Software Developer", salary: 90_000_00), result) + assert_payload(DEVELOPER_JOB, result) end def test_when_value_is_not_a_hash_returns_failure diff --git a/test/typed/hash_serializer_test.rb b/test/typed/hash_serializer_test.rb index 41c8d9f..7e72383 100644 --- a/test/typed/hash_serializer_test.rb +++ b/test/typed/hash_serializer_test.rb @@ -18,7 +18,7 @@ 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: Job.new(title: "Software Developer", salary: 1_000_000_00)}, result) + assert_payload({name: "Alex", age: 31, ruby_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: 1_000_000_00}}, result) + assert_payload({name: "Alex", age: 31, ruby_rank: "pretty", job: {title: "Software Developer", salary: 90_000_00}}, result) end def test_with_boolean_it_can_serialize @@ -52,7 +52,7 @@ def test_with_array_it_can_deep_serialize end def test_when_struct_given_is_not_of_target_type_returns_failure - result = @serializer.serialize(Job.new(title: "Testing", salary: 90_00)) + result = @serializer.serialize(DEVELOPER_JOB) assert_failure(result) assert_error(Typed::SerializeError.new("'Job' cannot be serialized to target type of 'Person'."), result) @@ -96,7 +96,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, ruby_rank: "pretty", job: {title: "Software Developer", salary: 1_000_000_00}}) + result = @serializer.deserialize({name: "Alex", age: 31, ruby_rank: "pretty", job: {title: "Software Developer", salary: 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 f05aca1..859ea9b 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,"ruby_rank":"pretty","job":{"title":"Software Developer","salary":100000000}}', result) + assert_payload('{"name":"Alex","age":31,"ruby_rank":"pretty","job":{"title":"Software Developer","salary":9000000}}', result) end def test_with_boolean_it_can_serialize @@ -61,7 +61,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,"ruby_rank":"pretty","job":{"title":"Software Developer","salary":100000000}}') + result = @serializer.deserialize('{"name":"Alex","age":31,"ruby_rank":"pretty","job":{"title":"Software Developer","salary":9000000}}') assert_success(result) assert_payload(ALEX_PERSON, result) From f33d4b725f9b3fbc11159f9d6ce3e2399e377a19 Mon Sep 17 00:00:00 2001 From: Max VelDink Date: Thu, 21 Mar 2024 13:44:02 -0400 Subject: [PATCH 2/2] feat: Add inline serializer to fields --- lib/typed/field.rb | 26 ++++++++++++++++++++++++-- lib/typed/hash_serializer.rb | 4 +--- lib/typed/json_serializer.rb | 2 +- lib/typed/serializer.rb | 7 +++++++ test/support/structs/job.rb | 8 ++++++++ test/typed/field_test.rb | 12 ++++++++++++ test/typed/hash_serializer_test.rb | 7 +++++++ test/typed/json_serializer_test.rb | 7 +++++++ 8 files changed, 67 insertions(+), 6 deletions(-) diff --git a/lib/typed/field.rb b/lib/typed/field.rb index d59ed35..1c25404 100644 --- a/lib/typed/field.rb +++ b/lib/typed/field.rb @@ -4,6 +4,8 @@ module Typed class Field extend T::Sig + InlineSerializer = T.type_alias { T.proc.params(arg0: T.untyped).returns(T.untyped) } + sig { returns(Symbol) } attr_reader :name @@ -13,11 +15,22 @@ class Field sig { returns(T::Boolean) } attr_reader :required - sig { params(name: Symbol, type: T.any(T::Class[T.anything], T::Types::Base), required: T::Boolean).void } - def initialize(name:, type:, required: true) + sig { returns(T.nilable(InlineSerializer)) } + attr_reader :inline_serializer + + sig do + params( + name: Symbol, + type: T.any(T::Class[T.anything], T::Types::Base), + required: T::Boolean, + inline_serializer: T.nilable(InlineSerializer) + ).void + end + def initialize(name:, type:, required: true, inline_serializer: nil) @name = name @type = T.let(T::Utils.coerce(type), T::Types::Base) @required = required + @inline_serializer = inline_serializer end sig { params(other: Field).returns(T.nilable(T::Boolean)) } @@ -37,6 +50,15 @@ def optional? !required end + sig { params(value: Value).returns(Value) } + def serialize(value) + if inline_serializer && value + T.must(inline_serializer).call(value) + else + value + end + end + sig { params(value: Value).returns(Validations::ValidationResult) } def validate(value) Validations::FieldTypeValidator.new.validate(field: self, value: value) diff --git a/lib/typed/hash_serializer.rb b/lib/typed/hash_serializer.rb index 861ff61..63e1b91 100644 --- a/lib/typed/hash_serializer.rb +++ b/lib/typed/hash_serializer.rb @@ -22,9 +22,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 - hsh = schema.fields.each_with_object({}) { |field, hsh| hsh[field.name] = struct.send(field.name) } - - Success.new(HashTransformer.new(should_serialize_values: should_serialize_values).deep_symbolize_keys(hsh.compact)) + Success.new(serialize_from_struct(struct: struct, should_serialize_values: should_serialize_values)) end private diff --git a/lib/typed/json_serializer.rb b/lib/typed/json_serializer.rb index 08a5dc2..d03034e 100644 --- a/lib/typed/json_serializer.rb +++ b/lib/typed/json_serializer.rb @@ -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(struct.serialize)) + Success.new(JSON.generate(serialize_from_struct(struct: struct, should_serialize_values: true))) end end end diff --git a/lib/typed/serializer.rb b/lib/typed/serializer.rb index f91fa06..21e91ca 100644 --- a/lib/typed/serializer.rb +++ b/lib/typed/serializer.rb @@ -55,5 +55,12 @@ def deserialize_from_creation_params(creation_params) Success.new(schema.target.new(**validated_params)) end end + + sig { params(struct: T::Struct, should_serialize_values: T::Boolean).returns(T::Hash[Symbol, T.untyped]) } + 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) + end end end diff --git a/test/support/structs/job.rb b/test/support/structs/job.rb index a3d2d5e..ce81b72 100644 --- a/test/support/structs/job.rb +++ b/test/support/structs/job.rb @@ -10,5 +10,13 @@ class Job < T::Struct const :start_date, T.nilable(Date) end +JOB_SCHEMA_WITH_INLINE_SERIALIZER = Typed::Schema.new( + target: Job, + fields: [ + Typed::Field.new(name: :title, type: String), + Typed::Field.new(name: :salary, type: Integer), + Typed::Field.new(name: :start_date, type: Date, required: false, 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)) diff --git a/test/typed/field_test.rb b/test/typed/field_test.rb index 89500a1..31145b2 100644 --- a/test/typed/field_test.rb +++ b/test/typed/field_test.rb @@ -27,6 +27,18 @@ def test_required_and_optional_helpers_work_when_optional refute_predicate(@optional_field, :required?) end + def test_when_inline_serializer_serialize_uses_it + field = Typed::Field.new(name: :testing, type: String, inline_serializer: ->(_value) { "banana" }) + + assert_equal("banana", field.serialize("testing")) + assert_nil(field.serialize(nil)) + end + + def test_when_no_inline_serializer_serialize_returns_given_value + assert_equal("testing", @required_field.serialize("testing")) + assert_nil(@required_field.serialize(nil)) + end + def test_when_standard_type_work_with_works assert(@required_field.works_with?("Max")) refute(@required_field.works_with?(1)) diff --git a/test/typed/hash_serializer_test.rb b/test/typed/hash_serializer_test.rb index 7e72383..0ee7f38 100644 --- a/test/typed/hash_serializer_test.rb +++ b/test/typed/hash_serializer_test.rb @@ -58,6 +58,13 @@ def test_when_struct_given_is_not_of_target_type_returns_failure assert_error(Typed::SerializeError.new("'Job' cannot be serialized to target type of 'Person'."), result) end + 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) + end + # Deserialize Tests def test_it_can_simple_deserialize diff --git a/test/typed/json_serializer_test.rb b/test/typed/json_serializer_test.rb index 859ea9b..501710e 100644 --- a/test/typed/json_serializer_test.rb +++ b/test/typed/json_serializer_test.rb @@ -37,6 +37,13 @@ def test_with_array_it_can_serialize assert_payload('{"name":"US","cities":[{"name":"New York","capital":false},{"name":"DC","capital":true}]}', result) end + 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) + end + # Deserialize Tests def test_it_can_simple_deserialize