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