plumb 0.0.3 → 0.0.5

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