plumb 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent'
4
+ require 'plumb/steppable'
5
+ require 'plumb/result'
6
+ require 'plumb/hash_class'
7
+
8
+ module Plumb
9
+ class ArrayClass
10
+ include Steppable
11
+
12
+ attr_reader :element_type
13
+
14
+ def initialize(element_type: Types::Any)
15
+ @element_type = case element_type
16
+ when Steppable
17
+ element_type
18
+ when ::Hash
19
+ HashClass.new(element_type)
20
+ else
21
+ raise ArgumentError,
22
+ "element_type #{element_type.inspect} must be a Steppable"
23
+ end
24
+
25
+ freeze
26
+ end
27
+
28
+ def of(element_type)
29
+ self.class.new(element_type:)
30
+ end
31
+
32
+ alias [] of
33
+
34
+ def concurrent
35
+ ConcurrentArrayClass.new(element_type:)
36
+ end
37
+
38
+ private def _inspect
39
+ %(#{name}[#{element_type}])
40
+ end
41
+
42
+ def call(result)
43
+ return result.invalid(errors: 'is not an Array') unless result.value.is_a?(::Enumerable)
44
+
45
+ values, errors = map_array_elements(result.value)
46
+ return result.valid(values) unless errors.any?
47
+
48
+ result.invalid(errors:)
49
+ end
50
+
51
+ private
52
+
53
+ def map_array_elements(list)
54
+ # Reuse the same result object for each element
55
+ # to decrease object allocation.
56
+ # Steps might return the same result instance, so we map the values directly
57
+ # separate from the errors.
58
+ element_result = BLANK_RESULT.dup
59
+ errors = {}
60
+ values = list.map.with_index do |e, idx|
61
+ re = element_type.call(element_result.reset(e))
62
+ errors[idx] = re.errors unless re.valid?
63
+ re.value
64
+ end
65
+
66
+ [values, errors]
67
+ end
68
+
69
+ class ConcurrentArrayClass < self
70
+ private
71
+
72
+ def map_array_elements(list)
73
+ errors = {}
74
+
75
+ values = list
76
+ .map { |e| Concurrent::Future.execute { element_type.resolve(e) } }
77
+ .map.with_index do |f, idx|
78
+ re = f.value
79
+ errors[idx] = f.reason if f.rejected?
80
+ re.value
81
+ end
82
+
83
+ [values, errors]
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb/steppable'
4
+
5
+ module Plumb
6
+ class Build
7
+ include Steppable
8
+
9
+ attr_reader :type
10
+
11
+ def initialize(type, factory_method: :new, &block)
12
+ @type = type
13
+ @block = block || ->(value) { type.send(factory_method, value) }
14
+ end
15
+
16
+ def call(result) = result.valid(@block.call(result.value))
17
+ end
18
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thread'
4
+
5
+ module Plumb
6
+ class Deferred
7
+ include Steppable
8
+
9
+ def initialize(definition)
10
+ @lock = Mutex.new
11
+ @definition = definition
12
+ @cached_type = nil
13
+ # freeze
14
+ end
15
+
16
+ def call(result)
17
+ cached_type.call(result)
18
+ end
19
+
20
+ private def cached_type
21
+ @lock.synchronize do
22
+ @cached_type = @definition.call
23
+ self.define_singleton_method(:cached_type) do
24
+ @cached_type
25
+ end
26
+ @cached_type
27
+ end
28
+ end
29
+ end
30
+ end
31
+
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb/steppable'
4
+ require 'plumb/key'
5
+ require 'plumb/static_class'
6
+ require 'plumb/hash_map'
7
+ require 'plumb/tagged_hash'
8
+
9
+ module Plumb
10
+ class HashClass
11
+ include Steppable
12
+
13
+ attr_reader :_schema
14
+
15
+ def initialize(schema = {})
16
+ @_schema = wrap_keys_and_values(schema)
17
+ freeze
18
+ end
19
+
20
+ # A Hash type with a specific schema.
21
+ # Option 1: a Hash representing schema
22
+ #
23
+ # Types::Hash[name: Types::String.present, age?: Types::Integer]
24
+ #
25
+ # Option 2: a Map with pre-defined types for all keys and values
26
+ #
27
+ # Types::Hash[Types::String, Types::Integer]
28
+ def schema(*args)
29
+ case args
30
+ in [::Hash => hash]
31
+ self.class.new(_schema.merge(wrap_keys_and_values(hash)))
32
+ in [Steppable => key_type, Steppable => value_type]
33
+ HashMap.new(key_type, value_type)
34
+ else
35
+ raise ::ArgumentError, "unexpected value to Types::Hash#schema #{args.inspect}"
36
+ end
37
+ end
38
+
39
+ alias [] schema
40
+
41
+ # Hash#merge keeps the left-side key in the new hash
42
+ # if they match via #hash and #eql?
43
+ # we need to keep the right-side key, because even if the key name is the same,
44
+ # it's optional flag might have changed
45
+ def +(other)
46
+ raise ArgumentError, "expected a HashClass, got #{other.class}" unless other.is_a?(HashClass)
47
+
48
+ self.class.new(merge_rightmost_keys(_schema, other._schema))
49
+ end
50
+
51
+ def &(other)
52
+ raise ArgumentError, "expected a HashClass, got #{other.class}" unless other.is_a?(HashClass)
53
+
54
+ intersected_keys = other._schema.keys & _schema.keys
55
+ intersected = intersected_keys.each.with_object({}) do |k, memo|
56
+ memo[k] = other.at_key(k)
57
+ end
58
+
59
+ self.class.new(intersected)
60
+ end
61
+
62
+ def tagged_by(key, *types)
63
+ TaggedHash.new(self, key, types)
64
+ end
65
+
66
+ def at_key(a_key)
67
+ _schema[Key.wrap(a_key)]
68
+ end
69
+
70
+ def to_h = _schema
71
+
72
+ private def _inspect
73
+ %(#{name}[#{_schema.map { |(k, v)| [k.inspect, v.inspect].join(':') }.join(' ')}])
74
+ end
75
+
76
+ def call(result)
77
+ return result.invalid(errors: 'must be a Hash') unless result.value.is_a?(::Hash)
78
+ return result unless _schema.any?
79
+
80
+ input = result.value
81
+ errors = {}
82
+ field_result = BLANK_RESULT.dup
83
+ output = _schema.each.with_object({}) do |(key, field), ret|
84
+ key_s = key.to_sym
85
+ if input.key?(key_s)
86
+ r = field.call(field_result.reset(input[key_s]))
87
+ errors[key_s] = r.errors unless r.valid?
88
+ ret[key_s] = r.value
89
+ elsif !key.optional?
90
+ r = field.call(BLANK_RESULT)
91
+ errors[key_s] = r.errors unless r.valid?
92
+ ret[key_s] = r.value unless r.value == Undefined
93
+ end
94
+ end
95
+
96
+ errors.any? ? result.invalid(output, errors:) : result.valid(output)
97
+ end
98
+
99
+ private
100
+
101
+ def wrap_keys_and_values(hash)
102
+ case hash
103
+ when ::Array
104
+ hash.map { |e| wrap_keys_and_values(e) }
105
+ when ::Hash
106
+ hash.each.with_object({}) do |(k, v), ret|
107
+ ret[Key.wrap(k)] = wrap_keys_and_values(v)
108
+ end
109
+ when Callable
110
+ hash
111
+ else #  leaf values
112
+ StaticClass.new(hash)
113
+ end
114
+ end
115
+
116
+ def merge_rightmost_keys(hash1, hash2)
117
+ hash2.each.with_object(hash1.clone) do |(k, v), memo|
118
+ # assigning a key that already exist with #hash and #eql
119
+ # leaves the original key instance in place.
120
+ # but we want the hash2 key there, because its optionality could have changed.
121
+ memo.delete(k) if memo.key?(k)
122
+ memo[k] = v
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb/steppable'
4
+
5
+ module Plumb
6
+ class HashMap
7
+ include Steppable
8
+
9
+ attr_reader :key_type, :value_type
10
+
11
+ def initialize(key_type, value_type)
12
+ @key_type = key_type
13
+ @value_type = value_type
14
+ freeze
15
+ end
16
+
17
+ def call(result)
18
+ failed = result.value.lazy.filter_map do |key, value|
19
+ key_r = @key_type.resolve(key)
20
+ value_r = @value_type.resolve(value)
21
+ if !key_r.valid?
22
+ [:key, key, key_r]
23
+ elsif !value_r.valid?
24
+ [:value, value, value_r]
25
+ end
26
+ end
27
+ if (first = failed.next)
28
+ field, val, halt = failed.first
29
+ result.invalid(errors: "#{field} #{val.inspect} #{halt.errors}")
30
+ end
31
+ rescue StopIteration
32
+ result
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb/steppable'
4
+
5
+ module Plumb
6
+ class InterfaceClass
7
+ include Steppable
8
+
9
+ attr_reader :method_names
10
+
11
+ def initialize(method_names = [])
12
+ @method_names = method_names
13
+ freeze
14
+ end
15
+
16
+ def of(*args)
17
+ case args
18
+ in Array => symbols if symbols.all? { |s| s.is_a?(::Symbol) }
19
+ self.class.new(symbols)
20
+ else
21
+ raise ::ArgumentError, "unexpected value to Types::Interface#of #{args.inspect}"
22
+ end
23
+ end
24
+
25
+ alias [] of
26
+
27
+ def call(result)
28
+ obj = result.value
29
+ missing_methods = @method_names.reject { |m| obj.respond_to?(m) }
30
+ return result.invalid(errors: "missing methods: #{missing_methods.join(', ')}") if missing_methods.any?
31
+
32
+ result
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb/visitor_handlers'
4
+
5
+ module Plumb
6
+ class JSONSchemaVisitor
7
+ include VisitorHandlers
8
+
9
+ TYPE = 'type'
10
+ PROPERTIES = 'properties'
11
+ REQUIRED = 'required'
12
+ DEFAULT = 'default'
13
+ ANY_OF = 'anyOf'
14
+ ALL_OF = 'allOf'
15
+ ENUM = 'enum'
16
+ CONST = 'const'
17
+ ITEMS = 'items'
18
+ PATTERN = 'pattern'
19
+ MINIMUM = 'minimum'
20
+ MAXIMUM = 'maximum'
21
+
22
+ def self.call(type)
23
+ {
24
+ '$schema' => 'https://json-schema.org/draft-08/schema#',
25
+ }.merge(new.visit(type))
26
+ end
27
+
28
+ private def stringify_keys(hash) = hash.transform_keys(&:to_s)
29
+
30
+ on(:any) do |type, props|
31
+ props
32
+ end
33
+
34
+ on(:pipeline) do |type, props|
35
+ visit(type.type, props)
36
+ end
37
+
38
+ on(:step) do |type, props|
39
+ props.merge(stringify_keys(type._metadata))
40
+ end
41
+
42
+ on(:hash) do |type, props|
43
+ props.merge(
44
+ TYPE => 'object',
45
+ PROPERTIES => type._schema.each_with_object({}) do |(key, value), hash|
46
+ hash[key.to_s] = visit(value)
47
+ end,
48
+ REQUIRED => type._schema.select { |key, value| !key.optional? }.keys.map(&:to_s)
49
+ )
50
+ end
51
+
52
+ on(:and) do |type, props|
53
+ left = visit(type.left)
54
+ right = visit(type.right)
55
+ type = right[TYPE] || left[TYPE]
56
+ props = props.merge(left).merge(right)
57
+ props = props.merge(TYPE => type) if type
58
+ props
59
+ end
60
+
61
+ # A "default" value is usually an "or" of expected_value | (undefined >> static_value)
62
+ on(:or) do |type, props|
63
+ left = visit(type.left)
64
+ right = visit(type.right)
65
+ any_of = [left, right].uniq
66
+ if any_of.size == 1
67
+ props.merge(left)
68
+ elsif any_of.size == 2 && (defidx = any_of.index { |p| p.key?(DEFAULT) })
69
+ val = any_of[defidx == 0 ? 1 : 0]
70
+ props.merge(val).merge(DEFAULT => any_of[defidx][DEFAULT])
71
+ else
72
+ props.merge(ANY_OF => any_of)
73
+ end
74
+ end
75
+
76
+ on(:value) do |type, props|
77
+ props = case type.value
78
+ when ::String, ::Symbol, ::Numeric
79
+ props.merge(CONST => type.value)
80
+ else
81
+ props
82
+ end
83
+
84
+ visit(type.value, props)
85
+ end
86
+
87
+ on(:transform) do |type, props|
88
+ visit(type.target_type, props)
89
+ end
90
+
91
+ on(:undefined) do |type, props|
92
+ props
93
+ end
94
+
95
+ on(:static) do |type, props|
96
+ props = case type.value
97
+ when ::String, ::Symbol, ::Numeric
98
+ props.merge(CONST => type.value, DEFAULT => type.value)
99
+ else
100
+ props
101
+ end
102
+
103
+ visit(type.value, props)
104
+ end
105
+
106
+ on(:rules) do |type, props|
107
+ type.rules.reduce(props) do |acc, rule|
108
+ acc.merge(visit(rule))
109
+ end
110
+ end
111
+
112
+ on(:rule_included_in) do |type, props|
113
+ props.merge(ENUM => type.arg_value)
114
+ end
115
+
116
+ on(:match) do |type, props|
117
+ visit(type.matcher, props)
118
+ end
119
+
120
+ on(:boolean) do |type, props|
121
+ props.merge(TYPE => 'boolean')
122
+ end
123
+
124
+ on(::String) do |type, props|
125
+ props.merge(TYPE => 'string')
126
+ end
127
+
128
+ on(::Integer) do |type, props|
129
+ props.merge(TYPE => 'integer')
130
+ end
131
+
132
+ on(::Numeric) do |type, props|
133
+ props.merge(TYPE => 'number')
134
+ end
135
+
136
+ on(::BigDecimal) do |type, props|
137
+ props.merge(TYPE => 'number')
138
+ end
139
+
140
+ on(::Float) do |type, props|
141
+ props.merge(TYPE => 'number')
142
+ end
143
+
144
+ on(::TrueClass) do |type, props|
145
+ props.merge(TYPE => 'boolean')
146
+ end
147
+
148
+ on(::NilClass) do |type, props|
149
+ props.merge(TYPE => 'null')
150
+ end
151
+
152
+ on(::FalseClass) do |type, props|
153
+ props.merge(TYPE => 'boolean')
154
+ end
155
+
156
+ on(::Regexp) do |type, props|
157
+ props.merge(PATTERN => type.source)
158
+ end
159
+
160
+ on(::Range) do |type, props|
161
+ opts = {}
162
+ opts[MINIMUM] = type.min if type.begin
163
+ opts[MAXIMUM] = type.max if type.end
164
+ props.merge(opts)
165
+ end
166
+
167
+ on(:metadata) do |type, props|
168
+ # TODO: here we should filter out the metadata that is not relevant for JSON Schema
169
+ props.merge(stringify_keys(type.metadata))
170
+ end
171
+
172
+ on(:hash_map) do |type, props|
173
+ {
174
+ TYPE => 'object',
175
+ 'patternProperties' => {
176
+ '.*' => visit(type.value_type)
177
+ }
178
+ }
179
+ end
180
+
181
+ on(:build) do |type, props|
182
+ visit(type.type, props)
183
+ end
184
+
185
+ on(:array) do |type, props|
186
+ items = visit(type.element_type)
187
+ { TYPE => 'array', ITEMS => items }
188
+ end
189
+
190
+ on(:tuple) do |type, props|
191
+ items = type.types.map { |t| visit(t) }
192
+ { TYPE => 'array', 'prefixItems' => items }
193
+ end
194
+
195
+ on(:tagged_hash) do |type, props|
196
+ required = Set.new
197
+ result = {
198
+ TYPE => 'object',
199
+ PROPERTIES => {}
200
+ }
201
+
202
+ key = type.key.to_s
203
+ children = type.types.map { |c| visit(c) }
204
+ key_enum = children.map { |c| c[PROPERTIES][key][CONST] }
205
+ key_type = children.map { |c| c[PROPERTIES][key][TYPE] }
206
+ required << key
207
+ result[PROPERTIES][key] = { TYPE => key_type.first, ENUM => key_enum }
208
+ result[ALL_OF] = children.map do |child|
209
+ child_prop = child[PROPERTIES][key]
210
+
211
+ {
212
+ 'if' => {
213
+ PROPERTIES => { key => child_prop.slice(CONST, TYPE) }
214
+ },
215
+ 'then' => child.except(TYPE)
216
+ }
217
+ end
218
+
219
+ result.merge(REQUIRED => required.to_a)
220
+ end
221
+ end
222
+ end
data/lib/plumb/key.rb ADDED
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plumb
4
+ class Key
5
+ OPTIONAL_EXP = /(\w+)(\?)?$/
6
+
7
+ def self.wrap(key)
8
+ key.is_a?(Key) ? key : new(key)
9
+ end
10
+
11
+ attr_reader :to_sym, :node_name
12
+
13
+ def initialize(key, optional: false)
14
+ key_s = key.to_s
15
+ match = OPTIONAL_EXP.match(key_s)
16
+ @node_name = :key
17
+ @key = match[1]
18
+ @to_sym = @key.to_sym
19
+ @optional = !match[2].nil? ? true : optional
20
+ freeze
21
+ end
22
+
23
+ def to_s = @key
24
+
25
+ def hash
26
+ @key.hash
27
+ end
28
+
29
+ def eql?(other)
30
+ other.hash == hash
31
+ end
32
+
33
+ def optional?
34
+ @optional
35
+ end
36
+
37
+ def inspect
38
+ "#{@key}#{'?' if @optional}"
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb/steppable'
4
+
5
+ module Plumb
6
+ class MatchClass
7
+ include Steppable
8
+
9
+ attr_reader :matcher
10
+
11
+ def initialize(matcher = Undefined, error: nil)
12
+ raise TypeError 'matcher must respond to #===' unless matcher.respond_to?(:===)
13
+
14
+ @matcher = matcher
15
+ @error = error.nil? ? build_error(matcher) : (error % matcher)
16
+ end
17
+
18
+ def inspect
19
+ %(#{name}[#{@matcher.inspect}])
20
+ end
21
+
22
+ def call(result)
23
+ @matcher === result.value ? result : result.invalid(errors: @error)
24
+ end
25
+
26
+ private def build_error(matcher)
27
+ case matcher
28
+ when Class # A class primitive, ex. String, Integer, etc.
29
+ "Must be a #{matcher}"
30
+ when ::String, ::Symbol, ::Numeric, ::TrueClass, ::FalseClass, ::NilClass, ::Array, ::Hash
31
+ "Must be equal to #{matcher}"
32
+ when ::Range
33
+ "Must be within #{matcher}"
34
+ else
35
+ "Must match #{matcher.inspect}"
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plumb
4
+ class Metadata
5
+ include Steppable
6
+
7
+ attr_reader :metadata
8
+
9
+ def initialize(metadata)
10
+ @metadata = metadata
11
+ end
12
+
13
+ def call(result) = result
14
+ end
15
+ end