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.
- 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
|