plumb 0.0.3 → 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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +609 -57
  3. data/bench/compare_parametric_schema.rb +102 -0
  4. data/bench/compare_parametric_struct.rb +68 -0
  5. data/bench/parametric_schema.rb +229 -0
  6. data/bench/plumb_hash.rb +109 -0
  7. data/examples/concurrent_downloads.rb +3 -3
  8. data/examples/env_config.rb +2 -2
  9. data/examples/event_registry.rb +127 -0
  10. data/examples/weekdays.rb +1 -1
  11. data/lib/plumb/and.rb +4 -3
  12. data/lib/plumb/any_class.rb +4 -4
  13. data/lib/plumb/array_class.rb +8 -5
  14. data/lib/plumb/attributes.rb +268 -0
  15. data/lib/plumb/build.rb +4 -3
  16. data/lib/plumb/composable.rb +381 -0
  17. data/lib/plumb/decorator.rb +57 -0
  18. data/lib/plumb/deferred.rb +1 -1
  19. data/lib/plumb/hash_class.rb +19 -8
  20. data/lib/plumb/hash_map.rb +8 -6
  21. data/lib/plumb/interface_class.rb +6 -2
  22. data/lib/plumb/json_schema_visitor.rb +59 -32
  23. data/lib/plumb/match_class.rb +5 -4
  24. data/lib/plumb/metadata.rb +5 -1
  25. data/lib/plumb/metadata_visitor.rb +13 -42
  26. data/lib/plumb/not.rb +4 -3
  27. data/lib/plumb/or.rb +10 -4
  28. data/lib/plumb/pipeline.rb +27 -7
  29. data/lib/plumb/policy.rb +10 -3
  30. data/lib/plumb/schema.rb +11 -10
  31. data/lib/plumb/static_class.rb +4 -3
  32. data/lib/plumb/step.rb +4 -3
  33. data/lib/plumb/stream_class.rb +8 -7
  34. data/lib/plumb/tagged_hash.rb +11 -11
  35. data/lib/plumb/transform.rb +4 -3
  36. data/lib/plumb/tuple_class.rb +8 -8
  37. data/lib/plumb/type_registry.rb +5 -2
  38. data/lib/plumb/types.rb +30 -1
  39. data/lib/plumb/value_class.rb +4 -3
  40. data/lib/plumb/version.rb +1 -1
  41. data/lib/plumb/visitor_handlers.rb +6 -0
  42. data/lib/plumb.rb +11 -5
  43. metadata +10 -3
  44. data/lib/plumb/steppable.rb +0 -229
@@ -0,0 +1,381 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb/metadata_visitor'
4
+
5
+ module Plumb
6
+ class UndefinedClass
7
+ def inspect
8
+ %(Undefined)
9
+ end
10
+
11
+ def to_s = inspect
12
+ def node_name = :undefined
13
+ def empty? = true
14
+ end
15
+
16
+ ParseError = Class.new(::TypeError)
17
+ Undefined = UndefinedClass.new.freeze
18
+
19
+ BLANK_STRING = ''
20
+ BLANK_ARRAY = [].freeze
21
+ BLANK_HASH = {}.freeze
22
+ BLANK_RESULT = Result.wrap(Undefined)
23
+ NOOP = ->(result) { result }
24
+
25
+ module Callable
26
+ def resolve(value = Undefined)
27
+ call(Result.wrap(value))
28
+ end
29
+
30
+ def parse(value = Undefined)
31
+ result = resolve(value)
32
+ raise ParseError, result.errors if result.invalid?
33
+
34
+ result.value
35
+ end
36
+
37
+ def call(result)
38
+ raise NotImplementedError, "Implement #call(Result) => Result in #{self.class}"
39
+ end
40
+ end
41
+
42
+ # This module gets included by Composable,
43
+ # but only when Composable is `included` in classes, not `extended`.
44
+ # The rule of this module is to assign a name to constants that point to Composable instances.
45
+ module Naming
46
+ attr_reader :name
47
+
48
+ # When including this module,
49
+ # define a #node_name method on the Composable instance
50
+ # #node_name is used by Visitors to determine the type of node.
51
+ def self.included(base)
52
+ nname = base.name.split('::').last
53
+ nname.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
54
+ nname.downcase!
55
+ nname.gsub!(/_class$/, '')
56
+ nname = nname.to_sym
57
+ base.define_method(:node_name) { nname }
58
+ end
59
+
60
+ class Name
61
+ def initialize(name)
62
+ @name = name
63
+ end
64
+
65
+ def to_s = @name
66
+
67
+ def set(n)
68
+ @name = n
69
+ self
70
+ end
71
+ end
72
+
73
+ def freeze
74
+ return self if frozen?
75
+
76
+ @name = Name.new(_inspect)
77
+ super
78
+ end
79
+
80
+ private def _inspect = self.class.name
81
+
82
+ def inspect = name.to_s
83
+
84
+ def node_name = self.class.name.split('::').last.to_sym
85
+ end
86
+
87
+ #  Composable mixes in composition methods to classes.
88
+ # such as #>>, #|, #not, and others.
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`
91
+ module Composable
92
+ include Callable
93
+
94
+ # This only runs when including Composable,
95
+ # not extending classes with it.
96
+ def self.included(base)
97
+ base.send(:include, Naming)
98
+ end
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]
113
+ def self.wrap(callable)
114
+ if callable.is_a?(Composable)
115
+ callable
116
+ elsif callable.is_a?(::Hash)
117
+ HashClass.new(schema: callable)
118
+ elsif callable.respond_to?(:call)
119
+ Step.new(callable)
120
+ else
121
+ MatchClass.new(callable)
122
+ end
123
+ end
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
+ # ]
132
+ def defer(definition = nil, &block)
133
+ Deferred.new(definition || block)
134
+ end
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]
143
+ def >>(other)
144
+ And.new(self, Composable.wrap(other))
145
+ end
146
+
147
+ # Chain two composable objects together as a disjunction ("or").
148
+ #
149
+ # @param other [Composable]
150
+ # @return [Or]
151
+ def |(other)
152
+ Or.new(self, Composable.wrap(other))
153
+ end
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]
163
+ def transform(target_type, callable = nil, &block)
164
+ self >> Transform.new(target_type, callable || block)
165
+ end
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]
174
+ def check(errors = 'did not pass the check', &block)
175
+ self >> MatchClass.new(block, error: errors, label: errors)
176
+ end
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]
185
+ def metadata(data = Undefined)
186
+ if data == Undefined
187
+ MetadataVisitor.call(self)
188
+ else
189
+ self >> Metadata.new(data)
190
+ end
191
+ end
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]
201
+ def not(other = self)
202
+ Not.new(other)
203
+ end
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]
209
+ def invalid(errors: nil)
210
+ Not.new(self, errors:)
211
+ end
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]
223
+ def value(val)
224
+ self >> ValueClass.new(val)
225
+ end
226
+
227
+ # Alias of `#[]`
228
+ # Match a value using `#===`
229
+ # @example
230
+ # email = Types::String['@']
231
+ #
232
+ # @param args [Array<Object>]
233
+ # @return [And]
234
+ def match(*args)
235
+ self >> MatchClass.new(*args)
236
+ end
237
+
238
+ def [](val) = match(val)
239
+
240
+ #  Support #as_node.
241
+ class Node
242
+ include Composable
243
+
244
+ attr_reader :node_name, :type, :attributes
245
+
246
+ def initialize(node_name, type, attributes = BLANK_HASH)
247
+ @node_name = node_name
248
+ @type = type
249
+ @attributes = attributes
250
+ freeze
251
+ end
252
+
253
+ def call(result) = type.call(result)
254
+ end
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]
264
+ def as_node(node_name, metadata = BLANK_HASH)
265
+ Node.new(node_name, self, metadata)
266
+ end
267
+
268
+ # Register a policy for this step.
269
+ # Mode 1.a: #policy(:name, arg) a single policy with an argument
270
+ # Mode 1.b: #policy(:name) a single policy without an argument
271
+ # Mode 2: #policy(p1: value, p2: value) multiple policies with arguments
272
+ # The latter mode will be expanded to multiple #policy calls.
273
+ # @return [Step]
274
+ def policy(*args, &blk)
275
+ case args
276
+ in [::Symbol => name, *rest] # #policy(:name, arg)
277
+ types = Array(metadata[:type]).uniq
278
+
279
+ bargs = [self]
280
+ arg = Undefined
281
+ if rest.size.positive?
282
+ bargs << rest.first
283
+ arg = rest.first
284
+ end
285
+ block = Plumb.policies.get(types, name)
286
+ pol = block.call(*bargs, &blk)
287
+
288
+ Policy.new(name, arg, pol)
289
+ in [::Hash => opts] # #policy(p1: value, p2: value)
290
+ opts.reduce(self) { |step, (name, value)| step.policy(name, value) }
291
+ else
292
+ raise ArgumentError, "expected a symbol or hash, got #{args.inspect}"
293
+ end
294
+ end
295
+
296
+ # `#===` equality. So that Plumb steps can be used in case statements and pattern matching.
297
+ # @param other [Object]
298
+ # @return [Boolean]
299
+ def ===(other)
300
+ case other
301
+ when Composable
302
+ other == self
303
+ else
304
+ resolve(other).valid?
305
+ end
306
+ end
307
+
308
+ def ==(other)
309
+ other.is_a?(self.class) && other.respond_to?(:children) && other.children == children
310
+ end
311
+
312
+ # Visitors expect a #node_name and #children interface.
313
+ # @return [Array<Composable>]
314
+ def children = BLANK_ARRAY
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]
329
+ def build(cns, factory_method = :new, &block)
330
+ self >> Build.new(cns, factory_method:, &block)
331
+ end
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]
342
+ def pipeline(&block)
343
+ Pipeline.new(self, &block)
344
+ end
345
+
346
+ def to_s
347
+ inspect
348
+ end
349
+
350
+ # @option root [Boolean] whether to include JSON Schema $schema property
351
+ # @return [Hash]
352
+ def to_json_schema(root: false)
353
+ JSONSchemaVisitor.call(self, root:)
354
+ end
355
+
356
+ # Build a step that will invoke one or more methods on the value.
357
+ # Ex 1: Types::String.invoke(:downcase)
358
+ # Ex 2: Types::Array.invoke(:[], 1)
359
+ # Ex 3 chain of methods: Types::String.invoke([:downcase, :to_sym])
360
+ # @return [Step]
361
+ def invoke(*args, &block)
362
+ case args
363
+ in [::Symbol => method_name, *rest]
364
+ self >> Step.new(
365
+ ->(result) { result.valid(result.value.public_send(method_name, *rest, &block)) },
366
+ [method_name.inspect, rest.inspect].join(' ')
367
+ )
368
+ in [Array => methods] if methods.all? { |m| m.is_a?(Symbol) }
369
+ methods.reduce(self) { |step, method| step.invoke(method) }
370
+ else
371
+ raise ArgumentError, "expected a symbol or array of symbols, got #{args.inspect}"
372
+ end
373
+ end
374
+ end
375
+ end
376
+
377
+ require 'plumb/deferred'
378
+ require 'plumb/transform'
379
+ require 'plumb/policy'
380
+ require 'plumb/build'
381
+ require 'plumb/metadata'
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plumb
4
+ # A class to help decorate all or some types in a
5
+ # type composition.
6
+ # Example:
7
+ # Type = Types::Array[Types::String | Types::Integer]
8
+ # Decorated = Plumb::Decorator.(Type) do |type|
9
+ # if type.is_a?(Plumb::ArrayClass)
10
+ # LoggerType.new(type, 'array')
11
+ # else
12
+ # type
13
+ # end
14
+ # end
15
+ class Decorator
16
+ def self.call(type, &block)
17
+ new(block).visit(type)
18
+ end
19
+
20
+ def initialize(block)
21
+ @block = block
22
+ end
23
+
24
+ # @param type [Composable]
25
+ # @return [Composable]
26
+ def visit(type)
27
+ type = case type
28
+ when And
29
+ left, right = visit_children(type)
30
+ And.new(left, right)
31
+ when Or
32
+ left, right = visit_children(type)
33
+ Or.new(left, right)
34
+ when Not
35
+ child = visit_children(type).first
36
+ Not.new(child, errors: type.errors)
37
+ when Policy
38
+ child = visit_children(type).first
39
+ Policy.new(type.policy_name, type.arg, child)
40
+ else
41
+ type
42
+ end
43
+
44
+ decorate(type)
45
+ end
46
+
47
+ private
48
+
49
+ def visit_children(type)
50
+ type.children.map { |child| visit(child) }
51
+ end
52
+
53
+ def decorate(type)
54
+ @block.call(type)
55
+ end
56
+ end
57
+ end
@@ -4,7 +4,7 @@ require 'thread'
4
4
 
5
5
  module Plumb
6
6
  class Deferred
7
- include Steppable
7
+ include Composable
8
8
 
9
9
  def initialize(definition)
10
10
  @lock = Mutex.new
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'plumb/steppable'
3
+ require 'plumb/composable'
4
4
  require 'plumb/key'
5
5
  require 'plumb/static_class'
6
6
  require 'plumb/hash_map'
@@ -8,7 +8,9 @@ require 'plumb/tagged_hash'
8
8
 
9
9
  module Plumb
10
10
  class HashClass
11
- include Steppable
11
+ include Composable
12
+
13
+ NOT_A_HASH = { _: 'must be a Hash' }.freeze
12
14
 
13
15
  attr_reader :_schema
14
16
 
@@ -31,7 +33,7 @@ module Plumb
31
33
  in [::Hash => hash]
32
34
  self.class.new(schema: _schema.merge(wrap_keys_and_values(hash)), inclusive: @inclusive)
33
35
  in [key_type, value_type]
34
- HashMap.new(Steppable.wrap(key_type), Steppable.wrap(value_type))
36
+ HashMap.new(Composable.wrap(key_type), Composable.wrap(value_type))
35
37
  else
36
38
  raise ::ArgumentError, "unexpected value to Types::Hash#schema #{args.inspect}"
37
39
  end
@@ -44,9 +46,14 @@ module Plumb
44
46
  # we need to keep the right-side key, because even if the key name is the same,
45
47
  # it's optional flag might have changed
46
48
  def +(other)
47
- raise ArgumentError, "expected a HashClass, got #{other.class}" unless other.is_a?(HashClass)
48
-
49
- self.class.new(schema: merge_rightmost_keys(_schema, other._schema), inclusive: @inclusive)
49
+ other_schema = case other
50
+ when HashClass then other._schema
51
+ when ::Hash then other
52
+ else
53
+ raise ArgumentError, "expected a HashClass or Hash, got #{other.class}"
54
+ end
55
+
56
+ self.class.new(schema: merge_rightmost_keys(_schema, other_schema), inclusive: @inclusive)
50
57
  end
51
58
 
52
59
  def &(other)
@@ -97,7 +104,7 @@ module Plumb
97
104
  end
98
105
 
99
106
  def call(result)
100
- return result.invalid(errors: 'must be a Hash') unless result.value.is_a?(::Hash)
107
+ return result.invalid(errors: NOT_A_HASH) unless result.value.is_a?(::Hash)
101
108
  return result unless _schema.any?
102
109
 
103
110
  input = result.value
@@ -121,6 +128,10 @@ module Plumb
121
128
  errors.any? ? result.invalid(output, errors:) : result.valid(output)
122
129
  end
123
130
 
131
+ def ==(other)
132
+ other.is_a?(self.class) && other._schema == _schema
133
+ end
134
+
124
135
  private
125
136
 
126
137
  def _inspect
@@ -138,7 +149,7 @@ module Plumb
138
149
  when Callable
139
150
  hash
140
151
  else #  leaf values
141
- Steppable.wrap(hash)
152
+ Composable.wrap(hash)
142
153
  end
143
154
  end
144
155
 
@@ -1,16 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'plumb/steppable'
3
+ require 'plumb/composable'
4
4
 
5
5
  module Plumb
6
6
  class HashMap
7
- include Steppable
7
+ include Composable
8
8
 
9
- attr_reader :key_type, :value_type
9
+ attr_reader :children
10
10
 
11
11
  def initialize(key_type, value_type)
12
12
  @key_type = key_type
13
13
  @value_type = value_type
14
+ @children = [key_type, value_type].freeze
14
15
  freeze
15
16
  end
16
17
 
@@ -35,19 +36,20 @@ module Plumb
35
36
  end
36
37
 
37
38
  def filtered
38
- FilteredHashMap.new(key_type, value_type)
39
+ FilteredHashMap.new(@key_type, @value_type)
39
40
  end
40
41
 
41
42
  private def _inspect = "HashMap[#{@key_type.inspect}, #{@value_type.inspect}]"
42
43
 
43
44
  class FilteredHashMap
44
- include Steppable
45
+ include Composable
45
46
 
46
- attr_reader :key_type, :value_type
47
+ attr_reader :children
47
48
 
48
49
  def initialize(key_type, value_type)
49
50
  @key_type = key_type
50
51
  @value_type = value_type
52
+ @children = [key_type, value_type].freeze
51
53
  freeze
52
54
  end
53
55
 
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'plumb/steppable'
3
+ require 'plumb/composable'
4
4
 
5
5
  module Plumb
6
6
  class InterfaceClass
7
- include Steppable
7
+ include Composable
8
8
 
9
9
  attr_reader :method_names
10
10
 
@@ -13,6 +13,10 @@ module Plumb
13
13
  freeze
14
14
  end
15
15
 
16
+ def ==(other)
17
+ other.is_a?(self.class) && other.method_names == method_names
18
+ end
19
+
16
20
  def of(*args)
17
21
  case args
18
22
  in Array => symbols if symbols.all? { |s| s.is_a?(::Symbol) }