plumb 0.0.4 → 0.0.6

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,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.any?
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
@@ -139,17 +139,8 @@ module Plumb
139
139
  end
140
140
 
141
141
  def wrap_keys_and_values(hash)
142
- case hash
143
- when ::Array
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', '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')
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|
@@ -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,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
@@ -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.6'
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.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-08-05 00:00:00.000000000 Z
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