plumb 0.0.4 → 0.0.5
Sign up to get free protection for your applications and to get access to all the features.
- 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
|