datacaster 0.9.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +12 -0
- data/.rspec +3 -0
- data/.travis.yml +6 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +59 -0
- data/LICENSE.txt +21 -0
- data/README.md +981 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/datacaster.gemspec +30 -0
- data/lib/datacaster.rb +50 -0
- data/lib/datacaster/absent.rb +19 -0
- data/lib/datacaster/and_node.rb +22 -0
- data/lib/datacaster/and_with_error_aggregation_node.rb +31 -0
- data/lib/datacaster/array_schema.rb +35 -0
- data/lib/datacaster/base.rb +77 -0
- data/lib/datacaster/caster.rb +30 -0
- data/lib/datacaster/checker.rb +26 -0
- data/lib/datacaster/comparator.rb +24 -0
- data/lib/datacaster/hash_mapper.rb +70 -0
- data/lib/datacaster/hash_schema.rb +56 -0
- data/lib/datacaster/or_node.rb +22 -0
- data/lib/datacaster/predefined.rb +179 -0
- data/lib/datacaster/result.rb +61 -0
- data/lib/datacaster/runner_context.rb +21 -0
- data/lib/datacaster/terminator.rb +77 -0
- data/lib/datacaster/then_node.rb +35 -0
- data/lib/datacaster/transformer.rb +21 -0
- data/lib/datacaster/trier.rb +27 -0
- data/lib/datacaster/validator.rb +41 -0
- data/lib/datacaster/version.rb +3 -0
- metadata +138 -0
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -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__)
|
data/bin/setup
ADDED
data/datacaster.gemspec
ADDED
@@ -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
|
data/lib/datacaster.rb
ADDED
@@ -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,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
|