Skip to content

Commit

Permalink
refactor: Use Sorbet types under the hood and add more tests to coercers
Browse files Browse the repository at this point in the history
  • Loading branch information
maxveldink committed Mar 20, 2024
1 parent 2a25410 commit 9e5ee53
Show file tree
Hide file tree
Showing 25 changed files with 173 additions and 88 deletions.
2 changes: 1 addition & 1 deletion lib/typed/coercion.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ def self.register_coercer(coercer)
CoercerRegistry.instance.register(coercer)
end

sig { type_parameters(:U).params(type: Field::Type, value: Value).returns(Result[Value, CoercionError]) }
sig { type_parameters(:U).params(type: T::Types::Base, value: Value).returns(Result[Value, CoercionError]) }
def self.coerce(type:, value:)
coercer = CoercerRegistry.instance.select_coercer_by(type: type)

Expand Down
10 changes: 5 additions & 5 deletions lib/typed/coercion/boolean_coercer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@ class BooleanCoercer < Coercer

Target = type_member { {fixed: T::Boolean} }

sig { override.params(type: Field::Type).returns(T::Boolean) }
sig { override.params(type: T::Types::Base).returns(T::Boolean) }
def used_for_type?(type)
type == T::Utils.coerce(T::Boolean)
end

sig { override.params(type: Field::Type, value: Value).returns(Result[Target, CoercionError]) }
sig { override.params(type: T::Types::Base, value: Value).returns(Result[Target, CoercionError]) }
def coerce(type:, value:)
if T.cast(type, T::Types::Base).recursively_valid?(value)
return Failure.new(CoercionError.new("Type must be a T::Boolean.")) unless used_for_type?(type)

if type.recursively_valid?(value)
Success.new(value)
elsif value == "true"
Success.new(true)
Expand All @@ -23,8 +25,6 @@ def coerce(type:, value:)
else
Failure.new(CoercionError.new)
end
rescue TypeError
Failure.new(CoercionError.new("Field type must be a T::Boolean."))
end
end
end
Expand Down
4 changes: 2 additions & 2 deletions lib/typed/coercion/coercer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ class Coercer

Target = type_member(:out)

sig { abstract.params(type: Field::Type).returns(T::Boolean) }
sig { abstract.params(type: T::Types::Base).returns(T::Boolean) }
def used_for_type?(type)
end

sig { abstract.params(type: Field::Type, value: Value).returns(Result[Target, CoercionError]) }
sig { abstract.params(type: T::Types::Base, value: Value).returns(Result[Target, CoercionError]) }
def coerce(type:, value:)
end
end
Expand Down
2 changes: 1 addition & 1 deletion lib/typed/coercion/coercer_registry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def reset!
@available = DEFAULT_COERCERS.clone
end

sig { params(type: Field::Type).returns(T.nilable(T.class_of(Coercer))) }
sig { params(type: T::Types::Base).returns(T.nilable(T.class_of(Coercer))) }
def select_coercer_by(type:)
@available.find { |coercer| coercer.new.used_for_type?(type) }
end
Expand Down
12 changes: 7 additions & 5 deletions lib/typed/coercion/enum_coercer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,18 @@ class EnumCoercer < Coercer

Target = type_member { {fixed: T::Enum} }

sig { override.params(type: Field::Type).returns(T::Boolean) }
sig { override.params(type: T::Types::Base).returns(T::Boolean) }
def used_for_type?(type)
type.is_a?(Class) && !!(type < T::Enum)
return false unless type.respond_to?(:raw_type)

!!(T.cast(type, T::Types::Simple).raw_type < T::Enum)
end

sig { override.params(type: Field::Type, value: Value).returns(Result[Target, CoercionError]) }
sig { override.params(type: T::Types::Base, value: Value).returns(Result[Target, CoercionError]) }
def coerce(type:, value:)
return Failure.new(CoercionError.new("Field type must inherit from T::Enum for Enum coercion.")) unless type.is_a?(Class) && !!(type < T::Enum)
return Failure.new(CoercionError.new("Field type must inherit from T::Enum for Enum coercion.")) unless used_for_type?(type)

Success.new(type.from_serialized(value))
Success.new(T.cast(type, T::Types::Simple).raw_type.from_serialized(value))
rescue KeyError => e
Failure.new(CoercionError.new(e.message))
end
Expand Down
6 changes: 4 additions & 2 deletions lib/typed/coercion/float_coercer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ class FloatCoercer < Coercer

Target = type_member { {fixed: Float} }

sig { override.params(type: Field::Type).returns(T::Boolean) }
sig { override.params(type: T::Types::Base).returns(T::Boolean) }
def used_for_type?(type)
T::Utils.coerce(type) == T::Utils.coerce(Float)
end

sig { override.params(type: Field::Type, value: Value).returns(Result[Target, CoercionError]) }
sig { override.params(type: T::Types::Base, value: Value).returns(Result[Target, CoercionError]) }
def coerce(type:, value:)
return Failure.new(CoercionError.new("Type must be a Float.")) unless used_for_type?(type)

Success.new(Float(value))
rescue ArgumentError, TypeError
Failure.new(CoercionError.new("'#{value}' cannot be coerced into Float."))
Expand Down
8 changes: 5 additions & 3 deletions lib/typed/coercion/integer_coercer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ class IntegerCoercer < Coercer

Target = type_member { {fixed: Integer} }

sig { override.params(type: Field::Type).returns(T::Boolean) }
sig { override.params(type: T::Types::Base).returns(T::Boolean) }
def used_for_type?(type)
type == Integer
type == T::Utils.coerce(Integer)
end

sig { override.params(type: Field::Type, value: Value).returns(Result[Target, CoercionError]) }
sig { override.params(type: T::Types::Base, value: Value).returns(Result[Target, CoercionError]) }
def coerce(type:, value:)
return Failure.new(CoercionError.new("Type must be a Integer.")) unless used_for_type?(type)

Success.new(Integer(value))
rescue ArgumentError, TypeError
Failure.new(CoercionError.new("'#{value}' cannot be coerced into Integer."))
Expand Down
8 changes: 5 additions & 3 deletions lib/typed/coercion/string_coercer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ class StringCoercer < Coercer

Target = type_member { {fixed: String} }

sig { override.params(type: Field::Type).returns(T::Boolean) }
sig { override.params(type: T::Types::Base).returns(T::Boolean) }
def used_for_type?(type)
type == String
type == T::Utils.coerce(String)
end

sig { override.params(type: Field::Type, value: Value).returns(Result[Target, CoercionError]) }
sig { override.params(type: T::Types::Base, value: Value).returns(Result[Target, CoercionError]) }
def coerce(type:, value:)
return Failure.new(CoercionError.new("Type must be a String.")) unless used_for_type?(type)

Success.new(String(value))
end
end
Expand Down
18 changes: 10 additions & 8 deletions lib/typed/coercion/struct_coercer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,23 @@ class StructCoercer < Coercer

Target = type_member { {fixed: T::Struct} }

sig { override.params(type: Field::Type).returns(T::Boolean) }
sig { override.params(type: T::Types::Base).returns(T::Boolean) }
def used_for_type?(type)
type.is_a?(Class) && !!(type < T::Struct)
return false unless type.respond_to?(:raw_type)

!!(T.cast(type, T::Types::Simple).raw_type < T::Struct)
end

sig { override.params(type: Field::Type, value: Value).returns(Result[Target, CoercionError]) }
sig { override.params(type: T::Types::Base, value: Value).returns(Result[Target, CoercionError]) }
def coerce(type:, value:)
return Failure.new(CoercionError.new("Field type must inherit from T::Struct for Struct coercion.")) unless type.is_a?(Class) && type < T::Struct
return Success.new(value) if value.instance_of?(type)
return Failure.new(CoercionError.new("Field type must inherit from T::Struct for Struct coercion.")) unless used_for_type?(type)
return Success.new(value) if type.recursively_valid?(value)

return Failure.new(CoercionError.new("Value of type '#{value.class}' cannot be coerced to #{type} Struct.")) unless value.is_a?(Hash)

Success.new(type.from_hash!(HashTransformer.new.deep_stringify_keys(value)))
rescue ArgumentError => e
Failure.new(CoercionError.new(e.message))
Success.new(T.cast(type, T::Types::Simple).raw_type.from_hash!(HashTransformer.new.deep_stringify_keys(value)))
rescue ArgumentError, RuntimeError
Failure.new(CoercionError.new("Given hash could not be coerced to #{type}."))
end
end
end
Expand Down
8 changes: 4 additions & 4 deletions lib/typed/coercion/typed_array_coercer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,20 @@ class TypedArrayCoercer < Coercer

Target = type_member { {fixed: T::Array[T.untyped]} }

sig { override.params(type: Field::Type).returns(T::Boolean) }
sig { override.params(type: T::Types::Base).returns(T::Boolean) }
def used_for_type?(type)
type.is_a?(T::Types::TypedArray)
end

sig { override.params(type: Field::Type, value: Value).returns(Result[Target, CoercionError]) }
sig { override.params(type: T::Types::Base, value: Value).returns(Result[Target, CoercionError]) }
def coerce(type:, value:)
return Failure.new(CoercionError.new("Field type must be a T::Array.")) unless type.is_a?(T::Types::TypedArray)
return Failure.new(CoercionError.new("Field type must be a T::Array.")) unless used_for_type?(type)
return Failure.new(CoercionError.new("Value must be an Array.")) unless value.is_a?(Array)

return Success.new(value) if type.recursively_valid?(value)

coerced_results = value.map do |item|
Coercion.coerce(type: type.type.raw_type, value: item)
Coercion.coerce(type: T.cast(type, T::Types::TypedArray).type, value: item)
end

if coerced_results.all?(&:success?)
Expand Down
31 changes: 22 additions & 9 deletions lib/typed/field.rb
Original file line number Diff line number Diff line change
@@ -1,16 +1,31 @@
# typed: strict

module Typed
class Field < T::Struct
class Field
extend T::Sig

include ActsAsComparable
sig { returns(Symbol) }
attr_reader :name

Type = T.type_alias { T.any(T::Class[T.anything], T::Types::Base) }
sig { returns(T::Types::Base) }
attr_reader :type

const :name, Symbol
const :type, Type
const :required, T::Boolean, default: true
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)
@name = name
@type = T.let(T::Utils.coerce(type), T::Types::Base)
@required = required
end

sig { params(other: Field).returns(T.nilable(T::Boolean)) }
def ==(other)
name == other.name &&
type == other.type &&
required == other.required
end

sig { returns(T::Boolean) }
def required?
Expand All @@ -29,9 +44,7 @@ def validate(value)

sig { params(value: Value).returns(T::Boolean) }
def works_with?(value)
value.class == type || T.cast(type, T::Types::Base).recursively_valid?(value) # standard:disable Style/ClassEqualityComparison
rescue TypeError
false
type.recursively_valid?(value)
end
end
end
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: Field::Type, given_type: T::Class[T.anything]).void }
sig { params(field_name: Symbol, field_type: T::Types::Base, 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
6 changes: 3 additions & 3 deletions test/support/simple_string_coercer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ class SimpleStringCoercer < Typed::Coercion::Coercer

Target = type_member { {fixed: String} }

sig { override.params(type: Typed::Field::Type).returns(T::Boolean) }
sig { override.params(type: T::Types::Base).returns(T::Boolean) }
def used_for_type?(type)
type == String
type == T::Utils.coerce(String)
end

sig { override.params(type: Typed::Field::Type, value: Typed::Value).returns(Typed::Result[Target, Typed::Coercion::CoercionError]) }
sig { override.params(type: T::Types::Base, value: Typed::Value).returns(Typed::Result[Target, Typed::Coercion::CoercionError]) }
def coerce(type:, value:)
Typed::Success.new("always this value")
end
Expand Down
22 changes: 18 additions & 4 deletions test/typed/coercion/boolean_coercer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,21 @@ def setup

def test_used_for_type_works
assert(@coercer.used_for_type?(@type))
refute(@coercer.used_for_type?(Integer))
refute(@coercer.used_for_type?(T::Utils.coerce(Integer)))
end

def test_when_non_boolean_field_given_returns_failure
result = @coercer.coerce(type: Integer, value: "testing")
def test_when_non_boolean_type_given_returns_failure
result = @coercer.coerce(type: T::Utils.coerce(Integer), value: "testing")

assert_failure(result)
assert_error(Typed::Coercion::CoercionError.new("Field type must be a T::Boolean."), result)
assert_error(Typed::Coercion::CoercionError.new("Type must be a T::Boolean."), result)
end

def test_when_true_class_returns_success
result = @coercer.coerce(type: @type, value: true)

assert_success(result)
assert_payload(true, result)
end

def test_when_true_boolean_can_be_coerced_returns_success
Expand All @@ -25,6 +32,13 @@ def test_when_true_boolean_can_be_coerced_returns_success
assert_payload(true, result)
end

def test_when_false_class_returns_success
result = @coercer.coerce(type: @type, value: false)

assert_success(result)
assert_payload(false, result)
end

def test_when_false_boolean_can_be_coerced_returns_success
result = @coercer.coerce(type: @type, value: "false")

Expand Down
6 changes: 4 additions & 2 deletions test/typed/coercion/coercer_registry_test.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# typed: true

require "date"

class CoercerRegistryTest < Minitest::Test
def teardown
Typed::Coercion::CoercerRegistry.instance.reset!
Expand All @@ -8,10 +10,10 @@ def teardown
def test_register_prepends_coercer_so_it_overrides_built_in_ones
Typed::Coercion::CoercerRegistry.instance.register(SimpleStringCoercer)

assert_equal(SimpleStringCoercer, Typed::Coercion::CoercerRegistry.instance.select_coercer_by(type: String))
assert_equal(SimpleStringCoercer, Typed::Coercion::CoercerRegistry.instance.select_coercer_by(type: T::Utils.coerce(String)))
end

def test_when_type_doesnt_match_coercer_returns_nil
assert_nil(Typed::Coercion::CoercerRegistry.instance.select_coercer_by(type: Array))
assert_nil(Typed::Coercion::CoercerRegistry.instance.select_coercer_by(type: T::Utils.coerce(Date)))
end
end
12 changes: 6 additions & 6 deletions test/typed/coercion/enum_coercer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,27 @@ def setup
end

def test_used_for_type_works
assert(@coercer.used_for_type?(RubyRank))
refute(@coercer.used_for_type?(T::Enum))
refute(@coercer.used_for_type?(Integer))
assert(@coercer.used_for_type?(T::Utils.coerce(RubyRank)))
refute(@coercer.used_for_type?(T::Utils.coerce(T::Enum)))
refute(@coercer.used_for_type?(T::Utils.coerce(Integer)))
end

def test_when_non_enum_field_given_returns_failure
result = @coercer.coerce(type: Integer, value: "testing")
result = @coercer.coerce(type: T::Utils.coerce(Integer), value: "testing")

assert_failure(result)
assert_error(Typed::Coercion::CoercionError.new("Field type must inherit from T::Enum for Enum coercion."), result)
end

def test_when_enum_can_be_coerced_returns_success
result = @coercer.coerce(type: RubyRank, value: "shiny")
result = @coercer.coerce(type: T::Utils.coerce(RubyRank), value: "shiny")

assert_success(result)
assert_payload(RubyRank::Luminary, result)
end

def test_when_enum_cannot_be_coerced_returns_failure
result = @coercer.coerce(type: RubyRank, value: "bad")
result = @coercer.coerce(type: T::Utils.coerce(RubyRank), value: "bad")

assert_failure(result)
assert_error(Typed::Coercion::CoercionError.new("Enum RubyRank key not found: \"bad\""), result)
Expand Down
13 changes: 10 additions & 3 deletions test/typed/coercion/float_coercer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,19 @@
class FloatCoercerTest < Minitest::Test
def setup
@coercer = Typed::Coercion::FloatCoercer.new
@type = Float
@type = T::Utils.coerce(Float)
end

def test_used_for_type_works
assert(@coercer.used_for_type?(Float))
refute(@coercer.used_for_type?(Integer))
assert(@coercer.used_for_type?(@type))
refute(@coercer.used_for_type?(T::Utils.coerce(Integer)))
end

def test_when_non_float_type_given_returns_failure
result = @coercer.coerce(type: T::Utils.coerce(Integer), value: 1.0)

assert_failure(result)
assert_error(Typed::Coercion::CoercionError.new("Type must be a Float."), result)
end

def test_when_coercable_returns_success
Expand Down
Loading

0 comments on commit 9e5ee53

Please sign in to comment.