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.
@@ -13,7 +13,7 @@ module Plumb
13
13
  def empty? = true
14
14
  end
15
15
 
16
- TypeError = Class.new(::TypeError)
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 TypeError, result.errors if result.invalid?
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.any?
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', 'format' => 'date-time')
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', 'format' => 'date')
255
+ props.merge(TYPE => 'string', FORMAT => 'date')
247
256
  end
248
257
 
249
258
  on(::Hash) do |_node, props|
@@ -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 TypeError 'matcher must respond to #===' unless matcher.respond_to?(:===)
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)
@@ -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 = @around_blocks.reduce(callable) { |cl, bl| AroundStep.new(cl, bl) } if @around_blocks.any?
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
@@ -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 TypeError, "key type at :#{@key} #{key_type} must be a Match type" unless key_type.is_a?(MatchClass)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Plumb
4
- VERSION = '0.0.4'
4
+ VERSION = '0.0.5'
5
5
  end
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
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-05 00:00:00.000000000 Z
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