plumb 0.0.4 → 0.0.5
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 +223 -10
- 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 +109 -0
- data/examples/event_registry.rb +34 -27
- data/examples/weekdays.rb +1 -1
- data/lib/plumb/attributes.rb +13 -7
- data/lib/plumb/composable.rb +123 -4
- data/lib/plumb/json_schema_visitor.rb +11 -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 +24 -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,6 +97,19 @@ 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
|
+
# Any `#call(Result) => Result` interface is wrapped in a Step.
|
104
|
+
# Anything else is assumed to be something you want to match against via `#===`.
|
105
|
+
#
|
106
|
+
# @example
|
107
|
+
# ten = Composable.wrap(10)
|
108
|
+
# ten.resolve(10) # => Result::Valid
|
109
|
+
# ten.resolve(11) # => Result::Invalid
|
110
|
+
#
|
111
|
+
# @param callable [Object]
|
112
|
+
# @return [Composable]
|
99
113
|
def self.wrap(callable)
|
100
114
|
if callable.is_a?(Composable)
|
101
115
|
callable
|
@@ -108,26 +122,66 @@ module Plumb
|
|
108
122
|
end
|
109
123
|
end
|
110
124
|
|
125
|
+
# A helper to wrap a block in a Step that will defer execution.
|
126
|
+
# This so that types can be used recursively in compositions.
|
127
|
+
# @example
|
128
|
+
# LinkedList = Types::Hash[
|
129
|
+
# value: Types::Any,
|
130
|
+
# next: Types::Any.defer { LinkedList }
|
131
|
+
# ]
|
111
132
|
def defer(definition = nil, &block)
|
112
133
|
Deferred.new(definition || block)
|
113
134
|
end
|
114
135
|
|
136
|
+
# Chain two composable objects together.
|
137
|
+
# A.K.A "and" or "sequence"
|
138
|
+
# @example
|
139
|
+
# Step1 >> Step2 >> Step3
|
140
|
+
#
|
141
|
+
# @param other [Composable]
|
142
|
+
# @return [And]
|
115
143
|
def >>(other)
|
116
144
|
And.new(self, Composable.wrap(other))
|
117
145
|
end
|
118
146
|
|
147
|
+
# Chain two composable objects together as a disjunction ("or").
|
148
|
+
#
|
149
|
+
# @param other [Composable]
|
150
|
+
# @return [Or]
|
119
151
|
def |(other)
|
120
152
|
Or.new(self, Composable.wrap(other))
|
121
153
|
end
|
122
154
|
|
155
|
+
# Transform value. Requires specifying the resulting type of the value after transformation.
|
156
|
+
# @example
|
157
|
+
# Types::String.transform(Types::Symbol, &:to_sym)
|
158
|
+
#
|
159
|
+
# @param target_type [Class] what type this step will transform the value to
|
160
|
+
# @param callable [#call, nil] a callable that will be applied to the value, or nil if block provided
|
161
|
+
# @param block [Proc] a block that will be applied to the value, or nil if callable provided
|
162
|
+
# @return [And]
|
123
163
|
def transform(target_type, callable = nil, &block)
|
124
164
|
self >> Transform.new(target_type, callable || block)
|
125
165
|
end
|
126
166
|
|
167
|
+
# Pass the value through an arbitrary validation
|
168
|
+
# @example
|
169
|
+
# type = Types::String.check('must start with "Role:"') { |value| value.start_with?('Role:') }
|
170
|
+
#
|
171
|
+
# @param errors [String] error message to use when validation fails
|
172
|
+
# @param block [Proc] a block that will be applied to the value
|
173
|
+
# @return [And]
|
127
174
|
def check(errors = 'did not pass the check', &block)
|
128
175
|
self >> MatchClass.new(block, error: errors, label: errors)
|
129
176
|
end
|
130
177
|
|
178
|
+
# Return a new Step with added metadata, or build step metadata if no argument is provided.
|
179
|
+
# @example
|
180
|
+
# type = Types::String.metadata(label: 'Name')
|
181
|
+
# type.metadata # => { type: String, label: 'Name' }
|
182
|
+
#
|
183
|
+
# @param data [Hash] metadata to add to the step
|
184
|
+
# @return [Hash, And]
|
131
185
|
def metadata(data = Undefined)
|
132
186
|
if data == Undefined
|
133
187
|
MetadataVisitor.call(self)
|
@@ -136,24 +190,54 @@ module Plumb
|
|
136
190
|
end
|
137
191
|
end
|
138
192
|
|
193
|
+
# Negate the result of a step.
|
194
|
+
# Ie. if the step is valid, it will be invalid, and vice versa.
|
195
|
+
# @example
|
196
|
+
# type = Types::String.not
|
197
|
+
# type.resolve('foo') # invalid
|
198
|
+
# type.resolve(10) # valid
|
199
|
+
#
|
200
|
+
# @return [Not]
|
139
201
|
def not(other = self)
|
140
202
|
Not.new(other)
|
141
203
|
end
|
142
204
|
|
205
|
+
# Like #not, but with a custom error message.
|
206
|
+
#
|
207
|
+
# @option errors [String] error message to use when validation fails
|
208
|
+
# @return [Not]
|
143
209
|
def invalid(errors: nil)
|
144
210
|
Not.new(self, errors:)
|
145
211
|
end
|
146
212
|
|
213
|
+
# Match a value using `#==`
|
214
|
+
# Normally you'll build matchers via ``#[]`, which uses `#===`.
|
215
|
+
# Use this if you want to match against concrete instances of things that respond to `#===`
|
216
|
+
# @example
|
217
|
+
# regex = Types::Any.value(/foo/)
|
218
|
+
# regex.resolve('foo') # invalid. We're matching against the regex itself.
|
219
|
+
# regex.resolve(/foo/) # valid
|
220
|
+
#
|
221
|
+
# @param value [Object]
|
222
|
+
# @rerurn [And]
|
147
223
|
def value(val)
|
148
224
|
self >> ValueClass.new(val)
|
149
225
|
end
|
150
226
|
|
227
|
+
# Alias of `#[]`
|
228
|
+
# Match a value using `#===`
|
229
|
+
# @example
|
230
|
+
# email = Types::String['@']
|
231
|
+
#
|
232
|
+
# @param args [Array<Object>]
|
233
|
+
# @return [And]
|
151
234
|
def match(*args)
|
152
235
|
self >> MatchClass.new(*args)
|
153
236
|
end
|
154
237
|
|
155
238
|
def [](val) = match(val)
|
156
239
|
|
240
|
+
# Support #as_node.
|
157
241
|
class Node
|
158
242
|
include Composable
|
159
243
|
|
@@ -169,6 +253,14 @@ module Plumb
|
|
169
253
|
def call(result) = type.call(result)
|
170
254
|
end
|
171
255
|
|
256
|
+
# Wrap a Step in a node with a custom #node_name
|
257
|
+
# which is expected by visitors.
|
258
|
+
# So that we can define special visitors for certain compositions.
|
259
|
+
# Ex. Types::Boolean is a compoition of Types::True | Types::False, but we want to treat it as a single node.
|
260
|
+
#
|
261
|
+
# @param node_name [Symbol]
|
262
|
+
# @param metadata [Hash]
|
263
|
+
# @return [Node]
|
172
264
|
def as_node(node_name, metadata = BLANK_HASH)
|
173
265
|
Node.new(node_name, self, metadata)
|
174
266
|
end
|
@@ -186,7 +278,7 @@ module Plumb
|
|
186
278
|
|
187
279
|
bargs = [self]
|
188
280
|
arg = Undefined
|
189
|
-
if rest.
|
281
|
+
if rest.size.positive?
|
190
282
|
bargs << rest.first
|
191
283
|
arg = rest.first
|
192
284
|
end
|
@@ -201,6 +293,9 @@ module Plumb
|
|
201
293
|
end
|
202
294
|
end
|
203
295
|
|
296
|
+
# `#===` equality. So that Plumb steps can be used in case statements and pattern matching.
|
297
|
+
# @param other [Object]
|
298
|
+
# @return [Boolean]
|
204
299
|
def ===(other)
|
205
300
|
case other
|
206
301
|
when Composable
|
@@ -211,15 +306,39 @@ module Plumb
|
|
211
306
|
end
|
212
307
|
|
213
308
|
def ==(other)
|
214
|
-
other.is_a?(self.class) && other.children == children
|
309
|
+
other.is_a?(self.class) && other.respond_to?(:children) && other.children == children
|
215
310
|
end
|
216
311
|
|
312
|
+
# Visitors expect a #node_name and #children interface.
|
313
|
+
# @return [Array<Composable>]
|
217
314
|
def children = BLANK_ARRAY
|
218
315
|
|
316
|
+
# Compose a step that instantiates a class.
|
317
|
+
# @example
|
318
|
+
# type = Types::String.build(MyClass, :new)
|
319
|
+
# thing = type.parse('foo') # same as MyClass.new('foo')
|
320
|
+
#
|
321
|
+
# It sets the class as the output type of the step.
|
322
|
+
# Optionally takes a block.
|
323
|
+
#
|
324
|
+
# type = Types::String.build(Money) { |value| Monetize.parse(value) }
|
325
|
+
#
|
326
|
+
# @param cns [Class] constructor class or object.
|
327
|
+
# @param factory_method [Symbol] method to call on the class to instantiate it.
|
328
|
+
# @return [And]
|
219
329
|
def build(cns, factory_method = :new, &block)
|
220
330
|
self >> Build.new(cns, factory_method:, &block)
|
221
331
|
end
|
222
332
|
|
333
|
+
# Build a Plumb::Pipeline with this object as the starting step.
|
334
|
+
# @example
|
335
|
+
# pipe = Types::Data[name: String].pipeline do |pl|
|
336
|
+
# pl.step Validate
|
337
|
+
# pl.step Debug
|
338
|
+
# pl.step Log
|
339
|
+
# end
|
340
|
+
#
|
341
|
+
# @return [Pipeline]
|
223
342
|
def pipeline(&block)
|
224
343
|
Pipeline.new(self, &block)
|
225
344
|
end
|
@@ -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,11 @@ 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')
|
247
256
|
end
|
248
257
|
|
249
258
|
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,8 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'bigdecimal'
|
4
|
+
require 'uri'
|
5
|
+
require 'date'
|
4
6
|
|
5
7
|
module Plumb
|
6
8
|
# Define core policies
|
@@ -104,6 +106,18 @@ module Plumb
|
|
104
106
|
(Types::Undefined >> val_type) | type
|
105
107
|
end
|
106
108
|
|
109
|
+
# Wrap a step execution in a rescue block.
|
110
|
+
# Expect a specific exception class, and return an invalid result if it is raised.
|
111
|
+
# Usage:
|
112
|
+
# type = Types::String.build(Date, :parse).policy(:rescue, Date::Error)
|
113
|
+
policy :rescue do |type, exception_class|
|
114
|
+
Step.new(nil, 'Rescue') do |result|
|
115
|
+
type.call(result)
|
116
|
+
rescue exception_class => e
|
117
|
+
result.invalid(errors: e.message)
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
107
121
|
# Split a string into an array. Default separator is /\s*,\s*/
|
108
122
|
# Usage:
|
109
123
|
# type = Types::String.split
|
@@ -145,6 +159,12 @@ module Plumb
|
|
145
159
|
Tuple = TupleClass.new
|
146
160
|
Hash = HashClass.new
|
147
161
|
Interface = InterfaceClass.new
|
162
|
+
Email = String[URI::MailTo::EMAIL_REGEXP].as_node(:email)
|
163
|
+
Date = Any[::Date]
|
164
|
+
|
165
|
+
module UUID
|
166
|
+
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)
|
167
|
+
end
|
148
168
|
|
149
169
|
class Data
|
150
170
|
extend Composable
|
@@ -190,6 +210,10 @@ module Plumb
|
|
190
210
|
Boolean = True | False
|
191
211
|
|
192
212
|
Nil = Nil | (String[BLANK_STRING] >> nil)
|
213
|
+
|
214
|
+
# Accept a Date, or a string that can be parsed into a Date
|
215
|
+
# via Date.parse
|
216
|
+
Date = Date | (String >> Any.build(::Date, :parse).policy(:rescue, ::Date::Error))
|
193
217
|
end
|
194
218
|
end
|
195
219
|
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.5
|
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-08-
|
11
|
+
date: 2024-08-30 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
|