diff --git a/lib/typed/coercion/boolean_coercer.rb b/lib/typed/coercion/boolean_coercer.rb new file mode 100644 index 0000000..0ed09d4 --- /dev/null +++ b/lib/typed/coercion/boolean_coercer.rb @@ -0,0 +1,31 @@ +# typed: strict + +module Typed + module Coercion + class BooleanCoercer < Coercer + extend T::Generic + + Target = type_member { {fixed: T::Boolean} } + + sig { override.params(type: Field::Type).returns(T::Boolean) } + def used_for_type?(type) + type == T::Utils.coerce(T::Boolean) + end + + sig { override.params(field: Field, value: Value).returns(Result[Target, CoercionError]) } + def coerce(field:, value:) + if T.cast(field.type, T::Types::Base).valid?(value) + Success.new(value) + elsif value == "true" + Success.new(true) + elsif value == "false" + Success.new(false) + else + Failure.new(CoercionError.new) + end + rescue TypeError + Failure.new(CoercionError.new("Field type must be a T::Boolean.")) + end + end + end +end diff --git a/lib/typed/coercion/coercer.rb b/lib/typed/coercion/coercer.rb index 0d50345..c7776e2 100644 --- a/lib/typed/coercion/coercer.rb +++ b/lib/typed/coercion/coercer.rb @@ -10,7 +10,7 @@ class Coercer Target = type_member(:out) - sig { abstract.params(type: T::Class[T.anything]).returns(T::Boolean) } + sig { abstract.params(type: Field::Type).returns(T::Boolean) } def used_for_type?(type) end diff --git a/lib/typed/coercion/coercer_registry.rb b/lib/typed/coercion/coercer_registry.rb index 2f8cda2..47a086d 100644 --- a/lib/typed/coercion/coercer_registry.rb +++ b/lib/typed/coercion/coercer_registry.rb @@ -11,7 +11,7 @@ class CoercerRegistry Registry = T.type_alias { T::Array[T.class_of(Coercer)] } - DEFAULT_COERCERS = T.let([StringCoercer, IntegerCoercer, FloatCoercer, EnumCoercer, StructCoercer], Registry) + DEFAULT_COERCERS = T.let([StringCoercer, BooleanCoercer, IntegerCoercer, FloatCoercer, EnumCoercer, StructCoercer], Registry) sig { void } def initialize @@ -28,7 +28,7 @@ def reset! @available = DEFAULT_COERCERS.clone end - sig { params(type: T::Class[T.anything]).returns(T.nilable(T.class_of(Coercer))) } + sig { params(type: Field::Type).returns(T.nilable(T.class_of(Coercer))) } def select_coercer_by(type:) @available.find { |coercer| coercer.new.used_for_type?(type) } end diff --git a/lib/typed/coercion/enum_coercer.rb b/lib/typed/coercion/enum_coercer.rb index c0537c5..c0070e8 100644 --- a/lib/typed/coercion/enum_coercer.rb +++ b/lib/typed/coercion/enum_coercer.rb @@ -7,16 +7,16 @@ class EnumCoercer < Coercer Target = type_member { {fixed: T::Enum} } - sig { override.params(type: T::Class[T.anything]).returns(T::Boolean) } + sig { override.params(type: Field::Type).returns(T::Boolean) } def used_for_type?(type) - !!(type < T::Enum) + type.is_a?(Class) && !!(type < T::Enum) end sig { override.params(field: Field, value: Value).returns(Result[Target, CoercionError]) } def coerce(field:, value:) type = field.type - return Failure.new(CoercionError.new("Field type must inherit from T::Enum for Enum coercion.")) unless type < T::Enum + return Failure.new(CoercionError.new("Field type must inherit from T::Enum for Enum coercion.")) unless type.is_a?(Class) && !!(type < T::Enum) Success.new(type.from_serialized(value)) rescue KeyError => e diff --git a/lib/typed/coercion/float_coercer.rb b/lib/typed/coercion/float_coercer.rb index 526637f..fb0db82 100644 --- a/lib/typed/coercion/float_coercer.rb +++ b/lib/typed/coercion/float_coercer.rb @@ -7,7 +7,7 @@ class FloatCoercer < Coercer Target = type_member { {fixed: Float} } - sig { override.params(type: T::Class[T.anything]).returns(T::Boolean) } + sig { override.params(type: Field::Type).returns(T::Boolean) } def used_for_type?(type) type == Float end diff --git a/lib/typed/coercion/integer_coercer.rb b/lib/typed/coercion/integer_coercer.rb index 6a24396..faa4fd1 100644 --- a/lib/typed/coercion/integer_coercer.rb +++ b/lib/typed/coercion/integer_coercer.rb @@ -7,7 +7,7 @@ class IntegerCoercer < Coercer Target = type_member { {fixed: Integer} } - sig { override.params(type: T::Class[T.anything]).returns(T::Boolean) } + sig { override.params(type: Field::Type).returns(T::Boolean) } def used_for_type?(type) type == Integer end diff --git a/lib/typed/coercion/string_coercer.rb b/lib/typed/coercion/string_coercer.rb index 29e8b41..73c9e71 100644 --- a/lib/typed/coercion/string_coercer.rb +++ b/lib/typed/coercion/string_coercer.rb @@ -7,7 +7,7 @@ class StringCoercer < Coercer Target = type_member { {fixed: String} } - sig { override.params(type: T::Class[T.anything]).returns(T::Boolean) } + sig { override.params(type: Field::Type).returns(T::Boolean) } def used_for_type?(type) type == String end diff --git a/lib/typed/coercion/struct_coercer.rb b/lib/typed/coercion/struct_coercer.rb index 9b14d9f..be51421 100644 --- a/lib/typed/coercion/struct_coercer.rb +++ b/lib/typed/coercion/struct_coercer.rb @@ -7,16 +7,16 @@ class StructCoercer < Coercer Target = type_member { {fixed: T::Struct} } - sig { override.params(type: T::Class[T.anything]).returns(T::Boolean) } + sig { override.params(type: Field::Type).returns(T::Boolean) } def used_for_type?(type) - !!(type < T::Struct) + type.is_a?(Class) && !!(type < T::Struct) end sig { override.params(field: Field, value: Value).returns(Result[Target, CoercionError]) } def coerce(field:, value:) type = field.type - return Failure.new(CoercionError.new("Field type must inherit from T::Struct for Struct coercion.")) unless type < T::Struct + return Failure.new(CoercionError.new("Field type must inherit from T::Struct for Struct coercion.")) unless type.is_a?(Class) && type < T::Struct return Failure.new(CoercionError.new("Value must be a Hash for Struct coercion.")) unless value.is_a?(Hash) Success.new(type.from_hash!(HashTransformer.new.deep_stringify_keys(value))) diff --git a/lib/typed/field.rb b/lib/typed/field.rb index 80ffdac..b22ad28 100644 --- a/lib/typed/field.rb +++ b/lib/typed/field.rb @@ -6,8 +6,10 @@ class Field < T::Struct include ActsAsComparable + Type = T.type_alias { T.any(T::Class[T.anything], T::Types::Base) } + const :name, Symbol - const :type, T::Class[T.anything] + const :type, Type const :required, T::Boolean, default: true sig { returns(T::Boolean) } @@ -24,5 +26,12 @@ def optional? def validate(value) Validations::FieldTypeValidator.new.validate(field: self, value: value) end + + sig { params(value: Value).returns(T::Boolean) } + def works_with?(value) + value.class == type || T.cast(type, T::Types::Base).valid?(value) # standard:disable Style/ClassEqualityComparison + rescue TypeError + false + end end end diff --git a/lib/typed/serializer.rb b/lib/typed/serializer.rb index 67fbb23..3d4471b 100644 --- a/lib/typed/serializer.rb +++ b/lib/typed/serializer.rb @@ -35,9 +35,9 @@ def deserialize_from_creation_params(creation_params) results = schema.fields.map do |field| value = creation_params[field.name] - if value.nil? + if value.nil? || field.works_with?(value) field.validate(value) - elsif value.class != field.type + else coercion_result = Coercion.coerce(field: field, value: value) if coercion_result.success? @@ -45,8 +45,6 @@ def deserialize_from_creation_params(creation_params) else Failure.new(Validations::ValidationError.new(coercion_result.error.message)) end - else - field.validate(value) end end diff --git a/lib/typed/validations/field_type_validator.rb b/lib/typed/validations/field_type_validator.rb index ffda964..3148761 100644 --- a/lib/typed/validations/field_type_validator.rb +++ b/lib/typed/validations/field_type_validator.rb @@ -9,7 +9,7 @@ class FieldTypeValidator sig { override.params(field: Field, value: Value).returns(ValidationResult) } def validate(field:, value:) - if field.type == value.class + if field.works_with?(value) Success.new(ValidatedValue.new(name: field.name, value: value)) elsif field.required? && value.nil? Failure.new(RequiredFieldError.new(field_name: field.name)) diff --git a/lib/typed/validations/type_mismatch_error.rb b/lib/typed/validations/type_mismatch_error.rb index 99ca072..8970b77 100644 --- a/lib/typed/validations/type_mismatch_error.rb +++ b/lib/typed/validations/type_mismatch_error.rb @@ -5,7 +5,7 @@ module Validations class TypeMismatchError < ValidationError extend T::Sig - sig { params(field_name: Symbol, field_type: T::Class[T.anything], given_type: T::Class[T.anything]).void } + sig { params(field_name: Symbol, field_type: Field::Type, given_type: T::Class[T.anything]).void } def initialize(field_name:, field_type:, given_type:) super("Invalid type given to #{field_name}. Expected #{field_type}, got #{given_type}.") end diff --git a/test/support/simple_string_coercer.rb b/test/support/simple_string_coercer.rb index 8a49917..496b5fc 100644 --- a/test/support/simple_string_coercer.rb +++ b/test/support/simple_string_coercer.rb @@ -5,7 +5,7 @@ class SimpleStringCoercer < Typed::Coercion::Coercer Target = type_member { {fixed: String} } - sig { override.params(type: T::Class[T.anything]).returns(T::Boolean) } + sig { override.params(type: Typed::Field::Type).returns(T::Boolean) } def used_for_type?(type) type == String end diff --git a/test/support/structs/city.rb b/test/support/structs/city.rb new file mode 100644 index 0000000..0ce275a --- /dev/null +++ b/test/support/structs/city.rb @@ -0,0 +1,11 @@ +# typed: true + +class City < T::Struct + include ActsAsComparable + + const :name, String + const :capital, T::Boolean +end + +NEW_YORK_CITY = City.new(name: "New York", capital: false) +DC_CITY = City.new(name: "DC", capital: true) diff --git a/test/support/structs/country.rb b/test/support/structs/country.rb new file mode 100644 index 0000000..e4a7e3f --- /dev/null +++ b/test/support/structs/country.rb @@ -0,0 +1,10 @@ +# typed: true + +require_relative "city" + +class Country < T::Struct + const :name, String + const :cities, T::Array[City] +end + +US_COUNTRY = Country.new(name: "US", cities: [NEW_YORK_CITY, DC_CITY]) diff --git a/test/typed/coercion/boolean_coercer_test.rb b/test/typed/coercion/boolean_coercer_test.rb new file mode 100644 index 0000000..8d661fd --- /dev/null +++ b/test/typed/coercion/boolean_coercer_test.rb @@ -0,0 +1,41 @@ +# typed: true + +class BooleanCoercerTest < Minitest::Test + def setup + @coercer = Typed::Coercion::BooleanCoercer.new + @field = Typed::Field.new(name: :capital, type: T::Utils.coerce(T::Boolean)) + end + + def test_used_for_type_works + assert(@coercer.used_for_type?(T::Utils.coerce(T::Boolean))) + refute(@coercer.used_for_type?(Integer)) + end + + def test_when_boolean_field_given_returns_failure + result = @coercer.coerce(field: Typed::Field.new(name: :testing, type: Integer), value: "testing") + + assert_failure(result) + assert_error(Typed::Coercion::CoercionError.new("Field type must be a T::Boolean."), result) + end + + def test_when_true_boolean_can_be_coerced_returns_success + result = @coercer.coerce(field: @field, value: "true") + + assert_success(result) + assert_payload(true, result) + end + + def test_when_false_boolean_can_be_coerced_returns_success + result = @coercer.coerce(field: @field, value: "false") + + assert_success(result) + assert_payload(false, result) + end + + def test_when_enum_cannot_be_coerced_returns_failure + result = @coercer.coerce(field: @field, value: "bad") + + assert_failure(result) + assert_error(Typed::Coercion::CoercionError.new, result) + end +end diff --git a/test/typed/field_test.rb b/test/typed/field_test.rb index 11fc1b3..ba7345c 100644 --- a/test/typed/field_test.rb +++ b/test/typed/field_test.rb @@ -17,4 +17,17 @@ def test_required_and_optional_helpers_work_when_optional assert_predicate(@optional_field, :optional?) refute_predicate(@optional_field, :required?) end + + def test_when_standard_type_work_with_works + assert(@required_field.works_with?("Max")) + refute(@required_field.works_with?(1)) + end + + def test_when_base_type_works_with_works + field = Typed::Field.new(name: :bools, type: T::Utils.coerce(T::Boolean)) + + assert(field.works_with?(true)) + assert(field.works_with?(false)) + refute(field.works_with?("Max")) + end end diff --git a/test/typed/hash_serializer_test.rb b/test/typed/hash_serializer_test.rb index 0df1cde..c96702b 100644 --- a/test/typed/hash_serializer_test.rb +++ b/test/typed/hash_serializer_test.rb @@ -30,6 +30,13 @@ def test_it_can_deep_serialize assert_payload({name: "Alex", age: 31, ruby_rank: "pretty", job: {title: "Software Developer", salary: 1_000_000_00}}, result) end + def test_with_boolean_it_can_serialize + result = Typed::HashSerializer.new(schema: Typed::Schema.from_struct(City)).serialize(NEW_YORK_CITY) + + assert_success(result) + assert_payload({name: "New York", capital: false}, result) + end + def test_when_struct_given_is_not_of_target_type_returns_failure result = @serializer.serialize(Job.new(title: "Testing", salary: 90_00)) @@ -53,6 +60,13 @@ def test_it_can_simple_deserialize_from_string_keys assert_payload(MAX_PERSON, result) end + def test_with_boolean_it_can_deserialize + result = Typed::HashSerializer.new(schema: Typed::Schema.from_struct(City)).deserialize({name: "New York", capital: false}) + + assert_success(result) + assert_payload(NEW_YORK_CITY, result) + end + def test_it_can_deserialize_with_nested_object result = @serializer.deserialize({name: "Alex", age: 31, ruby_rank: RubyRank::Brilliant, job: {title: "Software Developer", salary: 1_000_000_00}}) diff --git a/test/typed/json_serializer_test.rb b/test/typed/json_serializer_test.rb index 1546abb..0954bed 100644 --- a/test/typed/json_serializer_test.rb +++ b/test/typed/json_serializer_test.rb @@ -23,6 +23,13 @@ def test_it_can_serialize_with_nested_struct assert_payload('{"name":"Alex","age":31,"ruby_rank":"pretty","job":{"title":"Software Developer","salary":100000000}}', result) end + def test_with_boolean_it_can_serialize + result = Typed::JSONSerializer.new(schema: Typed::Schema.from_struct(City)).serialize(NEW_YORK_CITY) + + assert_success(result) + assert_payload('{"name":"New York","capital":false}', result) + end + # Deserialize Tests def test_it_can_simple_deserialize @@ -32,6 +39,13 @@ def test_it_can_simple_deserialize assert_payload(MAX_PERSON, result) end + def test_with_boolean_it_can_deserialize + result = Typed::JSONSerializer.new(schema: Typed::Schema.from_struct(City)).deserialize('{"name":"New York","capital":false}') + + assert_success(result) + assert_payload(NEW_YORK_CITY, result) + 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}}')