Skip to content

Commit

Permalink
feat!: implement default handling for fields (#105)
Browse files Browse the repository at this point in the history
  • Loading branch information
maxveldink authored Jul 3, 2024
1 parent 3c905af commit 054d59f
Show file tree
Hide file tree
Showing 13 changed files with 175 additions and 68 deletions.
42 changes: 8 additions & 34 deletions lib/typed/coercion/struct_coercer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,41 +21,15 @@ def coerce(type:, value:)

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

values = {}

type = T.cast(type, T::Types::Simple)

type.raw_type.props.each do |name, prop|
attribute_type = prop[:type_object]
value = HashTransformer.new.deep_symbolize_keys(value)

if value[name].nil?
# if the value is nil but the type is nilable, no need to coerce
next if attribute_type.respond_to?(:valid?) && attribute_type.valid?(value[name])

return Typed::Failure.new(CoercionError.new("#{name} is required but nil given"))
end

# now that we've done the nil check, we can unwrap the nilable type to get the raw type
simple_attribute_type = attribute_type.respond_to?(:unwrap_nilable) ? attribute_type.unwrap_nilable : attribute_type

# if the prop is a struct, we need to recursively coerce it
if simple_attribute_type.respond_to?(:raw_type) && simple_attribute_type.raw_type <= T::Struct
Typed::HashSerializer
.new(schema: simple_attribute_type.raw_type.schema)
.deserialize(value[name])
.and_then { |struct| Typed::Success.new(values[name] = struct) }
.on_error { |error| return Typed::Failure.new(CoercionError.new("Nested hash for #{type} could not be coerced to #{name}, error: #{error}")) }
else
value = HashTransformer.new.deep_symbolize_keys(value)

Coercion
.coerce(type: attribute_type, value: value[name])
.and_then { |coerced_value| Typed::Success.new(values[name] = coerced_value) }
end
deserialization_result = T.cast(type, T::Types::Simple)
.raw_type
.deserialize_from(:hash, value)

if deserialization_result.success?
deserialization_result
else
Failure.new(CoercionError.new(deserialization_result.error.message))
end

Success.new(type.raw_type.new(values))
rescue ArgumentError, RuntimeError
Failure.new(CoercionError.new("Given hash could not be coerced to #{type}."))
end
Expand Down
33 changes: 29 additions & 4 deletions lib/typed/field.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ class Field
sig { returns(T::Types::Base) }
attr_reader :type

sig { returns(T.untyped) }
attr_reader :default

sig { returns(T::Boolean) }
attr_reader :required

Expand All @@ -22,22 +25,44 @@ class Field
params(
name: Symbol,
type: T.any(T::Class[T.anything], T::Types::Base),
required: T::Boolean,
optional: T::Boolean,
default: T.untyped,
inline_serializer: T.nilable(InlineSerializer)
).void
end
def initialize(name:, type:, required: true, inline_serializer: nil)
def initialize(name:, type:, optional: false, default: nil, inline_serializer: nil)
@name = name
@type = T.let(T::Utils.coerce(type), T::Types::Base)
@required = required
# TODO: Guarentee type signature of the serializer will be valid
@inline_serializer = inline_serializer

coerced_type = T::Utils.coerce(type)

if coerced_type.valid?(nil)
@required = T.let(false, T::Boolean)
@type = T.let(T.unsafe(coerced_type).unwrap_nilable, T::Types::Base)
else
@required = true
@type = coerced_type
end

if optional
@required = false
end

if !default.nil? && @type.valid?(default)
@default = T.let(default, T.untyped)
@required = false
elsif !default.nil? && @required
raise ArgumentError, "Given #{default} with class of #{default.class} for default, invalid with type #{@type}"
end
end

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

Expand Down
4 changes: 2 additions & 2 deletions lib/typed/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def self.from_struct(struct)
Typed::Schema.new(
target: struct,
fields: struct.props.map do |name, properties|
Typed::Field.new(name: name, type: properties[:type], required: !properties[:fully_optional])
Typed::Field.new(name:, type: properties[:type_object], default: properties.fetch(:default, nil))
end
)
end
Expand All @@ -34,7 +34,7 @@ def add_serializer(field_name, serializer)
target: target,
fields: fields.map do |field|
if field.name == field_name
Field.new(name: field.name, type: field.type, required: field.required, inline_serializer: serializer)
Field.new(name: field.name, type: field.type, default: field.default, inline_serializer: serializer)
else
field
end
Expand Down
10 changes: 6 additions & 4 deletions lib/typed/serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,14 @@ def serialize(struct)
sig { params(creation_params: Params).returns(DeserializeResult) }
def deserialize_from_creation_params(creation_params)
results = schema.fields.map do |field|
value = creation_params[field.name]
value = creation_params.fetch(field.name, nil)

if value.nil? || field.works_with?(value)
if value.nil? && field.default
Success.new(Validations::ValidatedValue.new(field.default))
elsif value.nil? || field.works_with?(value)
field.validate(value)
else
coercion_result = Coercion.coerce(type: field.type, value: value)
coercion_result = Coercion.coerce(type: field.type, value:)

if coercion_result.success?
field.validate(coercion_result.payload)
Expand All @@ -49,7 +51,7 @@ def deserialize_from_creation_params(creation_params)
end

Validations::ValidationResults
.new(results: results)
.new(results:)
.combine
.and_then do |validated_params|
Success.new(schema.target.new(**validated_params))
Expand Down
8 changes: 6 additions & 2 deletions lib/typed/validations/field_type_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,15 @@ class FieldTypeValidator
sig { override.params(field: Field, value: Value).returns(ValidationResult) }
def validate(field:, value:)
if field.works_with?(value)
Success.new(ValidatedValue.new(name: field.name, value: value))
Success.new(ValidatedValue.new(name: field.name, value:))
elsif field.required? && value.nil?
Failure.new(RequiredFieldError.new(field_name: field.name))
elsif field.optional? && value.nil?
Success.new(ValidatedValue.new(name: field.name, value: value))
if field.default.nil?
Success.new(ValidatedValue.new(name: field.name, value:))
else
Success.new(ValidatedValue.new(name: field.name, value: field.default))
end
else
Failure.new(TypeMismatchError.new(field_name: field.name, field_type: field.type, given_type: value.class))
end
Expand Down
3 changes: 2 additions & 1 deletion test/support/structs/job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ class Job < T::Struct
const :title, String
const :salary, Integer
const :start_date, T.nilable(Date)
const :needs_credential, T::Boolean, default: false
end

JOB_SCHEMA_WITH_INLINE_SERIALIZER = Typed::Schema.new(
target: Job,
fields: [
Typed::Field.new(name: :title, type: String),
Typed::Field.new(name: :salary, type: Integer),
Typed::Field.new(name: :start_date, type: Date, required: false, inline_serializer: ->(start_date) { start_date.strftime("%j %B") })
Typed::Field.new(name: :start_date, type: T::Utils.coerce(T.nilable(Date)), inline_serializer: ->(start_date) { start_date.strftime("%j %B") })
]
)
DEVELOPER_JOB = Job.new(title: "Software Developer", salary: 90_000_00)
Expand Down
14 changes: 14 additions & 0 deletions test/t/struct_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,20 @@ def test_schema_can_be_derived_from_struct
assert_equal(expected_schema, City.schema)
end

def test_schema_can_be_derived_from_struct_with_default
expected_schema = Typed::Schema.new(
fields: [
Typed::Field.new(name: :title, type: String),
Typed::Field.new(name: :salary, type: Integer),
Typed::Field.new(name: :start_date, type: Date, optional: true),
Typed::Field.new(name: :needs_credential, type: T::Utils.coerce(T::Boolean), default: false, optional: true)
],
target: Job
)

assert_equal(expected_schema, Job.schema)
end

def test_serializer_returns_hash_serializer
assert_kind_of(Typed::HashSerializer, City.serializer(:hash))
end
Expand Down
2 changes: 1 addition & 1 deletion test/typed/coercion/struct_coercer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ def test_when_struct_cannot_be_coerced_returns_failure
result = @coercer.coerce(type: @type, value: {"not" => "valid"})

assert_failure(result)
assert_error(Typed::Coercion::CoercionError.new("title is required but nil given"), result)
assert_error(Typed::Coercion::CoercionError.new("Multiple validation errors found: title is required. | salary is required."), result)
end

def test_when_struct_has_nested_struct_and_all_values_passed_for_nested_struct
Expand Down
108 changes: 93 additions & 15 deletions test/typed/field_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,102 @@
require "test_helper"

class FieldTest < Minitest::Test
def setup
@required_field = Typed::Field.new(name: :im_required, type: String)
@optional_field = Typed::Field.new(name: :im_optional, type: String, required: false)
def test_sets_values_correctly_for_required
field = Typed::Field.new(name: :required, type: String)

assert_equal(:required, field.name)
assert_equal(T::Utils.coerce(String), field.type)
assert(field.required)
assert_nil(field.default)
assert_nil(field.inline_serializer)
end

def test_initialize_takes_sorbet_types_and_built_in_types
assert_equal(@required_field, Typed::Field.new(name: :im_required, type: T::Utils.coerce(String)))
def test_sets_values_correctly_for_default
field = Typed::Field.new(name: :with_default, type: T::Utils.coerce(T::Boolean), default: false)

assert_equal(:with_default, field.name)
assert_equal(T::Utils.coerce(T::Boolean), field.type)
refute(field.required)
assert_equal(false, field.default)
assert_nil(field.inline_serializer)
end

def test_sets_values_correctly_for_nilable_type
field = Typed::Field.new(name: :nilable_type, type: T::Utils.coerce(T.nilable(String)))

assert_equal(:nilable_type, field.name)
assert_equal(T::Utils.coerce(String), field.type)
refute(field.required)
assert_nil(field.default)
assert_nil(field.inline_serializer)
end

def test_sets_values_correctly_for_nilable_type_with_default
field = Typed::Field.new(name: :nilable_type, type: T::Utils.coerce(T.nilable(String)), default: "fallback")

assert_equal(:nilable_type, field.name)
assert_equal(T::Utils.coerce(String), field.type)
refute(field.required)
assert_equal("fallback", field.default)
assert_nil(field.inline_serializer)
end

def test_sets_values_correctly_for_optional
field = Typed::Field.new(name: :optional, type: String, optional: true)

assert_equal(:optional, field.name)
assert_equal(T::Utils.coerce(String), field.type)
refute(field.required)
assert_nil(field.default)
assert_nil(field.inline_serializer)
end

def test_sets_values_correctly_for_optional_with_default
field = Typed::Field.new(name: :optional, type: String, default: "fallback")

assert_equal(:optional, field.name)
assert_equal(T::Utils.coerce(String), field.type)
refute(field.required)
assert_equal("fallback", field.default)
assert_nil(field.inline_serializer)
end

def test_sets_values_with_inline_serializer
field = Typed::Field.new(name: :inline, type: String, inline_serializer: ->(_value) { "banana" })

assert_equal(:inline, field.name)
assert_equal(T::Utils.coerce(String), field.type)
assert(field.required)
assert_nil(field.default)
refute_nil(field.inline_serializer)
end

def test_initialize_raises_argument_error_with_invalid_default_value
error = assert_raises(ArgumentError) { Typed::Field.new(name: :fallback, type: String, default: 1) }

assert_equal("Given 1 with class of Integer for default, invalid with type String", error.message)
end

def test_equality
assert_equal(@required_field, Typed::Field.new(name: :im_required, type: String))
refute_equal(@required_field, @optional_field)
inline_serializer = ->(_value) { "banana" }
assert_equal(
Typed::Field.new(name: :inline, type: String, optional: true, default: "testing", inline_serializer:),
Typed::Field.new(name: :inline, type: String, optional: true, default: "testing", inline_serializer:)
)
end

def test_required_and_optional_helpers_work_when_required
assert_predicate(@required_field, :required?)
refute_predicate(@required_field, :optional?)
field = Typed::Field.new(name: :required, type: String)

assert_predicate(field, :required?)
refute_predicate(field, :optional?)
end

def test_required_and_optional_helpers_work_when_optional
assert_predicate(@optional_field, :optional?)
refute_predicate(@optional_field, :required?)
field = Typed::Field.new(name: :optional, type: String, optional: true)

assert_predicate(field, :optional?)
refute_predicate(field, :required?)
end

def test_when_inline_serializer_serialize_uses_it
Expand All @@ -35,13 +109,17 @@ def test_when_inline_serializer_serialize_uses_it
end

def test_when_no_inline_serializer_serialize_returns_given_value
assert_equal("testing", @required_field.serialize("testing"))
assert_nil(@required_field.serialize(nil))
field = Typed::Field.new(name: :testing, type: String)

assert_equal("testing", field.serialize("testing"))
assert_nil(field.serialize(nil))
end

def test_when_standard_type_work_with_works
assert(@required_field.works_with?("Max"))
refute(@required_field.works_with?(1))
field = Typed::Field.new(name: :testing, type: String)

assert(field.works_with?("Max"))
refute(field.works_with?(1))
end

def test_when_simple_base_type_works_with_works
Expand Down
2 changes: 1 addition & 1 deletion test/typed/hash_serializer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def test_it_can_deep_serialize
result = serializer.serialize(ALEX_PERSON)

assert_success(result)
assert_payload({name: "Alex", age: 31, ruby_rank: "pretty", job: {title: "Software Developer", salary: 90_000_00}}, result)
assert_payload({name: "Alex", age: 31, ruby_rank: "pretty", job: {title: "Software Developer", salary: 90_000_00, needs_credential: false}}, result)
end

def test_with_boolean_it_can_serialize
Expand Down
2 changes: 1 addition & 1 deletion test/typed/json_serializer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def test_it_can_serialize_with_nested_struct
result = @serializer.serialize(ALEX_PERSON)

assert_success(result)
assert_payload('{"name":"Alex","age":31,"ruby_rank":"pretty","job":{"title":"Software Developer","salary":9000000}}', result)
assert_payload('{"name":"Alex","age":31,"ruby_rank":"pretty","job":{"title":"Software Developer","salary":9000000,"needs_credential":false}}', result)
end

def test_with_boolean_it_can_serialize
Expand Down
2 changes: 1 addition & 1 deletion test/typed/schema_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ def setup
Typed::Field.new(name: :name, type: String),
Typed::Field.new(name: :age, type: Integer),
Typed::Field.new(name: :ruby_rank, type: RubyRank),
Typed::Field.new(name: :job, type: Job, required: false)
Typed::Field.new(name: :job, type: Job, optional: true)
],
target: Person
)
Expand Down
Loading

0 comments on commit 054d59f

Please sign in to comment.