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: Array Support #48

Merged
merged 5 commits into from
Mar 14, 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
8 changes: 4 additions & 4 deletions lib/typed/coercion.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ 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:)
coercer = CoercerRegistry.instance.select_coercer_by(type: field.type)
sig { type_parameters(:U).params(type: Field::Type, value: Value).returns(Result[Value, CoercionError]) }
def self.coerce(type:, value:)
coercer = CoercerRegistry.instance.select_coercer_by(type: type)

return Failure.new(CoercionNotSupportedError.new) unless coercer

coercer.new.coerce(field: field, value: value)
coercer.new.coerce(type: type, value: value)
end
end
end
6 changes: 3 additions & 3 deletions lib/typed/coercion/boolean_coercer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ 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)
sig { override.params(type: Field::Type, value: Value).returns(Result[Target, CoercionError]) }
def coerce(type:, value:)
if T.cast(type, T::Types::Base).recursively_valid?(value)
Success.new(value)
elsif value == "true"
Success.new(true)
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 @@ -14,8 +14,8 @@ class Coercer
def used_for_type?(type)
end

sig { abstract.params(field: Field, value: Value).returns(Result[Target, CoercionError]) }
def coerce(field:, value:)
sig { abstract.params(type: Field::Type, value: Value).returns(Result[Target, CoercionError]) }
def coerce(type:, value:)
end
end
end
Expand Down
13 changes: 12 additions & 1 deletion lib/typed/coercion/coercer_registry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,18 @@ class CoercerRegistry

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

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

sig { void }
def initialize
Expand Down
6 changes: 2 additions & 4 deletions lib/typed/coercion/enum_coercer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,8 @@ def used_for_type?(type)
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

sig { override.params(type: Field::Type, 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)

Success.new(type.from_serialized(value))
Expand Down
6 changes: 3 additions & 3 deletions lib/typed/coercion/float_coercer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ class FloatCoercer < Coercer

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

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

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

sig { override.params(field: Field, value: Value).returns(Result[Target, CoercionError]) }
def coerce(field:, value:)
sig { override.params(type: Field::Type, value: Value).returns(Result[Target, CoercionError]) }
def coerce(type:, value:)
Success.new(String(value))
end
end
Expand Down
10 changes: 5 additions & 5 deletions lib/typed/coercion/struct_coercer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ def used_for_type?(type)
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

sig { override.params(type: Field::Type, 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 Failure.new(CoercionError.new("Value must be a Hash for Struct coercion.")) unless value.is_a?(Hash)
return Success.new(value) if value.instance_of?(type)

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
Expand Down
34 changes: 34 additions & 0 deletions lib/typed/coercion/typed_array_coercer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# typed: strict

module Typed
module Coercion
class TypedArrayCoercer < Coercer
extend T::Generic

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

sig { override.params(type: Field::Type).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]) }
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("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)
end

if coerced_results.all?(&:success?)
Success.new(coerced_results.map(&:payload))
else
Failure.new(CoercionError.new(coerced_results.select(&:failure?).map(&:error).map(&:message).join(" | ")))
end
end
end
end
end
2 changes: 1 addition & 1 deletion lib/typed/field.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def validate(value)

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
value.class == type || T.cast(type, T::Types::Base).recursively_valid?(value) # standard:disable Style/ClassEqualityComparison
rescue TypeError
false
end
Expand Down
2 changes: 1 addition & 1 deletion lib/typed/serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def deserialize_from_creation_params(creation_params)
if value.nil? || field.works_with?(value)
field.validate(value)
else
coercion_result = Coercion.coerce(field: field, value: value)
coercion_result = Coercion.coerce(type: field.type, value: value)

if coercion_result.success?
field.validate(coercion_result.payload)
Expand Down
4 changes: 2 additions & 2 deletions test/support/simple_string_coercer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ 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:)
sig { override.params(type: Typed::Field::Type, value: Typed::Value).returns(Typed::Result[Target, Typed::Coercion::CoercionError]) }
def coerce(type:, value:)
Typed::Success.new("always this value")
end
end
2 changes: 2 additions & 0 deletions test/support/structs/country.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
require_relative "city"

class Country < T::Struct
include ActsAsComparable

const :name, String
const :cities, T::Array[City]
end
Expand Down
14 changes: 7 additions & 7 deletions test/typed/coercion/boolean_coercer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,37 @@
class BooleanCoercerTest < Minitest::Test
def setup
@coercer = Typed::Coercion::BooleanCoercer.new
@field = Typed::Field.new(name: :capital, type: T::Utils.coerce(T::Boolean))
@type = T::Utils.coerce(T::Boolean)
end

def test_used_for_type_works
assert(@coercer.used_for_type?(T::Utils.coerce(T::Boolean)))
assert(@coercer.used_for_type?(@type))
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")
def test_when_non_boolean_field_given_returns_failure
result = @coercer.coerce(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")
result = @coercer.coerce(type: @type, 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")
result = @coercer.coerce(type: @type, 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")
result = @coercer.coerce(type: @type, value: "bad")

assert_failure(result)
assert_error(Typed::Coercion::CoercionError.new, result)
Expand Down
6 changes: 3 additions & 3 deletions test/typed/coercion/enum_coercer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,21 @@ def test_used_for_type_works
end

def test_when_non_enum_field_given_returns_failure
result = @coercer.coerce(field: Typed::Field.new(name: :testing, type: Integer), value: "testing")
result = @coercer.coerce(type: 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(field: Typed::Field.new(name: :ruby_rank, type: RubyRank), value: "shiny")
result = @coercer.coerce(type: RubyRank, value: "shiny")

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

def test_when_enum_cannot_be_coerced_returns_failure
result = @coercer.coerce(field: Typed::Field.new(name: :ruby_rank, type: RubyRank), value: "bad")
result = @coercer.coerce(type: RubyRank, value: "bad")

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

def test_used_for_type_works
Expand All @@ -12,12 +12,12 @@ def test_used_for_type_works
end

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

def test_when_not_coercable_returns_failure
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))
assert_error(Typed::Coercion::CoercionError.new("'a' cannot be coerced into Float."), @coercer.coerce(type: @type, value: "a"))
assert_error(Typed::Coercion::CoercionError.new("'true' cannot be coerced into Float."), @coercer.coerce(type: @type, value: true))
end
end
10 changes: 5 additions & 5 deletions test/typed/coercion/integer_coercer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
class IntegerCoercerTest < Minitest::Test
def setup
@coercer = Typed::Coercion::IntegerCoercer.new
@field = Typed::Field.new(name: :testing, type: Integer)
@type = Integer
end

def test_used_for_type_works
Expand All @@ -12,12 +12,12 @@ def test_used_for_type_works
end

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

def test_when_not_coercable_returns_failure
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))
assert_error(Typed::Coercion::CoercionError.new("'a' cannot be coerced into Integer."), @coercer.coerce(type: @type, value: "a"))
assert_error(Typed::Coercion::CoercionError.new("'true' cannot be coerced into Integer."), @coercer.coerce(type: @type, value: true))
end
end
5 changes: 2 additions & 3 deletions test/typed/coercion/string_coercer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
class StringCoercerTest < Minitest::Test
def setup
@coercer = Typed::Coercion::StringCoercer.new
@field = Typed::Field.new(name: :testing, type: String)
end

def test_used_for_type_works
Expand All @@ -12,7 +11,7 @@ def test_used_for_type_works
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]))
assert_payload("1", @coercer.coerce(type: String, value: 1))
assert_payload("[1, 2]", @coercer.coerce(type: String, value: [1, 2]))
end
end
24 changes: 20 additions & 4 deletions test/typed/coercion/struct_coercer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,39 @@ def test_used_for_type_works
end

def test_when_non_struct_field_given_returns_failure
result = @coercer.coerce(field: Typed::Field.new(name: :testing, type: Integer), value: "testing")
result = @coercer.coerce(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_of_correct_type_given_returns_success
job = Job.new(title: "Software Developer", salary: 90_000_00)

result = @coercer.coerce(type: Job, value: job)

assert_success(result)
assert_payload(job, result)
end

def test_when_struct_of_incorrect_type_given_returns_failure
result = @coercer.coerce(type: Job, value: Country.new(name: "Canada", cities: []))

assert_failure(result)
assert_error(Typed::Coercion::CoercionError.new("Value of type 'Country' cannot be coerced to Job Struct."), result)
end

def test_when_struct_can_be_coerced_returns_success
result = @coercer.coerce(field: Typed::Field.new(name: :job, type: Job), value: {"title" => "Software Developer", "salary" => 90_000_00})
result = @coercer.coerce(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 = @coercer.coerce(field: Typed::Field.new(name: :job, type: Job), value: "bad")
result = @coercer.coerce(type: Job, value: "bad")

assert_failure(result)
assert_error(Typed::Coercion::CoercionError.new("Value must be a Hash for Struct coercion."), result)
assert_error(Typed::Coercion::CoercionError.new("Value of type 'String' cannot be coerced to Job Struct."), result)
end
end
Loading