Skip to content

Commit

Permalink
Added ContextNode, refactored with_context, cast_errors and structure…
Browse files Browse the repository at this point in the history
… cleaning (#3)

* Fix merge message keys
* Extracted ContextNode
* Refactored with_context to new ContextNode
* Refactored cast_errors to ContextNode

---------

Co-authored-by: Pavel Egorov <[email protected]>
  • Loading branch information
EugZol and emfy0 committed Sep 14, 2023
1 parent 719dabd commit 4863176
Show file tree
Hide file tree
Showing 35 changed files with 635 additions and 431 deletions.
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1221,7 +1221,10 @@ schema =

schema.(user_id: 'wrong') # => #<Datacaster::ErrorResult({:user_id=>["must be integer"]})>
```
any instance of `Datacaster` can be passed to `.cast_errors`

`.cast_errors` will extract errors from the `ErrorResult` and provide them as hash value for the provided caster. If that caster returns `ErrorResult`, runtime exception is raised. If that caster returns `ValidResult`, it is packed back into `ErrorResult` and returned.

Any instance of `Datacaster` can be passed to `.cast_errors`.


## Registering custom 'predefined' types
Expand Down
1 change: 1 addition & 0 deletions datacaster.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,5 @@ Gem::Specification.new do |spec|
spec.add_development_dependency 'rspec', '~> 3.0'

spec.add_runtime_dependency 'dry-monads', '>= 1.3', '< 1.4'
spec.add_runtime_dependency 'zeitwerk', '>= 2', '< 3'
end
42 changes: 10 additions & 32 deletions lib/datacaster.rb
Original file line number Diff line number Diff line change
@@ -1,41 +1,23 @@
require 'zeitwerk'
loader = Zeitwerk::Loader.for_gem
loader.inflector.inflect('definition_dsl' => 'DefinitionDSL')
loader.setup

require_relative 'datacaster/result'
require_relative 'datacaster/version'

require_relative 'datacaster/absent'
require_relative 'datacaster/base'
require_relative 'datacaster/predefined'
require_relative 'datacaster/definition_context'
require_relative 'datacaster/terminator'
require_relative 'datacaster/config'

require_relative 'datacaster/array_schema'
require_relative 'datacaster/caster'
require_relative 'datacaster/checker'
require_relative 'datacaster/comparator'
require_relative 'datacaster/hash_mapper'
require_relative 'datacaster/hash_schema'
require_relative 'datacaster/message_keys_merger'
require_relative 'datacaster/transformer'
require_relative 'datacaster/trier'

require_relative 'datacaster/and_node'
require_relative 'datacaster/and_with_error_aggregation_node'
require_relative 'datacaster/or_node'
require_relative 'datacaster/then_node'

module Datacaster
extend self

def schema(&block)
build_schema(Terminator::Raising.instance, &block)
ContextNodes::StructureCleaner.new(build_schema(&block), :fail)
end

def choosy_schema(&block)
build_schema(Terminator::Sweeping.instance, &block)
ContextNodes::StructureCleaner.new(build_schema(&block), :remove)
end

def partial_schema(&block)
build_schema(nil, &block)
ContextNodes::StructureCleaner.new(build_schema(&block), :pass)
end

def absent
Expand All @@ -44,19 +26,15 @@ def absent

private

def build_schema(terminator, &block)
def build_schema(&block)
raise "Expected block" unless block

definition_context = DefinitionContext.new

datacaster = definition_context.instance_exec(&block)
datacaster = DefinitionDSL.eval(&block)

unless datacaster.is_a?(Base)
raise "Datacaster instance should be returned from a block (e.g. result of 'hash_schema(...)' call)"
end

datacaster = (datacaster & terminator) if terminator
datacaster.set_definition_context(definition_context)
datacaster
end
end
8 changes: 3 additions & 5 deletions lib/datacaster/and_node.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,11 @@ def initialize(left, right)
@right = right
end

def cast(object)
object = super(object)

left_result = @left.(object)
def cast(object, runtime:)
left_result = @left.with_runtime(runtime).(object)
return left_result unless left_result.valid?

@right.(left_result)
@right.with_runtime(runtime).(left_result.value)
end

def inspect
Expand Down
9 changes: 4 additions & 5 deletions lib/datacaster/and_with_error_aggregation_node.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,13 @@ def initialize(left, right)

# Works like AndNode, but doesn't stop at first error — in order to aggregate all Failures
# Makes sense only for Hash Schemas
def cast(object)
object = super(object)
left_result = @left.(object)
def cast(object, runtime:)
left_result = @left.with_runtime(runtime).(object)

if left_result.valid?
@right.(left_result)
@right.with_runtime(runtime).(left_result.value)
else
right_result = @right.(object)
right_result = @right.with_runtime(runtime).(object)
if right_result.valid?
left_result
else
Expand Down
22 changes: 9 additions & 13 deletions lib/datacaster/array_schema.rb
Original file line number Diff line number Diff line change
@@ -1,28 +1,24 @@
module Datacaster
class ArraySchema < Base
def initialize(element_caster)
# support of shortcut nested validation definitions, e.g. array_schema({a: [integer], b: {c: integer}})
@element_caster = shortcut_definition(element_caster)
@element_caster = element_caster
end

def cast(object)
object = super(object)
checked_schema = object.meta[:checked_schema] || []

array = object.value

def cast(array, runtime:)
return Datacaster.ErrorResult(["must be array"]) if !array.respond_to?(:map) || !array.respond_to?(:zip)
return Datacaster.ErrorResult(["must not be empty"]) if array.empty?

runtime.will_check!

result =
array.zip(checked_schema).map do |x, schema|
x = Datacaster.ValidResult(x, meta: {checked_schema: schema})
@element_caster.(x)
array.map.with_index do |x, i|
runtime.checked_key!(i) do
@element_caster.with_runtime(runtime).(x)
end
end

if result.all?(&:valid?)
checked_schema = result.map { |x| x.meta[:checked_schema] }
Datacaster.ValidResult(result.map(&:value), meta: {checked_schema: checked_schema})
Datacaster.ValidResult(result.map!(&:value))
else
Datacaster.ErrorResult(result.each.with_index.reject { |x, _| x.valid? }.map { |x, i| [i, x.errors] }.to_h)
end
Expand Down
69 changes: 21 additions & 48 deletions lib/datacaster/base.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
require "ostruct"

module Datacaster
class Base
def self.merge_errors(left, right)
Expand Down Expand Up @@ -43,66 +41,41 @@ def *(other)
AndWithErrorAggregationNode.new(self, other)
end

def then(other)
ThenNode.new(self, other)
def cast_errors(error_caster)
ContextNodes::ErrorsCaster.new(self, error_caster)
end

def set_definition_context(definition_context)
@definition_context = definition_context
def then(other)
ThenNode.new(self, other)
end

def with_context(additional_context)
@definition_context.context = OpenStruct.new(additional_context)
self
def with_context(context)
unless context.is_a?(Hash)
raise "with_context expected Hash as argument, got #{context.inspect} instead"
end
ContextNodes::UserContext.new(self, context)
end

def call(object)
object = cast(object)

return object if object.valid? || @cast_errors.nil?

error_cast = @cast_errors.(object.errors)

raise "#cast_errors must return Datacaster.ValidResult, currently it is #{error_cast.inspect}" unless error_cast.valid?
call_with_runtime(object, Runtime.new)
end

Datacaster.ErrorResult(
@cast_errors.(object.errors).value,
meta: object.meta
)
def call_with_runtime(object, runtime)
result = cast(object, runtime: runtime)
unless result.is_a?(Result)
raise RuntimeError.new("Caster should've returned Datacaster::Result, but returned #{result.inspect} instead")
end
result
end

def cast_errors(object)
@cast_errors = shortcut_definition(object)
self
def with_runtime(runtime)
->(object) do
call_with_runtime(object, runtime)
end
end

def inspect
"#<Datacaster::Base>"
end

private

def cast(object)
Datacaster.ValidResult(object)
end

# Translates hashes like {a: <IntegerChecker>} to <HashSchema {a: <IntegerChecker>}>
# and arrays like [<IntegerChecker>] to <ArraySchema <IntegerChecker>>
def shortcut_definition(definition)
case definition
when Datacaster::Base
definition
when Array
if definition.length != 1
raise ArgumentError.new("Datacaster: shortcut array definitions must have exactly 1 element in the array, e.g. [integer]")
end
ArraySchema.new(definition.first)
when Hash
HashSchema.new(definition)
else
return definition if definition.respond_to?(:call)
raise ArgumentError.new("Datacaster: Unknown definition #{definition.inspect}, which doesn't respond to #call")
end
end
end
end
7 changes: 2 additions & 5 deletions lib/datacaster/caster.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,8 @@ def initialize(name, &block)
@cast = block
end

def cast(object)
intermediary_result = super(object)
object = intermediary_result.value

result = @cast.(object)
def cast(object, runtime:)
result = Runtime.(runtime, @cast, object)

raise TypeError.new("Either Datacaster::Result or Dry::Monads::Result " \
"should be returned from cast block") unless [Datacaster::Result, Dry::Monads::Result].any? { |k| result.is_a?(k) }
Expand Down
7 changes: 2 additions & 5 deletions lib/datacaster/checker.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,8 @@ def initialize(name, error, &block)
@check = block
end

def cast(object)
intermediary_result = super(object)
object = intermediary_result.value

if @check.(object)
def cast(object, runtime:)
if Runtime.(runtime, @check, object)
Datacaster.ValidResult(object)
else
Datacaster.ErrorResult([@error])
Expand Down
5 changes: 1 addition & 4 deletions lib/datacaster/comparator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,7 @@ def initialize(value, name, error = nil)
@error = error || "must be equal to #{value.inspect}"
end

def cast(object)
intermediary_result = super(object)
object = intermediary_result.value

def cast(object, runtime:)
if @value == object
Datacaster.ValidResult(object)
else
Expand Down
43 changes: 43 additions & 0 deletions lib/datacaster/context_node.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
module Datacaster
class ContextNode < Base
def initialize(base)
@base = base
end

def cast(object, runtime:)
@runtime = create_runtime(runtime)
result = @base.with_runtime(@runtime).call(object)
transform_result(result)
end

def inspect
"#<Datacaster::ContextNode base: #{@base.inspect}>"
end

private

def create_runtime(parent)
parent
end

def runtime
@runtime
end

def transform_result(result)
if result.valid?
Datacaster.ValidResult(transform_success(result.value))
else
Datacaster.ErrorResult(transform_errors(result.errors))
end
end

def transform_success(value)
value
end

def transform_errors(errors)
errors
end
end
end
21 changes: 21 additions & 0 deletions lib/datacaster/context_nodes/errors_caster.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
module Datacaster
module ContextNodes
class ErrorsCaster < Datacaster::ContextNode
def initialize(base, error_caster)
super(base)
@caster = error_caster
end

private

def transform_errors(errors)
result = @caster.with_runtime(@runtime).(errors)
if result.valid?
result.value
else
raise RuntimeError.new("Error caster tried to cast these errors: #{errors.inspect}, but didn't return ValidResult: #{result.inspect}")
end
end
end
end
end
Loading

0 comments on commit 4863176

Please sign in to comment.