plumb 0.0.4 → 0.0.6

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,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