plumb 0.0.4 → 0.0.6
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 +4 -4
- data/README.md +255 -12
- data/bench/compare_parametric_schema.rb +102 -0
- data/bench/compare_parametric_struct.rb +68 -0
- data/bench/parametric_schema.rb +229 -0
- data/bench/plumb_hash.rb +99 -0
- data/examples/command_objects.rb +0 -3
- data/examples/concurrent_downloads.rb +2 -5
- data/examples/event_registry.rb +34 -27
- data/examples/weekdays.rb +2 -2
- data/lib/plumb/attributes.rb +16 -7
- data/lib/plumb/composable.rb +134 -4
- data/lib/plumb/hash_class.rb +2 -11
- data/lib/plumb/json_schema_visitor.rb +23 -2
- data/lib/plumb/match_class.rb +1 -1
- data/lib/plumb/pipeline.rb +21 -2
- data/lib/plumb/tagged_hash.rb +1 -1
- data/lib/plumb/types.rb +42 -0
- data/lib/plumb/version.rb +1 -1
- metadata +6 -2
data/lib/plumb/composable.rb
CHANGED
@@ -13,7 +13,7 @@ module Plumb
|
|
13
13
|
def empty? = true
|
14
14
|
end
|
15
15
|
|
16
|
-
|
16
|
+
ParseError = Class.new(::TypeError)
|
17
17
|
Undefined = UndefinedClass.new.freeze
|
18
18
|
|
19
19
|
BLANK_STRING = ''
|
@@ -29,7 +29,7 @@ module Plumb
|
|
29
29
|
|
30
30
|
def parse(value = Undefined)
|
31
31
|
result = resolve(value)
|
32
|
-
raise
|
32
|
+
raise ParseError, result.errors if result.invalid?
|
33
33
|
|
34
34
|
result.value
|
35
35
|
end
|
@@ -87,6 +87,7 @@ module Plumb
|
|
87
87
|
# Composable mixes in composition methods to classes.
|
88
88
|
# such as #>>, #|, #not, and others.
|
89
89
|
# Any Composable class can participate in Plumb compositions.
|
90
|
+
# A host object only needs to implement the Step interface `call(Result::Valid) => Result::Valid | Result::Invalid`
|
90
91
|
module Composable
|
91
92
|
include Callable
|
92
93
|
|
@@ -96,11 +97,35 @@ module Plumb
|
|
96
97
|
base.send(:include, Naming)
|
97
98
|
end
|
98
99
|
|
100
|
+
# Wrap an object in a Composable instance.
|
101
|
+
# Anything that includes Composable is a noop.
|
102
|
+
# A Hash is assumed to be a HashClass schema.
|
103
|
+
# An Array with zero or 1 element is assumed to be an ArrayClass.
|
104
|
+
# Any `#call(Result) => Result` interface is wrapped in a Step.
|
105
|
+
# Anything else is assumed to be something you want to match against via `#===`.
|
106
|
+
#
|
107
|
+
# @example
|
108
|
+
# ten = Composable.wrap(10)
|
109
|
+
# ten.resolve(10) # => Result::Valid
|
110
|
+
# ten.resolve(11) # => Result::Invalid
|
111
|
+
#
|
112
|
+
# @param callable [Object]
|
113
|
+
# @return [Composable]
|
99
114
|
def self.wrap(callable)
|
100
115
|
if callable.is_a?(Composable)
|
101
116
|
callable
|
102
117
|
elsif callable.is_a?(::Hash)
|
103
118
|
HashClass.new(schema: callable)
|
119
|
+
elsif callable.is_a?(::Array)
|
120
|
+
element_type = case callable.size
|
121
|
+
when 0
|
122
|
+
Types::Any
|
123
|
+
when 1
|
124
|
+
callable.first
|
125
|
+
else
|
126
|
+
raise ArgumentError, '[element_type] syntax allows a single element type'
|
127
|
+
end
|
128
|
+
Types::Array[element_type]
|
104
129
|
elsif callable.respond_to?(:call)
|
105
130
|
Step.new(callable)
|
106
131
|
else
|
@@ -108,26 +133,66 @@ module Plumb
|
|
108
133
|
end
|
109
134
|
end
|
110
135
|
|
136
|
+
# A helper to wrap a block in a Step that will defer execution.
|
137
|
+
# This so that types can be used recursively in compositions.
|
138
|
+
# @example
|
139
|
+
# LinkedList = Types::Hash[
|
140
|
+
# value: Types::Any,
|
141
|
+
# next: Types::Any.defer { LinkedList }
|
142
|
+
# ]
|
111
143
|
def defer(definition = nil, &block)
|
112
144
|
Deferred.new(definition || block)
|
113
145
|
end
|
114
146
|
|
147
|
+
# Chain two composable objects together.
|
148
|
+
# A.K.A "and" or "sequence"
|
149
|
+
# @example
|
150
|
+
# Step1 >> Step2 >> Step3
|
151
|
+
#
|
152
|
+
# @param other [Composable]
|
153
|
+
# @return [And]
|
115
154
|
def >>(other)
|
116
155
|
And.new(self, Composable.wrap(other))
|
117
156
|
end
|
118
157
|
|
158
|
+
# Chain two composable objects together as a disjunction ("or").
|
159
|
+
#
|
160
|
+
# @param other [Composable]
|
161
|
+
# @return [Or]
|
119
162
|
def |(other)
|
120
163
|
Or.new(self, Composable.wrap(other))
|
121
164
|
end
|
122
165
|
|
166
|
+
# Transform value. Requires specifying the resulting type of the value after transformation.
|
167
|
+
# @example
|
168
|
+
# Types::String.transform(Types::Symbol, &:to_sym)
|
169
|
+
#
|
170
|
+
# @param target_type [Class] what type this step will transform the value to
|
171
|
+
# @param callable [#call, nil] a callable that will be applied to the value, or nil if block provided
|
172
|
+
# @param block [Proc] a block that will be applied to the value, or nil if callable provided
|
173
|
+
# @return [And]
|
123
174
|
def transform(target_type, callable = nil, &block)
|
124
175
|
self >> Transform.new(target_type, callable || block)
|
125
176
|
end
|
126
177
|
|
178
|
+
# Pass the value through an arbitrary validation
|
179
|
+
# @example
|
180
|
+
# type = Types::String.check('must start with "Role:"') { |value| value.start_with?('Role:') }
|
181
|
+
#
|
182
|
+
# @param errors [String] error message to use when validation fails
|
183
|
+
# @param block [Proc] a block that will be applied to the value
|
184
|
+
# @return [And]
|
127
185
|
def check(errors = 'did not pass the check', &block)
|
128
186
|
self >> MatchClass.new(block, error: errors, label: errors)
|
129
187
|
end
|
130
188
|
|
189
|
+
# Return a new Step with added metadata, or build step metadata if no argument is provided.
|
190
|
+
# @example
|
191
|
+
# type = Types::String.metadata(label: 'Name')
|
192
|
+
# type.metadata # => { type: String, label: 'Name' }
|
193
|
+
#
|
194
|
+
# @param data [Hash] metadata to add to the step
|
195
|
+
# @return [Hash, And]
|
131
196
|
def metadata(data = Undefined)
|
132
197
|
if data == Undefined
|
133
198
|
MetadataVisitor.call(self)
|
@@ -136,24 +201,54 @@ module Plumb
|
|
136
201
|
end
|
137
202
|
end
|
138
203
|
|
204
|
+
# Negate the result of a step.
|
205
|
+
# Ie. if the step is valid, it will be invalid, and vice versa.
|
206
|
+
# @example
|
207
|
+
# type = Types::String.not
|
208
|
+
# type.resolve('foo') # invalid
|
209
|
+
# type.resolve(10) # valid
|
210
|
+
#
|
211
|
+
# @return [Not]
|
139
212
|
def not(other = self)
|
140
213
|
Not.new(other)
|
141
214
|
end
|
142
215
|
|
216
|
+
# Like #not, but with a custom error message.
|
217
|
+
#
|
218
|
+
# @option errors [String] error message to use when validation fails
|
219
|
+
# @return [Not]
|
143
220
|
def invalid(errors: nil)
|
144
221
|
Not.new(self, errors:)
|
145
222
|
end
|
146
223
|
|
224
|
+
# Match a value using `#==`
|
225
|
+
# Normally you'll build matchers via ``#[]`, which uses `#===`.
|
226
|
+
# Use this if you want to match against concrete instances of things that respond to `#===`
|
227
|
+
# @example
|
228
|
+
# regex = Types::Any.value(/foo/)
|
229
|
+
# regex.resolve('foo') # invalid. We're matching against the regex itself.
|
230
|
+
# regex.resolve(/foo/) # valid
|
231
|
+
#
|
232
|
+
# @param value [Object]
|
233
|
+
# @rerurn [And]
|
147
234
|
def value(val)
|
148
235
|
self >> ValueClass.new(val)
|
149
236
|
end
|
150
237
|
|
238
|
+
# Alias of `#[]`
|
239
|
+
# Match a value using `#===`
|
240
|
+
# @example
|
241
|
+
# email = Types::String['@']
|
242
|
+
#
|
243
|
+
# @param args [Array<Object>]
|
244
|
+
# @return [And]
|
151
245
|
def match(*args)
|
152
246
|
self >> MatchClass.new(*args)
|
153
247
|
end
|
154
248
|
|
155
249
|
def [](val) = match(val)
|
156
250
|
|
251
|
+
# Support #as_node.
|
157
252
|
class Node
|
158
253
|
include Composable
|
159
254
|
|
@@ -169,6 +264,14 @@ module Plumb
|
|
169
264
|
def call(result) = type.call(result)
|
170
265
|
end
|
171
266
|
|
267
|
+
# Wrap a Step in a node with a custom #node_name
|
268
|
+
# which is expected by visitors.
|
269
|
+
# So that we can define special visitors for certain compositions.
|
270
|
+
# Ex. Types::Boolean is a compoition of Types::True | Types::False, but we want to treat it as a single node.
|
271
|
+
#
|
272
|
+
# @param node_name [Symbol]
|
273
|
+
# @param metadata [Hash]
|
274
|
+
# @return [Node]
|
172
275
|
def as_node(node_name, metadata = BLANK_HASH)
|
173
276
|
Node.new(node_name, self, metadata)
|
174
277
|
end
|
@@ -186,7 +289,7 @@ module Plumb
|
|
186
289
|
|
187
290
|
bargs = [self]
|
188
291
|
arg = Undefined
|
189
|
-
if rest.
|
292
|
+
if rest.size.positive?
|
190
293
|
bargs << rest.first
|
191
294
|
arg = rest.first
|
192
295
|
end
|
@@ -201,6 +304,9 @@ module Plumb
|
|
201
304
|
end
|
202
305
|
end
|
203
306
|
|
307
|
+
# `#===` equality. So that Plumb steps can be used in case statements and pattern matching.
|
308
|
+
# @param other [Object]
|
309
|
+
# @return [Boolean]
|
204
310
|
def ===(other)
|
205
311
|
case other
|
206
312
|
when Composable
|
@@ -211,15 +317,39 @@ module Plumb
|
|
211
317
|
end
|
212
318
|
|
213
319
|
def ==(other)
|
214
|
-
other.is_a?(self.class) && other.children == children
|
320
|
+
other.is_a?(self.class) && other.respond_to?(:children) && other.children == children
|
215
321
|
end
|
216
322
|
|
323
|
+
# Visitors expect a #node_name and #children interface.
|
324
|
+
# @return [Array<Composable>]
|
217
325
|
def children = BLANK_ARRAY
|
218
326
|
|
327
|
+
# Compose a step that instantiates a class.
|
328
|
+
# @example
|
329
|
+
# type = Types::String.build(MyClass, :new)
|
330
|
+
# thing = type.parse('foo') # same as MyClass.new('foo')
|
331
|
+
#
|
332
|
+
# It sets the class as the output type of the step.
|
333
|
+
# Optionally takes a block.
|
334
|
+
#
|
335
|
+
# type = Types::String.build(Money) { |value| Monetize.parse(value) }
|
336
|
+
#
|
337
|
+
# @param cns [Class] constructor class or object.
|
338
|
+
# @param factory_method [Symbol] method to call on the class to instantiate it.
|
339
|
+
# @return [And]
|
219
340
|
def build(cns, factory_method = :new, &block)
|
220
341
|
self >> Build.new(cns, factory_method:, &block)
|
221
342
|
end
|
222
343
|
|
344
|
+
# Build a Plumb::Pipeline with this object as the starting step.
|
345
|
+
# @example
|
346
|
+
# pipe = Types::Data[name: String].pipeline do |pl|
|
347
|
+
# pl.step Validate
|
348
|
+
# pl.step Debug
|
349
|
+
# pl.step Log
|
350
|
+
# end
|
351
|
+
#
|
352
|
+
# @return [Pipeline]
|
223
353
|
def pipeline(&block)
|
224
354
|
Pipeline.new(self, &block)
|
225
355
|
end
|
data/lib/plumb/hash_class.rb
CHANGED
@@ -139,17 +139,8 @@ module Plumb
|
|
139
139
|
end
|
140
140
|
|
141
141
|
def wrap_keys_and_values(hash)
|
142
|
-
|
143
|
-
|
144
|
-
hash.map { |e| wrap_keys_and_values(e) }
|
145
|
-
when ::Hash
|
146
|
-
hash.each.with_object({}) do |(k, v), ret|
|
147
|
-
ret[Key.wrap(k)] = wrap_keys_and_values(v)
|
148
|
-
end
|
149
|
-
when Callable
|
150
|
-
hash
|
151
|
-
else # leaf values
|
152
|
-
Composable.wrap(hash)
|
142
|
+
hash.each.with_object({}) do |(k, v), ret|
|
143
|
+
ret[Key.wrap(k)] = Composable.wrap(v)
|
153
144
|
end
|
154
145
|
end
|
155
146
|
|
@@ -24,6 +24,7 @@ module Plumb
|
|
24
24
|
MAX_ITEMS = 'maxItems'
|
25
25
|
MIN_LENGTH = 'minLength'
|
26
26
|
MAX_LENGTH = 'maxLength'
|
27
|
+
FORMAT = 'format'
|
27
28
|
ENVELOPE = {
|
28
29
|
'$schema' => 'https://json-schema.org/draft-08/schema#'
|
29
30
|
}.freeze
|
@@ -192,6 +193,14 @@ module Plumb
|
|
192
193
|
props.merge(TYPE => 'boolean')
|
193
194
|
end
|
194
195
|
|
196
|
+
on(:uuid) do |_node, props|
|
197
|
+
props.merge(TYPE => 'string', FORMAT => 'uuid')
|
198
|
+
end
|
199
|
+
|
200
|
+
on(:email) do |_node, props|
|
201
|
+
props.merge(TYPE => 'string', FORMAT => 'email')
|
202
|
+
end
|
203
|
+
|
195
204
|
on(::String) do |_node, props|
|
196
205
|
props.merge(TYPE => 'string')
|
197
206
|
end
|
@@ -239,11 +248,23 @@ module Plumb
|
|
239
248
|
end
|
240
249
|
|
241
250
|
on(::Time) do |_node, props|
|
242
|
-
props.merge(TYPE => 'string',
|
251
|
+
props.merge(TYPE => 'string', FORMAT => 'date-time')
|
243
252
|
end
|
244
253
|
|
245
254
|
on(::Date) do |_node, props|
|
246
|
-
props.merge(TYPE => 'string',
|
255
|
+
props.merge(TYPE => 'string', FORMAT => 'date')
|
256
|
+
end
|
257
|
+
|
258
|
+
on(::URI::Generic) do |_node, props|
|
259
|
+
props.merge(TYPE => 'string', FORMAT => 'uri')
|
260
|
+
end
|
261
|
+
|
262
|
+
on(::URI::HTTP) do |_node, props|
|
263
|
+
props.merge(TYPE => 'string', FORMAT => 'uri')
|
264
|
+
end
|
265
|
+
|
266
|
+
on(::URI::File) do |_node, props|
|
267
|
+
props.merge(TYPE => 'string', FORMAT => 'uri')
|
247
268
|
end
|
248
269
|
|
249
270
|
on(::Hash) do |_node, props|
|
data/lib/plumb/match_class.rb
CHANGED
@@ -9,7 +9,7 @@ module Plumb
|
|
9
9
|
attr_reader :children
|
10
10
|
|
11
11
|
def initialize(matcher = Undefined, error: nil, label: nil)
|
12
|
-
raise
|
12
|
+
raise ParseError 'matcher must respond to #===' unless matcher.respond_to?(:===)
|
13
13
|
|
14
14
|
@matcher = matcher
|
15
15
|
@error = error.nil? ? build_error(matcher) : (error % matcher)
|
data/lib/plumb/pipeline.rb
CHANGED
@@ -19,12 +19,28 @@ module Plumb
|
|
19
19
|
end
|
20
20
|
end
|
21
21
|
|
22
|
+
class << self
|
23
|
+
def around_blocks
|
24
|
+
@around_blocks ||= []
|
25
|
+
end
|
26
|
+
|
27
|
+
def around(callable = nil, &block)
|
28
|
+
around_blocks << (callable || block)
|
29
|
+
self
|
30
|
+
end
|
31
|
+
|
32
|
+
def inherited(subclass)
|
33
|
+
around_blocks.each { |block| subclass.around(block) }
|
34
|
+
super
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
22
38
|
attr_reader :children
|
23
39
|
|
24
40
|
def initialize(type = Types::Any, &setup)
|
25
41
|
@type = type
|
26
42
|
@children = [type].freeze
|
27
|
-
@around_blocks =
|
43
|
+
@around_blocks = self.class.around_blocks.dup
|
28
44
|
return unless block_given?
|
29
45
|
|
30
46
|
configure(&setup)
|
@@ -42,7 +58,8 @@ module Plumb
|
|
42
58
|
"#step expects an interface #call(Result) Result, but got #{callable.inspect}"
|
43
59
|
end
|
44
60
|
|
45
|
-
callable =
|
61
|
+
callable = prepare_step(callable)
|
62
|
+
callable = @around_blocks.reverse.reduce(callable) { |cl, bl| AroundStep.new(cl, bl) } if @around_blocks.any?
|
46
63
|
@type >>= callable
|
47
64
|
self
|
48
65
|
end
|
@@ -70,5 +87,7 @@ module Plumb
|
|
70
87
|
|
71
88
|
true
|
72
89
|
end
|
90
|
+
|
91
|
+
def prepare_step(callable) = callable
|
73
92
|
end
|
74
93
|
end
|
data/lib/plumb/tagged_hash.rb
CHANGED
@@ -21,7 +21,7 @@ module Plumb
|
|
21
21
|
# types are assumed to have literal values for the index field :key
|
22
22
|
@index = @children.each.with_object({}) do |t, memo|
|
23
23
|
key_type = t.at_key(@key)
|
24
|
-
raise
|
24
|
+
raise ParseError, "key type at :#{@key} #{key_type} must be a Match type" unless key_type.is_a?(MatchClass)
|
25
25
|
|
26
26
|
memo[key_type.children[0]] = t
|
27
27
|
end
|
data/lib/plumb/types.rb
CHANGED
@@ -1,6 +1,9 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'bigdecimal'
|
4
|
+
require 'uri'
|
5
|
+
require 'date'
|
6
|
+
require 'time'
|
4
7
|
|
5
8
|
module Plumb
|
6
9
|
# Define core policies
|
@@ -104,6 +107,18 @@ module Plumb
|
|
104
107
|
(Types::Undefined >> val_type) | type
|
105
108
|
end
|
106
109
|
|
110
|
+
# Wrap a step execution in a rescue block.
|
111
|
+
# Expect a specific exception class, and return an invalid result if it is raised.
|
112
|
+
# Usage:
|
113
|
+
# type = Types::String.build(Date, :parse).policy(:rescue, Date::Error)
|
114
|
+
policy :rescue do |type, exception_class|
|
115
|
+
Step.new(nil, 'Rescue') do |result|
|
116
|
+
type.call(result)
|
117
|
+
rescue exception_class => e
|
118
|
+
result.invalid(errors: e.message)
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
107
122
|
# Split a string into an array. Default separator is /\s*,\s*/
|
108
123
|
# Usage:
|
109
124
|
# type = Types::String.split
|
@@ -145,6 +160,19 @@ module Plumb
|
|
145
160
|
Tuple = TupleClass.new
|
146
161
|
Hash = HashClass.new
|
147
162
|
Interface = InterfaceClass.new
|
163
|
+
Email = String[URI::MailTo::EMAIL_REGEXP].as_node(:email)
|
164
|
+
Date = Any[::Date]
|
165
|
+
Time = Any[::Time]
|
166
|
+
|
167
|
+
module UUID
|
168
|
+
V4 = String[/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i].as_node(:uuid)
|
169
|
+
end
|
170
|
+
|
171
|
+
module URI
|
172
|
+
Generic = Any[::URI::Generic]
|
173
|
+
HTTP = Any[::URI::HTTP]
|
174
|
+
File = Any[::URI::File]
|
175
|
+
end
|
148
176
|
|
149
177
|
class Data
|
150
178
|
extend Composable
|
@@ -190,6 +218,20 @@ module Plumb
|
|
190
218
|
Boolean = True | False
|
191
219
|
|
192
220
|
Nil = Nil | (String[BLANK_STRING] >> nil)
|
221
|
+
|
222
|
+
# Accept a Date, or a string that can be parsed into a Date
|
223
|
+
# via Date.parse
|
224
|
+
Date = Date | (String >> Any.build(::Date, :parse).policy(:rescue, ::Date::Error))
|
225
|
+
Time = Time | (String >> Any.build(::Time, :parse).policy(:rescue, ::ArgumentError))
|
226
|
+
|
227
|
+
# Turn strings into different URI types
|
228
|
+
module URI
|
229
|
+
# URI.parse is very permisive - a blank string is valid.
|
230
|
+
# We want to ensure that a generic URI at least starts with a scheme as per RFC 3986
|
231
|
+
Generic = Types::URI::Generic | (String[/^([a-z][a-z0-9+\-.]*)/].build(::URI, :parse))
|
232
|
+
HTTP = Generic[::URI::HTTP]
|
233
|
+
File = Generic[::URI::File]
|
234
|
+
end
|
193
235
|
end
|
194
236
|
end
|
195
237
|
end
|
data/lib/plumb/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: plumb
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.6
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Ismael Celis
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-
|
11
|
+
date: 2024-09-04 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: bigdecimal
|
@@ -50,6 +50,10 @@ files:
|
|
50
50
|
- LICENSE.txt
|
51
51
|
- README.md
|
52
52
|
- Rakefile
|
53
|
+
- bench/compare_parametric_schema.rb
|
54
|
+
- bench/compare_parametric_struct.rb
|
55
|
+
- bench/parametric_schema.rb
|
56
|
+
- bench/plumb_hash.rb
|
53
57
|
- examples/command_objects.rb
|
54
58
|
- examples/concurrent_downloads.rb
|
55
59
|
- examples/csv_stream.rb
|