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.
- checksums.yaml +4 -4
- data/README.md +887 -64
- data/bench/compare_dry_schema.rb +79 -0
- data/bench/compare_dry_types.rb +37 -0
- data/bench/compare_parametric_schema.rb +2 -80
- data/bench/dry_schema_hash.rb +103 -0
- data/bench/dry_types_hash.rb +125 -0
- data/bench/json_schema_profile.rb +107 -0
- data/bench/plumb_hash.rb +17 -11
- data/bench/results_allocations.rb +137 -0
- data/bench/sample_data.rb +78 -0
- data/examples/command_objects.rb +1 -1
- data/examples/concurrent_downloads.rb +16 -9
- data/examples/event_registry.rb +6 -1
- data/examples/weekdays.rb +1 -1
- data/lib/plumb/and.rb +63 -6
- data/lib/plumb/any_class.rb +12 -2
- data/lib/plumb/array_class.rb +133 -25
- data/lib/plumb/attribute_value_match.rb +41 -1
- data/lib/plumb/attributes.rb +59 -19
- data/lib/plumb/codec.rb +886 -0
- data/lib/plumb/composable.rb +451 -39
- data/lib/plumb/conjunction.rb +50 -0
- data/lib/plumb/constraint.rb +234 -0
- data/lib/plumb/covariant_fusion.rb +46 -0
- data/lib/plumb/decorator.rb +12 -22
- data/lib/plumb/deferred.rb +13 -5
- data/lib/plumb/disjunction.rb +112 -0
- data/lib/plumb/encoder.rb +207 -0
- data/lib/plumb/function.rb +347 -0
- data/lib/plumb/hash_class.rb +339 -32
- data/lib/plumb/hash_map.rb +58 -14
- data/lib/plumb/implementation.rb +247 -0
- data/lib/plumb/interface_class.rb +21 -2
- data/lib/plumb/intersection.rb +47 -0
- data/lib/plumb/json_schema_visitor.rb +255 -36
- data/lib/plumb/key.rb +63 -13
- data/lib/plumb/mermaid_visitor.rb +129 -0
- data/lib/plumb/metadata.rb +10 -1
- data/lib/plumb/metadata_visitor.rb +36 -34
- data/lib/plumb/never_class.rb +38 -0
- data/lib/plumb/node_mapper.rb +97 -0
- data/lib/plumb/not.rb +34 -2
- data/lib/plumb/optimizer.rb +444 -0
- data/lib/plumb/or.rb +26 -29
- data/lib/plumb/pipeline.rb +99 -11
- data/lib/plumb/policy.rb +17 -4
- data/lib/plumb/range_class.rb +46 -0
- data/lib/plumb/relation.rb +57 -0
- data/lib/plumb/result.rb +55 -23
- data/lib/plumb/semantic_matcher.rb +393 -0
- data/lib/plumb/static_class.rb +20 -1
- data/lib/plumb/stream_class.rb +28 -6
- data/lib/plumb/subtyping.rb +461 -0
- data/lib/plumb/tagged_hash.rb +45 -4
- data/lib/plumb/tuple_class.rb +21 -4
- data/lib/plumb/type_cache.rb +41 -0
- data/lib/plumb/type_registry.rb +71 -0
- data/lib/plumb/typed_step.rb +67 -0
- data/lib/plumb/types.rb +44 -43
- data/lib/plumb/union.rb +30 -0
- data/lib/plumb/value_class.rb +20 -1
- data/lib/plumb/version.rb +1 -1
- data/lib/plumb/visitor_handlers.rb +20 -4
- data/lib/plumb.rb +90 -3
- metadata +30 -8
- data/lib/plumb/build.rb +0 -22
- data/lib/plumb/match_class.rb +0 -42
- data/lib/plumb/schema.rb +0 -195
- data/lib/plumb/step.rb +0 -27
- 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(:
|
|
24
|
-
props
|
|
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(
|
|
32
|
-
|
|
33
|
-
props.merge(match: node, type:)
|
|
29
|
+
on(:never) do |_node, props|
|
|
30
|
+
props
|
|
34
31
|
end
|
|
35
32
|
|
|
36
|
-
on
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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(:
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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
|
|
81
|
+
props
|
|
80
82
|
end
|
|
81
83
|
|
|
82
84
|
on(:array) do |_node, props|
|
|
83
|
-
props
|
|
85
|
+
props
|
|
84
86
|
end
|
|
85
87
|
|
|
86
88
|
on(:stream) do |_node, props|
|
|
87
|
-
props
|
|
89
|
+
props
|
|
88
90
|
end
|
|
89
91
|
|
|
90
92
|
on(:tuple) do |_node, props|
|
|
91
|
-
props
|
|
93
|
+
props
|
|
92
94
|
end
|
|
93
95
|
|
|
94
96
|
on(:tagged_hash) do |_node, props|
|
|
95
|
-
props
|
|
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
|
-
|
|
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
|
-
|
|
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
|