plumb 0.0.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,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