plumb 0.0.18 → 0.2.0.beta.2

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +887 -64
  3. data/bench/compare_dry_schema.rb +79 -0
  4. data/bench/compare_dry_types.rb +37 -0
  5. data/bench/compare_parametric_schema.rb +2 -80
  6. data/bench/dry_schema_hash.rb +103 -0
  7. data/bench/dry_types_hash.rb +125 -0
  8. data/bench/json_schema_profile.rb +107 -0
  9. data/bench/plumb_hash.rb +17 -11
  10. data/bench/results_allocations.rb +137 -0
  11. data/bench/sample_data.rb +78 -0
  12. data/examples/command_objects.rb +1 -1
  13. data/examples/concurrent_downloads.rb +16 -9
  14. data/examples/event_registry.rb +6 -1
  15. data/examples/weekdays.rb +1 -1
  16. data/lib/plumb/and.rb +63 -6
  17. data/lib/plumb/any_class.rb +12 -2
  18. data/lib/plumb/array_class.rb +133 -25
  19. data/lib/plumb/attribute_value_match.rb +41 -1
  20. data/lib/plumb/attributes.rb +59 -19
  21. data/lib/plumb/codec.rb +886 -0
  22. data/lib/plumb/composable.rb +451 -39
  23. data/lib/plumb/conjunction.rb +50 -0
  24. data/lib/plumb/constraint.rb +234 -0
  25. data/lib/plumb/covariant_fusion.rb +46 -0
  26. data/lib/plumb/decorator.rb +12 -22
  27. data/lib/plumb/deferred.rb +13 -5
  28. data/lib/plumb/disjunction.rb +112 -0
  29. data/lib/plumb/encoder.rb +207 -0
  30. data/lib/plumb/function.rb +347 -0
  31. data/lib/plumb/hash_class.rb +339 -32
  32. data/lib/plumb/hash_map.rb +58 -14
  33. data/lib/plumb/implementation.rb +247 -0
  34. data/lib/plumb/interface_class.rb +21 -2
  35. data/lib/plumb/intersection.rb +47 -0
  36. data/lib/plumb/json_schema_visitor.rb +255 -36
  37. data/lib/plumb/key.rb +63 -13
  38. data/lib/plumb/mermaid_visitor.rb +129 -0
  39. data/lib/plumb/metadata.rb +10 -1
  40. data/lib/plumb/metadata_visitor.rb +36 -34
  41. data/lib/plumb/never_class.rb +38 -0
  42. data/lib/plumb/node_mapper.rb +97 -0
  43. data/lib/plumb/not.rb +34 -2
  44. data/lib/plumb/optimizer.rb +444 -0
  45. data/lib/plumb/or.rb +26 -29
  46. data/lib/plumb/pipeline.rb +99 -11
  47. data/lib/plumb/policy.rb +17 -4
  48. data/lib/plumb/range_class.rb +46 -0
  49. data/lib/plumb/relation.rb +57 -0
  50. data/lib/plumb/result.rb +55 -23
  51. data/lib/plumb/semantic_matcher.rb +393 -0
  52. data/lib/plumb/static_class.rb +20 -1
  53. data/lib/plumb/stream_class.rb +28 -6
  54. data/lib/plumb/subtyping.rb +461 -0
  55. data/lib/plumb/tagged_hash.rb +45 -4
  56. data/lib/plumb/tuple_class.rb +21 -4
  57. data/lib/plumb/type_cache.rb +41 -0
  58. data/lib/plumb/type_registry.rb +71 -0
  59. data/lib/plumb/typed_step.rb +67 -0
  60. data/lib/plumb/types.rb +44 -43
  61. data/lib/plumb/union.rb +30 -0
  62. data/lib/plumb/value_class.rb +20 -1
  63. data/lib/plumb/version.rb +1 -1
  64. data/lib/plumb/visitor_handlers.rb +20 -4
  65. data/lib/plumb.rb +90 -3
  66. metadata +30 -8
  67. data/lib/plumb/build.rb +0 -22
  68. data/lib/plumb/match_class.rb +0 -42
  69. data/lib/plumb/schema.rb +0 -195
  70. data/lib/plumb/step.rb +0 -27
  71. data/lib/plumb/transform.rb +0 -26
@@ -3,6 +3,10 @@
3
3
  require 'plumb/visitor_handlers'
4
4
 
5
5
  module Plumb
6
+ # Collects user-provided metadata for a type (via #metadata, custom step
7
+ # metadata, and policy arguments). Any type-bound information (the expected
8
+ # Ruby types, patterns, ranges, static values, etc.) is intentionally NOT
9
+ # collected here — it is described by #input_type / #output_type instead.
6
10
  class MetadataVisitor
7
11
  include VisitorHandlers
8
12
 
@@ -11,8 +15,6 @@ module Plumb
11
15
  end
12
16
 
13
17
  def on_missing_handler(node, props, _method_name)
14
- return props.merge(type: node) if node.instance_of?(Class)
15
-
16
18
  return props unless node.respond_to?(:children)
17
19
 
18
20
  node.children.reduce(props) do |acc, child|
@@ -20,44 +22,44 @@ module Plumb
20
22
  end
21
23
  end
22
24
 
23
- on(:step) do |node, props|
24
- props.merge(node._metadata)
25
- end
26
-
27
- on(::Regexp) do |node, props|
28
- props.merge(pattern: node, type: props[:type] || String)
25
+ on(:hash) do |_node, props|
26
+ props
29
27
  end
30
28
 
31
- on(::Range) do |node, props|
32
- type = props[:type] || (node.begin || node.end).class
33
- props.merge(match: node, type:)
29
+ on(:never) do |_node, props|
30
+ props
34
31
  end
35
32
 
36
- on(:hash) do |_node, props|
37
- props.merge(type: Hash)
33
+ # A refinement matcher carries its base; any user metadata lives on the base
34
+ # (the matcher itself is type-bound info we don't collect), so follow it.
35
+ on(:constraint) do |node, props|
36
+ node.base ? visit(node.base, props) : props
38
37
  end
39
38
 
39
+ # This visitor doesn't reason about types, so the Conjunction/Disjunction split
40
+ # makes no difference to it: :intersection and :union reach these same handlers
41
+ # through VisitorHandlers::NODE_NAME_FALLBACKS. Registering duplicate bodies
42
+ # would be a second mechanism for one job, and the copies could drift.
40
43
  on(:and) do |node, props|
41
44
  left, right = node.children.map { |child| visit(child) }
42
- type = right[:type] || left[:type]
43
- props = props.merge(left).merge(right)
44
- props = props.merge(type:) if type
45
- props
45
+ props.merge(left).merge(right)
46
46
  end
47
47
 
48
- on(:or) do |node, props|
49
- child_metas = node.children.map { |child| visit(child) }
50
- types = child_metas.map { |child| child[:type] }.flatten.compact
51
- types = types.first if types.size == 1
52
- child_metas.reduce(props) do |acc, child|
53
- acc.merge(child)
54
- end.merge(type: types)
48
+ # A factored union is transparent for metadata — its wrapped And carries the
49
+ # base + disjunction, so delegate to it.
50
+ on(:refined_union) do |node, props|
51
+ visit(node.type, props)
55
52
  end
56
53
 
57
- on(:static) do |node, props|
58
- value = node.children[0]
59
- type = value.is_a?(Class) ? value : value.class
60
- props.merge(static: value, type:)
54
+ on(:function) do |node, props|
55
+ left, right = node.children.map { |child| visit(child) }
56
+ props.merge(left).merge(right)
57
+ end
58
+
59
+ on(:or) do |node, props|
60
+ node.children
61
+ .map { |child| visit(child) }
62
+ .reduce(props) { |acc, child| acc.merge(child) }
61
63
  end
62
64
 
63
65
  on(:policy) do |node, props|
@@ -67,7 +69,7 @@ module Plumb
67
69
  end
68
70
 
69
71
  on(:boolean) do |_node, props|
70
- props.merge(type: 'boolean')
72
+ props
71
73
  end
72
74
 
73
75
  on(:metadata) do |node, props|
@@ -76,23 +78,23 @@ module Plumb
76
78
  end
77
79
 
78
80
  on(:hash_map) do |_node, props|
79
- props.merge(type: Hash)
81
+ props
80
82
  end
81
83
 
82
84
  on(:array) do |_node, props|
83
- props.merge(type: Array)
85
+ props
84
86
  end
85
87
 
86
88
  on(:stream) do |_node, props|
87
- props.merge(type: Enumerator)
89
+ props
88
90
  end
89
91
 
90
92
  on(:tuple) do |_node, props|
91
- props.merge(type: Array)
93
+ props
92
94
  end
93
95
 
94
96
  on(:tagged_hash) do |_node, props|
95
- props.merge(type: Hash)
97
+ props
96
98
  end
97
99
 
98
100
  on(Proc) do |_node, props|
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb/composable'
4
+
5
+ module Plumb
6
+ # The bottom type — the dual of AnyClass. No value inhabits it, so it always
7
+ # invalidates. It is produced by a provably-empty intersection (see
8
+ # Composable#& / Subtyping.intersect) and reduces at composition time:
9
+ # A & Never == Never (Never dominates the meet)
10
+ # A | Never == A (Never is absorbed by the join)
11
+ # Never >> A == Never
12
+ # As a subtype, Never is below everything: `Never <= X` for all X (its
13
+ # #subtype_of? hook always says yes), and nothing but Never is <= Never.
14
+ class NeverClass
15
+ include Composable
16
+
17
+ # Never dominates intersection and short-circuits sequencing/narrowing.
18
+ def &(_other) = self
19
+ def >>(_other) = self
20
+ def /(_other) = self
21
+
22
+ # Never is the identity of union: `Never | X == X`. Route through the hook
23
+ # (not bare Composable.wrap) so a context-resolving operand — an Encoder
24
+ # orients, a Codec raises at composition — is handled like every other #|
25
+ # rather than leaking in as an unresolved node.
26
+ def |(other) = Composable.resolve_operand(other, op: :|, left: self)
27
+
28
+ def call(result) = result.invalid!(errors: 'no value is allowed (Never)')
29
+
30
+ # Bottom is a subtype of every type. The mirror direction (`X <= Never`) is
31
+ # left to the default leaf hook, which is true only reflexively (X == Never).
32
+ def subtype_of?(_other) = true
33
+
34
+ # It never succeeds, so it never changes a value — value-preserving like Any,
35
+ # which keeps union absorption uniform.
36
+ def value_preserving? = true
37
+ end
38
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plumb
4
+ # THE STRUCTURAL REWRITE PRIMITIVE: map a node's Composable sub-types through a
5
+ # block and rebuild the node around the results — or return the ORIGINAL node
6
+ # when no sub-type changed.
7
+ #
8
+ # The identity guard is why this belongs in one place: every rewriting pass depends
9
+ # on an untouched subtree coming back `equal?` — so a caller can tell "nothing
10
+ # happened" from "rebuilt identically", and a no-op pass allocates nothing — and it
11
+ # must hold uniformly across a dozen node shapes. {Plumb::Decorator} is the
12
+ # cautionary tale: it grew its own `case` over five node types and silently recursed
13
+ # into no container or wrapper, so a block never saw a schema's fields.
14
+ #
15
+ # Each node contributes only its own rebuild, via the one-line #with_children hook
16
+ # where its #children already ARE its sub-types. Nodes whose sub-types live
17
+ # elsewhere — a Metadata's #type, a record's keyed fields — are handled below.
18
+ #
19
+ # NOT EXHAUSTIVE, deliberately for now: `map` dispatches on a closed class list, so
20
+ # a composite it does not name (`Plumb::Implementation`, or a user-defined one) is
21
+ # returned untouched. Generalizing means a `#map_subtypes(&blk)` hook on each node —
22
+ # the four branches below are the cases a plain `#with_children(array)` cannot
23
+ # express. Worth doing when a third pass needs it.
24
+ #
25
+ # DELIBERATELY NOT MAPPED, both matching what every existing pass already does:
26
+ #
27
+ # - Constraint's #base. A Constraint carries an error message and a label that
28
+ # it does not expose, so rebuilding one would silently drop a custom
29
+ # `#check('must start with a')` message.
30
+ # - Deferred. Forcing it to recurse would loop forever on a self-referential
31
+ # type. A pass that wants to rewrite through one must tie the knot itself
32
+ # with its own memo, as Codec::Rewriter does.
33
+ #
34
+ # @example
35
+ # NodeMapper.map(type) { |child| rewrite(child) }
36
+ module NodeMapper
37
+ module_function
38
+
39
+ # @param type [Composable]
40
+ # @yieldparam child [Composable] a Composable sub-type of `type`
41
+ # @return [Composable] `type` itself when nothing changed, else a rebuilt node
42
+ def map(type, &blk)
43
+ # Leaf fast path: a Constraint is the most numerous node in any real graph and
44
+ # is deliberately not mapped (see above), so answer it before the class tests.
45
+ return type if type.is_a?(Constraint) || !type.respond_to?(:children)
46
+
47
+ case type
48
+ # Nodes whose #children ARE their sub-types: each contributes its own
49
+ # #with_children rebuild and this owns the traversal + identity guard.
50
+ when Conjunction, Disjunction, Function, TupleClass, ArrayClass, HashMap, StreamClass,
51
+ Not, Policy, TaggedHash
52
+ map_children(type, &blk)
53
+ # Transparent wrappers: the sub-type is #type, not a child.
54
+ when Metadata
55
+ rewrap(type, type.type, blk) { |t| Metadata.new(t, type.metadata) }
56
+ when Composable::Node
57
+ rewrap(type, type.type, blk) { |t| t.as_node(type.node_name, type.args) }
58
+ # A record's sub-types are its keyed fields.
59
+ when HashClass
60
+ map_record(type, &blk)
61
+ else
62
+ type
63
+ end
64
+ end
65
+
66
+ # Map `type`'s #children and rebuild via #with_children, preserving identity
67
+ # when every child came back the same.
68
+ def map_children(type, &blk)
69
+ children = type.children
70
+ return type if children.empty?
71
+
72
+ mapped = children.map(&blk)
73
+ return type if mapped.each_with_index.all? { |m, i| m.equal?(children[i]) }
74
+
75
+ type.with_children(mapped)
76
+ end
77
+
78
+ # Yields (field, key) — the key so a caller can label the position (see
79
+ # Codec::Rewriter#visit_hash, which builds an error path from it).
80
+ def map_record(type, &blk)
81
+ changed = false
82
+ schema = type._schema.each_with_object({}) do |(key, field), acc|
83
+ mapped = blk.call(field, key)
84
+ changed ||= !mapped.equal?(field)
85
+ acc[key] = mapped
86
+ end
87
+ changed ? type.class.new(schema:) : type
88
+ end
89
+
90
+ # A transparent wrapper: map the wrapped type, keep the wrapper when it did not
91
+ # change, else re-wrap via the block.
92
+ def rewrap(original, inner, blk)
93
+ mapped = blk.call(inner)
94
+ mapped.equal?(inner) ? original : yield(mapped)
95
+ end
96
+ end
97
+ end
data/lib/plumb/not.rb CHANGED
@@ -8,10 +8,27 @@ module Plumb
8
8
 
9
9
  attr_reader :children, :errors
10
10
 
11
+ # Double negation cancels: `Not(Not(X))` is just `X` — it accepts exactly
12
+ # what `X` does. So wrapping an existing Not returns the inner type instead
13
+ # of nesting (covering both `#not` and `Not[]`). A custom error message
14
+ # keeps the Not, since collapsing would discard it.
15
+ def self.new(step = nil, errors: nil)
16
+ wrapped = Composable.wrap(step)
17
+ return wrapped.children.first if errors.nil? && wrapped.is_a?(self)
18
+
19
+ super
20
+ end
21
+
22
+ # @see Plumb::NodeMapper
23
+ def with_children(children) = self.class.new(children.first, errors: errors)
24
+
11
25
  def initialize(step = nil, errors: nil)
12
26
  @step = Composable.wrap(step)
13
27
  @errors = errors || "must not be #{step.inspect}"
14
- @children = [step].freeze
28
+ # Store the *wrapped* step (as every container does), so `Not[String]` and
29
+ # `Not[Types::String]` are the same node — and so the subtype engine isn't
30
+ # fooled into treating a raw-class child as an atomic leaf.
31
+ @children = [@step].freeze
15
32
  freeze
16
33
  end
17
34
 
@@ -21,13 +38,28 @@ module Plumb
21
38
  self.class.new(step)
22
39
  end
23
40
 
41
+ # Negation is contravariant: Not(A) <= Not(B) when B <= A, because excluding
42
+ # a wider set produces a narrower complement.
43
+ # @param other [Composable]
44
+ # @return [Boolean]
45
+ def subtype_of?(other)
46
+ return true if self == other
47
+ return Plumb::Subtyping.subtype?(other.children.first, @step) if other.is_a?(Not)
48
+
49
+ super
50
+ end
51
+
52
+ # @return [Boolean] true because negation only changes validity
53
+ def value_preserving? = true
54
+
24
55
  private def _inspect
25
56
  %(Not(#{@step.inspect}))
26
57
  end
27
58
 
28
59
  def call(result)
29
60
  result = @step.call(result)
30
- result.valid? ? result.invalid(errors: @errors) : result.valid
61
+ # In-place inversion no fork here, the cursor is ours to flip.
62
+ result.valid? ? result.invalid!(errors: @errors) : result.valid!
31
63
  end
32
64
  end
33
65
  end