diff --git a/lib/typed/coercion.rb b/lib/typed/coercion.rb index e75e8e2..88e2209 100644 --- a/lib/typed/coercion.rb +++ b/lib/typed/coercion.rb @@ -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 diff --git a/lib/typed/coercion/coercer.rb b/lib/typed/coercion/coercer.rb index e343b4d..0d50345 100644 --- a/lib/typed/coercion/coercer.rb +++ b/lib/typed/coercion/coercer.rb @@ -2,7 +2,7 @@ module Typed module Coercion - module Coercer + class Coercer extend T::Sig extend T::Generic @@ -10,6 +10,10 @@ module Coercer 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 diff --git a/lib/typed/coercion/coercer_registry.rb b/lib/typed/coercion/coercer_registry.rb new file mode 100644 index 0000000..b4a027f --- /dev/null +++ b/lib/typed/coercion/coercer_registry.rb @@ -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 diff --git a/lib/typed/coercion/float_coercer.rb b/lib/typed/coercion/float_coercer.rb index 6385410..526637f 100644 --- a/lib/typed/coercion/float_coercer.rb +++ b/lib/typed/coercion/float_coercer.rb @@ -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.")) diff --git a/lib/typed/coercion/integer_coercer.rb b/lib/typed/coercion/integer_coercer.rb index 9c3abf0..6a24396 100644 --- a/lib/typed/coercion/integer_coercer.rb +++ b/lib/typed/coercion/integer_coercer.rb @@ -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.")) diff --git a/lib/typed/coercion/string_coercer.rb b/lib/typed/coercion/string_coercer.rb index 7f6e22d..29e8b41 100644 --- a/lib/typed/coercion/string_coercer.rb +++ b/lib/typed/coercion/string_coercer.rb @@ -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 diff --git a/lib/typed/coercion/struct_coercer.rb b/lib/typed/coercion/struct_coercer.rb index 6650922..9b14d9f 100644 --- a/lib/typed/coercion/struct_coercer.rb +++ b/lib/typed/coercion/struct_coercer.rb @@ -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 diff --git a/test/support/simple_string_coercer.rb b/test/support/simple_string_coercer.rb new file mode 100644 index 0000000..8a49917 --- /dev/null +++ b/test/support/simple_string_coercer.rb @@ -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 diff --git a/test/test_helper.rb b/test/test_helper.rb index 5be6d95..f099999 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -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 } diff --git a/test/typed/coercion/coercer_registry_test.rb b/test/typed/coercion/coercer_registry_test.rb new file mode 100644 index 0000000..4a96adf --- /dev/null +++ b/test/typed/coercion/coercer_registry_test.rb @@ -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 diff --git a/test/typed/coercion/float_coercer_test.rb b/test/typed/coercion/float_coercer_test.rb index 8283613..f263d57 100644 --- a/test/typed/coercion/float_coercer_test.rb +++ b/test/typed/coercion/float_coercer_test.rb @@ -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 diff --git a/test/typed/coercion/integer_coercer_test.rb b/test/typed/coercion/integer_coercer_test.rb index bddb213..b2add99 100644 --- a/test/typed/coercion/integer_coercer_test.rb +++ b/test/typed/coercion/integer_coercer_test.rb @@ -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 diff --git a/test/typed/coercion/string_coercer_test.rb b/test/typed/coercion/string_coercer_test.rb index 428283f..da6e680 100644 --- a/test/typed/coercion/string_coercer_test.rb +++ b/test/typed/coercion/string_coercer_test.rb @@ -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 diff --git a/test/typed/coercion/struct_coercer_test.rb b/test/typed/coercion/struct_coercer_test.rb index 44195ed..828dd04 100644 --- a/test/typed/coercion/struct_coercer_test.rb +++ b/test/typed/coercion/struct_coercer_test.rb @@ -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) diff --git a/test/typed/coercion_test.rb b/test/typed/coercion_test.rb index ce3d9c3..2c7a363 100644 --- a/test/typed/coercion_test.rb +++ b/test/typed/coercion_test.rb @@ -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)