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!: Add better Sorbet type support and BooleanCoercer #47

Merged
merged 3 commits into from
Mar 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
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
31 changes: 31 additions & 0 deletions lib/typed/coercion/boolean_coercer.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion lib/typed/coercion/coercer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions lib/typed/coercion/coercer_registry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions lib/typed/coercion/enum_coercer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/typed/coercion/float_coercer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/typed/coercion/integer_coercer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/typed/coercion/string_coercer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions lib/typed/coercion/struct_coercer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down
11 changes: 10 additions & 1 deletion lib/typed/field.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand All @@ -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
6 changes: 2 additions & 4 deletions lib/typed/serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,16 @@ 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?
field.validate(coercion_result.payload)
else
Failure.new(Validations::ValidationError.new(coercion_result.error.message))
end
else
field.validate(value)
end
end

Expand Down
2 changes: 1 addition & 1 deletion lib/typed/validations/field_type_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
2 changes: 1 addition & 1 deletion lib/typed/validations/type_mismatch_error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion test/support/simple_string_coercer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions test/support/structs/city.rb
Original file line number Diff line number Diff line change
@@ -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)
10 changes: 10 additions & 0 deletions test/support/structs/country.rb
Original file line number Diff line number Diff line change
@@ -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])
41 changes: 41 additions & 0 deletions test/typed/coercion/boolean_coercer_test.rb
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions test/typed/field_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 14 additions & 0 deletions test/typed/hash_serializer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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))

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

Expand Down
14 changes: 14 additions & 0 deletions test/typed/json_serializer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}}')

Expand Down