datacaster 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "datacaster"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,30 @@
1
+ require_relative 'lib/datacaster/version'
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = 'datacaster'
5
+ spec.version = Datacaster::VERSION
6
+ spec.authors = ['Eugene Zolotarev']
7
+ spec.email = ['eugzol@gmail.com']
8
+
9
+ spec.summary = %q{Run-time type checker and transformer for Ruby}
10
+ spec.license = 'MIT'
11
+ spec.required_ruby_version = Gem::Requirement.new('>= 2.3.0')
12
+
13
+ spec.metadata['source_code_uri'] = 'https://github.com/EugZol/datacaster'
14
+ spec.homepage = 'https://github.com/EugZol/datacaster'
15
+
16
+ # Specify which files should be added to the gem when it is released.
17
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
18
+ spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
19
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
20
+ end
21
+ spec.bindir = 'exe'
22
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
23
+ spec.require_paths = ['lib']
24
+
25
+ spec.add_development_dependency 'activemodel', '>= 5.2'
26
+ spec.add_development_dependency 'rake', '~> 12.0'
27
+ spec.add_development_dependency 'rspec', '~> 3.0'
28
+
29
+ spec.add_runtime_dependency 'dry-monads', '>= 1.3', '< 1.4'
30
+ end
@@ -0,0 +1,50 @@
1
+ require_relative 'datacaster/result'
2
+ require_relative 'datacaster/version'
3
+
4
+ require_relative 'datacaster/absent'
5
+ require_relative 'datacaster/base'
6
+ require_relative 'datacaster/predefined'
7
+ require_relative 'datacaster/runner_context'
8
+ require_relative 'datacaster/terminator'
9
+
10
+ require_relative 'datacaster/caster'
11
+ require_relative 'datacaster/checker'
12
+ require_relative 'datacaster/comparator'
13
+ require_relative 'datacaster/array_schema'
14
+ require_relative 'datacaster/hash_schema'
15
+ require_relative 'datacaster/hash_mapper'
16
+ require_relative 'datacaster/transformer'
17
+ require_relative 'datacaster/trier'
18
+
19
+ require_relative 'datacaster/and_node'
20
+ require_relative 'datacaster/and_with_error_aggregation_node'
21
+ require_relative 'datacaster/or_node'
22
+ require_relative 'datacaster/then_node'
23
+
24
+ module Datacaster
25
+ def self.schema(&block)
26
+ raise "Expected block" unless block
27
+
28
+ datacaster = RunnerContext.instance.instance_eval(&block)
29
+ unless datacaster.is_a?(Base)
30
+ raise "Datacaster instance should be returned from a block (e.g. result of 'hash_schema(...)' call)"
31
+ end
32
+
33
+ datacaster & Terminator.instance
34
+ end
35
+
36
+ def self.partial_schema(&block)
37
+ raise "Expected block" unless block
38
+
39
+ datacaster = RunnerContext.instance.instance_eval(&block)
40
+ unless datacaster.is_a?(Base)
41
+ raise "Datacaster instance should be returned from a block (e.g. result of 'hash(...)' call)"
42
+ end
43
+
44
+ datacaster
45
+ end
46
+
47
+ def self.absent
48
+ Datacaster::Absent.instance
49
+ end
50
+ end
@@ -0,0 +1,19 @@
1
+ require 'singleton'
2
+
3
+ module Datacaster
4
+ class Absent
5
+ include Singleton
6
+
7
+ def blank?
8
+ true
9
+ end
10
+
11
+ def inspect
12
+ "#<Datacaster.absent>"
13
+ end
14
+
15
+ def present?
16
+ false
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,22 @@
1
+ module Datacaster
2
+ class AndNode < Base
3
+ def initialize(left, right)
4
+ @left = left
5
+ @right = right
6
+ end
7
+
8
+ def call(object)
9
+ object = super(object)
10
+
11
+ left_result = @left.(object)
12
+
13
+ return left_result unless left_result.valid?
14
+
15
+ @right.(left_result)
16
+ end
17
+
18
+ def inspect
19
+ "#<Datacaster::AndNode L: #{@left.inspect} R: #{@right.inspect}>"
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,31 @@
1
+ module Datacaster
2
+ class AndWithErrorAggregationNode < Base
3
+ def initialize(left, right)
4
+ @left = left
5
+ @right = right
6
+ end
7
+
8
+ # Works like AndNode, but doesn't stop at first error — in order to aggregate all Failures
9
+ # Makes sense only for Hash Schemas
10
+ def call(object)
11
+ object = super(object)
12
+
13
+ left_result = @left.(object)
14
+
15
+ if left_result.valid?
16
+ @right.(left_result)
17
+ else
18
+ right_result = @right.(object)
19
+ if right_result.valid?
20
+ left_result
21
+ else
22
+ Datacaster.ErrorResult(self.class.merge_errors(left_result.errors, right_result.errors))
23
+ end
24
+ end
25
+ end
26
+
27
+ def inspect
28
+ "#<Datacaster::AndWithErrorAggregationNode L: #{@left.inspect} R: #{@right.inspect}>"
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,35 @@
1
+ module Datacaster
2
+ class ArraySchema < Base
3
+ def initialize(element_caster)
4
+ # support of shortcut nested validation definitions, e.g. array_schema({a: [integer], b: {c: integer}})
5
+ @element_caster = shortcut_definition(element_caster) # & Terminator.instance
6
+ end
7
+
8
+ def call(object)
9
+ object = super(object)
10
+ checked_schema = object.meta[:checked_schema] || []
11
+
12
+ array = object.value
13
+
14
+ return Datacaster.ErrorResult(["must be array"]) if !array.respond_to?(:map) || !array.respond_to?(:zip)
15
+ return Datacaster.ErrorResult(["must not be empty"]) if array.empty?
16
+
17
+ result =
18
+ array.zip(checked_schema).map do |x, schema|
19
+ x = Datacaster.ValidResult(x, meta: {checked_schema: schema})
20
+ @element_caster.(x)
21
+ end
22
+
23
+ if result.all?(&:valid?)
24
+ checked_schema = result.map { |x| x.meta[:checked_schema] }
25
+ Datacaster.ValidResult(result.map(&:value), meta: {checked_schema: checked_schema})
26
+ else
27
+ Datacaster.ErrorResult(result.each.with_index.reject { |x, _| x.valid? }.map { |x, i| [i, x.errors] }.to_h)
28
+ end
29
+ end
30
+
31
+ def inspect
32
+ "#<Datacaster::ArraySchema [#{@element_caster.inspect}]>"
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,77 @@
1
+ module Datacaster
2
+ class Base
3
+ def self.merge_errors(left, right)
4
+ add_error_to_base = ->(hash, error) {
5
+ hash[:base] ||= []
6
+ hash[:base] = merge_errors(hash[:base], error)
7
+ hash
8
+ }
9
+
10
+ return [] if left.nil? && right.nil?
11
+ return right if left.nil?
12
+ return left if right.nil?
13
+
14
+ result = case [left.class, right.class]
15
+ when [Array, Array]
16
+ left | right
17
+ when [Array, Hash]
18
+ add_error_to_base.(right, left)
19
+ when [Hash, Hash]
20
+ (left.keys | right.keys).map do |k|
21
+ [k, merge_errors(left[k], right[k])]
22
+ end.to_h
23
+ when [Hash, Array]
24
+ add_error_to_base.(left, right)
25
+ else
26
+ raise ArgumentError.new("Expected failures to be Arrays or Hashes, left: #{left.inspect}, right: #{right.inspect}")
27
+ end
28
+
29
+ result
30
+ end
31
+
32
+ def &(other)
33
+ AndNode.new(self, other)
34
+ end
35
+
36
+ def |(other)
37
+ OrNode.new(self, other)
38
+ end
39
+
40
+ def *(other)
41
+ AndWithErrorAggregationNode.new(self, other)
42
+ end
43
+
44
+ def then(other)
45
+ ThenNode.new(self, other)
46
+ end
47
+
48
+ def call(object)
49
+ Datacaster.ValidResult(object)
50
+ end
51
+
52
+ def inspect
53
+ "#<Datacaster::Base>"
54
+ end
55
+
56
+ private
57
+
58
+ # Translates hashes like {a: <IntegerChecker>} to <HashSchema {a: <IntegerChecker>}>
59
+ # and arrays like [<IntegerChecker>] to <ArraySchema <IntegerChecker>>
60
+ def shortcut_definition(definition)
61
+ case definition
62
+ when Datacaster::Base
63
+ definition
64
+ when Array
65
+ if definition.length != 1
66
+ raise ArgumentError.new("Datacaster: shorcut array definitions must have exactly 1 element in the array, e.g. [integer]")
67
+ end
68
+ ArraySchema.new(definition.first)
69
+ when Hash
70
+ HashSchema.new(definition)
71
+ else
72
+ return definition if definition.respond_to?(:call)
73
+ raise ArgumentError.new("Datacaster: Unknown definition #{definition.inspect}, which doesn't respond to #call")
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,30 @@
1
+ module Datacaster
2
+ class Caster < Base
3
+ def initialize(name, &block)
4
+ raise "Expected block" unless block_given?
5
+
6
+ @name = name
7
+ @cast = block
8
+ end
9
+
10
+ def call(object)
11
+ intermediary_result = super(object)
12
+ object = intermediary_result.value
13
+
14
+ result = @cast.(object)
15
+
16
+ raise TypeError.new("Either Datacaster::Result or Dry::Monads::Result " \
17
+ "should be returned from cast block") unless [Datacaster::Result, Dry::Monads::Result].any? { |k| result.is_a?(k) }
18
+
19
+ if result.is_a?(Dry::Monads::Result)
20
+ result = result.success? ? Datacaster.ValidResult(result.value!) : Datacaster.ErrorResult(result.failure)
21
+ end
22
+
23
+ result
24
+ end
25
+
26
+ def inspect
27
+ "#<Datacaster::#{@name}Caster>"
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,26 @@
1
+ module Datacaster
2
+ class Checker < Base
3
+ def initialize(name, error, &block)
4
+ raise "Expected block" unless block_given?
5
+
6
+ @name = name
7
+ @error = error
8
+ @check = block
9
+ end
10
+
11
+ def call(object)
12
+ intermediary_result = super(object)
13
+ object = intermediary_result.value
14
+
15
+ if @check.(object)
16
+ Datacaster.ValidResult(object)
17
+ else
18
+ Datacaster.ErrorResult([@error])
19
+ end
20
+ end
21
+
22
+ def inspect
23
+ "#<Datacaster::#{@name}Checker>"
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,24 @@
1
+ module Datacaster
2
+ class Comparator < Base
3
+ def initialize(value, name, error = nil)
4
+ @value = value
5
+ @name = name
6
+ @error = error || "must be equal to #{value.inspect}"
7
+ end
8
+
9
+ def call(object)
10
+ intermediary_result = super(object)
11
+ object = intermediary_result.value
12
+
13
+ if @value == object
14
+ Datacaster.ValidResult(object)
15
+ else
16
+ Datacaster.ErrorResult([@error])
17
+ end
18
+ end
19
+
20
+ def inspect
21
+ "#<Datacaster::#{@name}Comparator>"
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,70 @@
1
+ module Datacaster
2
+ class HashMapper < Base
3
+ def initialize(fields)
4
+ @fields = fields
5
+ end
6
+
7
+ def call(object)
8
+ object = super(object)
9
+
10
+ # return Datacaster.ErrorResult(["must be hash"]) unless object.value.is_a?(Hash)
11
+
12
+ checked_schema = object.meta[:checked_schema].dup || {}
13
+
14
+ errors = {}
15
+ result = {}
16
+
17
+ @fields.each do |key, validator|
18
+ new_value = validator.(object)
19
+
20
+ # transform_to_hash([:a, :b, :c] => pick(:a, :b, :c) & ...)
21
+ keys = Array(key)
22
+ values_or_errors = Array(new_value.value || new_value.errors)
23
+ if keys.length != values_or_errors.length
24
+ raise TypeError.new("When using transform_to_hash([:a, :b, :c] => validator), validator should return Array "\
25
+ "with number of elements equal to the number of elements in left-hand-side array.\n" \
26
+ "Got the following (values or errors) instead: #{keys.inspect} => #{values_or_errors.inspect}.")
27
+ end
28
+
29
+ if new_value.valid?
30
+ keys.each.with_index do |key, i|
31
+ result[key] = values_or_errors[i]
32
+ checked_schema[key] = true
33
+ end
34
+
35
+ single_returned_schema = new_value.meta[:checked_schema].dup
36
+ checked_schema[keys.first] = single_returned_schema if keys.length == 1 && single_returned_schema
37
+ else
38
+ errors.merge!(keys.zip(values_or_errors).to_h)
39
+ end
40
+ end
41
+
42
+ errors.delete_if { |_, v| v.empty? }
43
+
44
+ if errors.empty?
45
+ # All unchecked key-value pairs of initial hash are passed through, and eliminated by Terminator
46
+ # at the end of the chain. If we weren't dealing with the hash, then ignore that.
47
+ result_hash =
48
+ if object.value.is_a?(Hash)
49
+ object.value.merge(result)
50
+ else
51
+ result
52
+ end
53
+
54
+ result_hash.keys.each { |k| result_hash.delete(k) if result_hash[k] == Datacaster.absent }
55
+ Datacaster.ValidResult(result_hash, meta: {checked_schema: checked_schema})
56
+ else
57
+ Datacaster.ErrorResult(errors)
58
+ end
59
+ end
60
+
61
+ def inspect
62
+ field_descriptions =
63
+ @fields.map do |k, v|
64
+ "#{k.inspect} => #{v.inspect}"
65
+ end
66
+
67
+ "#<Datacaster::HashMapper {#{field_descriptions.join(', ')}}>"
68
+ end
69
+ end
70
+ end