Skip to content

Commit

Permalink
feat!: Allow for custom Coercers (#34)
Browse files Browse the repository at this point in the history
  • Loading branch information
Max VelDink authored Mar 8, 2024
1 parent 012841f commit 54c6a53
Show file tree
Hide file tree
Showing 15 changed files with 169 additions and 72 deletions.
25 changes: 10 additions & 15 deletions lib/typed/coercion.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,18 @@ module Typed
module Coercion
extend T::Sig

# TODO: We can definitely improve how we select which coercer to use
# Related issues:
# * https://github.com/maxveldink/sorbet-schema/issues/9
# * https://github.com/maxveldink/sorbet-schema/issues/10
sig { params(coercer: T.class_of(Coercer)).void }
def self.register_coercer(coercer)
CoercerRegistry.instance.register(coercer)
end

sig { type_parameters(:U).params(field: Field, value: Value).returns(Result[Value, CoercionError]) }
def self.coerce(field:, value:)
if field.type < T::Struct
StructCoercer.coerce(field: field, value: value)
elsif field.type == String
StringCoercer.coerce(field: field, value: value)
elsif field.type == Integer
IntegerCoercer.coerce(field: field, value: value)
elsif field.type == Float
FloatCoercer.coerce(field: field, value: value)
else
Failure.new(CoercionNotSupportedError.new)
end
coercer = CoercerRegistry.instance.select_coercer_by(type: field.type)

return Failure.new(CoercionNotSupportedError.new) unless coercer

coercer.new.coerce(field: field, value: value)
end
end
end
6 changes: 5 additions & 1 deletion lib/typed/coercion/coercer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@

module Typed
module Coercion
module Coercer
class Coercer
extend T::Sig
extend T::Generic

abstract!

Target = type_member(:out)

sig { abstract.params(type: T::Class[T.anything]).returns(T::Boolean) }
def used_for_type?(type)
end

sig { abstract.params(field: Field, value: Value).returns(Result[Target, CoercionError]) }
def coerce(field:, value:)
end
Expand Down
37 changes: 37 additions & 0 deletions lib/typed/coercion/coercer_registry.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# typed: strict

require "singleton"

module Typed
module Coercion
class CoercerRegistry
extend T::Sig

include Singleton

Registry = T.type_alias { T::Array[T.class_of(Coercer)] }

DEFAULT_COERCERS = T.let([StringCoercer, IntegerCoercer, FloatCoercer, StructCoercer], Registry)

sig { void }
def initialize
@available = T.let(DEFAULT_COERCERS.clone, Registry)
end

sig { params(coercer: T.class_of(Coercer)).void }
def register(coercer)
@available.prepend(coercer)
end

sig { void }
def reset!
@available = DEFAULT_COERCERS.clone
end

sig { params(type: T::Class[T.anything]).returns(T.nilable(T.class_of(Coercer))) }
def select_coercer_by(type:)
@available.find { |coercer| coercer.new.used_for_type?(type) }
end
end
end
end
12 changes: 7 additions & 5 deletions lib/typed/coercion/float_coercer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@

module Typed
module Coercion
class FloatCoercer
extend T::Sig
class FloatCoercer < Coercer
extend T::Generic

extend Coercer
Target = type_member { {fixed: Float} }

Target = type_template { {fixed: Float} }
sig { override.params(type: T::Class[T.anything]).returns(T::Boolean) }
def used_for_type?(type)
type == Float
end

sig { override.params(field: Field, value: Value).returns(Result[Target, CoercionError]) }
def self.coerce(field:, value:)
def coerce(field:, value:)
Success.new(Float(value))
rescue ArgumentError, TypeError
Failure.new(CoercionError.new("'#{value}' cannot be coerced into Float."))
Expand Down
12 changes: 7 additions & 5 deletions lib/typed/coercion/integer_coercer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@

module Typed
module Coercion
class IntegerCoercer
extend T::Sig
class IntegerCoercer < Coercer
extend T::Generic

extend Coercer
Target = type_member { {fixed: Integer} }

Target = type_template { {fixed: Integer} }
sig { override.params(type: T::Class[T.anything]).returns(T::Boolean) }
def used_for_type?(type)
type == Integer
end

sig { override.params(field: Field, value: Value).returns(Result[Target, CoercionError]) }
def self.coerce(field:, value:)
def coerce(field:, value:)
Success.new(Integer(value))
rescue ArgumentError, TypeError
Failure.new(CoercionError.new("'#{value}' cannot be coerced into Integer."))
Expand Down
12 changes: 7 additions & 5 deletions lib/typed/coercion/string_coercer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@

module Typed
module Coercion
class StringCoercer
extend T::Sig
class StringCoercer < Coercer
extend T::Generic

extend Coercer
Target = type_member { {fixed: String} }

Target = type_template { {fixed: String} }
sig { override.params(type: T::Class[T.anything]).returns(T::Boolean) }
def used_for_type?(type)
type == String
end

sig { override.params(field: Field, value: Value).returns(Result[Target, CoercionError]) }
def self.coerce(field:, value:)
def coerce(field:, value:)
Success.new(String(value))
end
end
Expand Down
12 changes: 7 additions & 5 deletions lib/typed/coercion/struct_coercer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@

module Typed
module Coercion
class StructCoercer
extend T::Sig
class StructCoercer < Coercer
extend T::Generic

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

Target = type_template { {fixed: T::Struct} }
sig { override.params(type: T::Class[T.anything]).returns(T::Boolean) }
def used_for_type?(type)
!!(type < T::Struct)
end

sig { override.params(field: Field, value: Value).returns(Result[Target, CoercionError]) }
def self.coerce(field:, value:)
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
Expand Down
17 changes: 17 additions & 0 deletions test/support/simple_string_coercer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# typed: true

class SimpleStringCoercer < Typed::Coercion::Coercer
extend T::Generic

Target = type_member { {fixed: String} }

sig { override.params(type: T::Class[T.anything]).returns(T::Boolean) }
def used_for_type?(type)
type == String
end

sig { override.params(field: Typed::Field, value: Typed::Value).returns(Typed::Result[Target, Typed::Coercion::CoercionError]) }
def coerce(field:, value:)
Typed::Success.new("always this value")
end
end
2 changes: 1 addition & 1 deletion test/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@
require "sorbet-schema"
require "sorbet-schema/struct_ext"

Dir["test/support/structs/*.rb"].each { |file| require file }
Dir["test/support/**/*.rb"].each { |file| require file }
17 changes: 17 additions & 0 deletions test/typed/coercion/coercer_registry_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# typed: true

class CoercerRegistryTest < Minitest::Test
def teardown
Typed::Coercion::CoercerRegistry.instance.reset!
end

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))
end

def test_when_type_doesnt_match_coercer_returns_nil
assert_nil(Typed::Coercion::CoercerRegistry.instance.select_coercer_by(type: Array))
end
end
14 changes: 10 additions & 4 deletions test/typed/coercion/float_coercer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,22 @@

class FloatCoercerTest < Minitest::Test
def setup
@coercer = Typed::Coercion::FloatCoercer.new
@field = Typed::Field.new(name: :testing, type: Float)
end

def test_used_for_type_works
assert(@coercer.used_for_type?(Float))
refute(@coercer.used_for_type?(Integer))
end

def test_when_coercable_returns_success
assert_payload(1.1, Typed::Coercion::FloatCoercer.coerce(field: @field, value: "1.1"))
assert_payload(1.0, Typed::Coercion::FloatCoercer.coerce(field: @field, value: 1))
assert_payload(1.1, @coercer.coerce(field: @field, value: "1.1"))
assert_payload(1.0, @coercer.coerce(field: @field, value: 1))
end

def test_when_not_coercable_returns_failure
assert_error(Typed::Coercion::CoercionError.new("'a' cannot be coerced into Float."), Typed::Coercion::FloatCoercer.coerce(field: @field, value: "a"))
assert_error(Typed::Coercion::CoercionError.new("'true' cannot be coerced into Float."), Typed::Coercion::FloatCoercer.coerce(field: @field, value: true))
assert_error(Typed::Coercion::CoercionError.new("'a' cannot be coerced into Float."), @coercer.coerce(field: @field, value: "a"))
assert_error(Typed::Coercion::CoercionError.new("'true' cannot be coerced into Float."), @coercer.coerce(field: @field, value: true))
end
end
14 changes: 10 additions & 4 deletions test/typed/coercion/integer_coercer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,22 @@

class IntegerCoercerTest < Minitest::Test
def setup
@coercer = Typed::Coercion::IntegerCoercer.new
@field = Typed::Field.new(name: :testing, type: Integer)
end

def test_used_for_type_works
assert(@coercer.used_for_type?(Integer))
refute(@coercer.used_for_type?(Float))
end

def test_when_coercable_returns_success
assert_payload(1, Typed::Coercion::IntegerCoercer.coerce(field: @field, value: "1"))
assert_payload(1, Typed::Coercion::IntegerCoercer.coerce(field: @field, value: 1.1))
assert_payload(1, @coercer.coerce(field: @field, value: "1"))
assert_payload(1, @coercer.coerce(field: @field, value: 1.1))
end

def test_when_not_coercable_returns_failure
assert_error(Typed::Coercion::CoercionError.new("'a' cannot be coerced into Integer."), Typed::Coercion::IntegerCoercer.coerce(field: @field, value: "a"))
assert_error(Typed::Coercion::CoercionError.new("'true' cannot be coerced into Integer."), Typed::Coercion::IntegerCoercer.coerce(field: @field, value: true))
assert_error(Typed::Coercion::CoercionError.new("'a' cannot be coerced into Integer."), @coercer.coerce(field: @field, value: "a"))
assert_error(Typed::Coercion::CoercionError.new("'true' cannot be coerced into Integer."), @coercer.coerce(field: @field, value: true))
end
end
16 changes: 12 additions & 4 deletions test/typed/coercion/string_coercer_test.rb
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
# typed: true

class StringCoercerTest < Minitest::Test
def test_returns_success
field = Typed::Field.new(name: :testing, type: String)
def setup
@coercer = Typed::Coercion::StringCoercer.new
@field = Typed::Field.new(name: :testing, type: String)
end

assert_payload("1", Typed::Coercion::StringCoercer.coerce(field: field, value: 1))
assert_payload("[1, 2]", Typed::Coercion::StringCoercer.coerce(field: field, value: [1, 2]))
def test_used_for_type_works
assert(@coercer.used_for_type?(String))
refute(@coercer.used_for_type?(Integer))
end

def test_returns_success
assert_payload("1", @coercer.coerce(field: @field, value: 1))
assert_payload("[1, 2]", @coercer.coerce(field: @field, value: [1, 2]))
end
end
16 changes: 13 additions & 3 deletions test/typed/coercion/struct_coercer_test.rb
Original file line number Diff line number Diff line change
@@ -1,22 +1,32 @@
# typed: true

class StructCoercerTest < Minitest::Test
def setup
@coercer = Typed::Coercion::StructCoercer.new
end

def test_used_for_type_works
assert(@coercer.used_for_type?(Job))
refute(@coercer.used_for_type?(T::Struct))
refute(@coercer.used_for_type?(Integer))
end

def test_when_non_struct_field_given_returns_failure
result = Typed::Coercion::StructCoercer.coerce(field: Typed::Field.new(name: :testing, type: Integer), value: "testing")
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 inherit from T::Struct for Struct coercion."), result)
end

def test_when_struct_can_be_coerced_returns_success
result = Typed::Coercion::StructCoercer.coerce(field: Typed::Field.new(name: :job, type: Job), value: {"title" => "Software Developer", "salary" => 90_000_00})
result = @coercer.coerce(field: Typed::Field.new(name: :job, type: Job), value: {"title" => "Software Developer", "salary" => 90_000_00})

assert_success(result)
assert_payload(Job.new(title: "Software Developer", salary: 90_000_00), result)
end

def test_when_struct_cannot_be_coerced_returns_failure
result = Typed::Coercion::StructCoercer.coerce(field: Typed::Field.new(name: :job, type: Job), value: "bad")
result = @coercer.coerce(field: Typed::Field.new(name: :job, type: Job), value: "bad")

assert_failure(result)
assert_error(Typed::Coercion::CoercionError.new("Value must be a Hash for Struct coercion."), result)
Expand Down
29 changes: 9 additions & 20 deletions test/typed/coercion_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,24 @@
require "date"

class CoercionTest < Minitest::Test
def test_coercion_coerces_structs
result = Typed::Coercion.coerce(field: Typed::Field.new(name: :job, type: Job), value: {"title" => "Software Developer", "salary" => 90_000_00})

assert_success(result)
assert_payload(Job.new(title: "Software Developer", salary: 90_000_00), result)
def teardown
Typed::Coercion::CoercerRegistry.instance.reset!
end

def test_coercion_coerces_strings
result = Typed::Coercion.coerce(field: Typed::Field.new(name: :name, type: String), value: 1)
def test_new_coercers_can_be_registered
Typed::Coercion.register_coercer(SimpleStringCoercer)

assert_success(result)
assert_payload("1", result)
assert_equal(SimpleStringCoercer, Typed::Coercion::CoercerRegistry.instance.select_coercer_by(type: String))
end

def test_coercion_coerces_integers
result = Typed::Coercion.coerce(field: Typed::Field.new(name: :name, type: Integer), value: "1")

assert_success(result)
assert_payload(1, result)
end

def test_coercion_coerces_floats
result = Typed::Coercion.coerce(field: Typed::Field.new(name: :name, type: Float), value: "1.1")
def test_when_coercer_is_matched_coerce_coerces
result = Typed::Coercion.coerce(field: Typed::Field.new(name: :name, type: String), value: 1)

assert_success(result)
assert_payload(1.1, result)
assert_payload("1", result)
end

def test_when_coercer_isnt_matched_returns_failure
def test_when_coercer_isnt_matched_coerce_returns_failure
result = Typed::Coercion.coerce(field: Typed::Field.new(name: :testing, type: Date), value: "testing")

assert_failure(result)
Expand Down

0 comments on commit 54c6a53

Please sign in to comment.