plumb 0.0.4 → 0.0.5

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