datacaster 0.9.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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