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
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'plumb/subtyping'
|
|
4
|
+
|
|
5
|
+
module Plumb
|
|
6
|
+
# THE REWRITE RULES — the optimisation pass over the type AST.
|
|
7
|
+
#
|
|
8
|
+
# The operators do not build the tree you wrote; they build an equivalent one with
|
|
9
|
+
# the provably-redundant runtime work removed. `Integer[0..100] >> Integer[0..]`
|
|
10
|
+
# validates `::Integer` once; `Integer | Numeric` collapses to `Numeric`;
|
|
11
|
+
# `String[/a/] | String[/b/]` checks `String` once and branches on the suffixes.
|
|
12
|
+
#
|
|
13
|
+
# A different kind of thing from {Plumb::Subtyping}: the relation ANSWERS questions
|
|
14
|
+
# about types, these REWRITE one AST into another. Keeping them apart makes the
|
|
15
|
+
# dependency one-way (Optimizer -> Subtyping, never back).
|
|
16
|
+
#
|
|
17
|
+
# Runs EAGERLY at build time, not as a deferred pass, because reductions are
|
|
18
|
+
# observable through `#inspect`, `#==` and the visitors — a type's identity is its
|
|
19
|
+
# reduced form, and `(Integer[0..100] >> Integer[0..]) == Integer[0..100]` is a
|
|
20
|
+
# documented property.
|
|
21
|
+
#
|
|
22
|
+
# EVERY RULE MUST PRESERVE, for every input: validity, output value, errors, and
|
|
23
|
+
# execution order. The last is easy to lose and invisible to most tests, since it
|
|
24
|
+
# only shows up when a step has a side effect — which is why absorption and factoring
|
|
25
|
+
# are gated on `#value_preserving?`. spec/invariants_spec.rb asserts all four, order
|
|
26
|
+
# via probes that log each step as it runs.
|
|
27
|
+
#
|
|
28
|
+
# THE RULES, in the order tried:
|
|
29
|
+
#
|
|
30
|
+
# `left >> right` / `left / right` reduce_step, then redundant_refinement?
|
|
31
|
+
# (#>> only), else Conjunction.build
|
|
32
|
+
# `left | right` reduce_union, then factor_union,
|
|
33
|
+
# else Disjunction.build
|
|
34
|
+
#
|
|
35
|
+
# reduce_step is itself a ladder: intersection distribution, attribute narrowing,
|
|
36
|
+
# step fusion, refinement re-parenting, and finally boundary absorption.
|
|
37
|
+
#
|
|
38
|
+
# The meet (`#&`) is NOT here — a greatest lower bound is lattice algebra, not a
|
|
39
|
+
# rewrite, so it stays in Subtyping.intersect.
|
|
40
|
+
#
|
|
41
|
+
# DECLINED, not missing: dropping a redundant gate on the LEFT. reduce_step drops one
|
|
42
|
+
# on the right (`f >> Types::String` returns `f`), and the mirror looks equally free —
|
|
43
|
+
# `Types::String >> f` runs the String check twice when `f` declares String as its
|
|
44
|
+
# input, worth 21% of a resolve, or 64% for `Types::UUID::V4 >> a_finder` where a
|
|
45
|
+
# regex ran twice. It was implemented and measured, then backed out: it destroys the
|
|
46
|
+
# shared prefix factor_union hoists, so `(String >> b) | (String >> c) | (String >> d)`
|
|
47
|
+
# goes from one String check to three — 3.7x worse on the rejection path, which is
|
|
48
|
+
# where an enum-like union spends its time. The two rules want opposite things about
|
|
49
|
+
# the same node (absorb the gate vs extract it), and at `>>` time there is no way to
|
|
50
|
+
# know a `|` is coming. Factoring wins the bigger case.
|
|
51
|
+
#
|
|
52
|
+
# #absorb_boundary sits on the other side of that line, and where the check goes is
|
|
53
|
+
# what divides them: it moves a left-hand type into an `Any` input slot only, where
|
|
54
|
+
# there is no gate to drop, so what a union gives up is a step boundary in a chain
|
|
55
|
+
# whose middle step is an untyped callable. Extending it to a TYPED input slot
|
|
56
|
+
# (`Types::String >> (String -> Integer)` -> just the transform) is the declined rule
|
|
57
|
+
# in another form, and would need factor_union taught to unfold a value-preserving
|
|
58
|
+
# input slot back into a step before it could pay.
|
|
59
|
+
module Optimizer
|
|
60
|
+
module_function
|
|
61
|
+
|
|
62
|
+
# The full rule set for `left >> right`. Always returns a node.
|
|
63
|
+
#
|
|
64
|
+
# @param left [Composable]
|
|
65
|
+
# @param right [Composable]
|
|
66
|
+
# @return [Composable]
|
|
67
|
+
def rewrite_step(left, right)
|
|
68
|
+
reduce_step(left, right) ||
|
|
69
|
+
(redundant_refinement?(left, right) ? left : Conjunction.build(left, right))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# For `left / right` — the escape hatch, and the refinement builders (#[], #where,
|
|
73
|
+
# #value) that route through it.
|
|
74
|
+
#
|
|
75
|
+
# STRUCTURAL REDUCTION ONLY: absorption is deliberately skipped, because `#/` exists
|
|
76
|
+
# to assert a narrowing the checker cannot prove, and dropping it as "already
|
|
77
|
+
# guaranteed" would discard the cast the caller asked for. reduce_step still removes
|
|
78
|
+
# a duplicated type gate, which is pure bookkeeping.
|
|
79
|
+
#
|
|
80
|
+
# @param left [Composable]
|
|
81
|
+
# @param right [Composable]
|
|
82
|
+
# @return [Composable]
|
|
83
|
+
def rewrite_refinement(left, right)
|
|
84
|
+
reduce_step(left, right) || Conjunction.build(left, right)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The full rule set for `left | right`. Always returns a node.
|
|
88
|
+
#
|
|
89
|
+
# @param left [Composable]
|
|
90
|
+
# @param right [Composable]
|
|
91
|
+
# @return [Composable]
|
|
92
|
+
def rewrite_union(left, right)
|
|
93
|
+
reduce_union(left, right) ||
|
|
94
|
+
factor_union(left, right) ||
|
|
95
|
+
Disjunction.build(left, right)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Rung-1 structural reduction of `left >> right` — the ladder tried before
|
|
99
|
+
# absorption into a Conjunction: intersection distribution, attribute narrowing,
|
|
100
|
+
# step fusion, refinement re-parenting, boundary absorption. Each is a method or a
|
|
101
|
+
# commented block below, in that order. Returns the reduced type, or `nil` to fall
|
|
102
|
+
# back to `Conjunction.build`.
|
|
103
|
+
def reduce_step(left, right)
|
|
104
|
+
# A refinement narrows by each conjunct in turn: `left / (b ∧ c)` is
|
|
105
|
+
# `(left / b) / c`. Being an Intersection IS the condition — it is only
|
|
106
|
+
# built when both sides preserve the value — so no runtime value-preservation
|
|
107
|
+
# test is needed here. A composition (And) carries a transform, is a barrier,
|
|
108
|
+
# and falls through to the Constraint check below, which bails.
|
|
109
|
+
if right.is_a?(Intersection)
|
|
110
|
+
l = reduce_step(left, right.children[0]) || Conjunction.build(left, right.children[0])
|
|
111
|
+
return reduce_step(l, right.children[1]) || Conjunction.build(l, right.children[1])
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# An attribute constraint intersects into `left`'s clause on the same
|
|
115
|
+
# attribute — like Constraint.narrow intersects Ranges — or stacks on when
|
|
116
|
+
# there is none. `String.where(size: 0..40) / .where(size: 10..100)` ->
|
|
117
|
+
# `.where(size: 10..40)`, one `String` check.
|
|
118
|
+
return narrow_attribute(left, right) if right.is_a?(AttributeValueMatch)
|
|
119
|
+
|
|
120
|
+
# Two adjacent converting steps whose boundary is provable at build time
|
|
121
|
+
# fuse into one node: `(A -> B) >> (B -> C)` becomes `(A -> C)` running
|
|
122
|
+
# both fns, dropping the redundant out/in checks between them. fuse_with
|
|
123
|
+
# carries its own subtype proof, so it is sound from #>> and #/ alike.
|
|
124
|
+
if (fused = left.fuse_with(right))
|
|
125
|
+
return fused
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
reparent_refinement(left, right) || absorb_boundary(left, right)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# When `right` is a refinement (a `Constraint` chain) whose ROOT is a base-type
|
|
132
|
+
# (Module) gate that `left`'s output already guarantees, that gate is a duplicated
|
|
133
|
+
# runtime check: re-parent `right`'s refinement matchers onto `left` and drop it.
|
|
134
|
+
# Nil when it declines, which leaves the pair to the absorption rung.
|
|
135
|
+
#
|
|
136
|
+
# Keyed on the root TYPE only (`Subtyping.subtype?(left_output, root)`), NOT on matcher
|
|
137
|
+
# values — so `Integer[0..100] >> Integer[-10..110]` becomes
|
|
138
|
+
# `Integer[0..100][-10..110]` (the `-10..110` range is preserved), not the
|
|
139
|
+
# value-subsumed `Integer[0..100]` (that would be rung 2).
|
|
140
|
+
#
|
|
141
|
+
# matchers=[-10..110], root=Constraint(::Integer), left guarantees Integer
|
|
142
|
+
# => Constraint(-10..110, base: Integer[0..100]) == Integer[0..100][-10..110]
|
|
143
|
+
#
|
|
144
|
+
# Degenerate `left >> Integer` (`matchers == []`) returns `left` — a pure
|
|
145
|
+
# redundant type gate removed.
|
|
146
|
+
def reparent_refinement(left, right)
|
|
147
|
+
return nil unless right.is_a?(Constraint)
|
|
148
|
+
|
|
149
|
+
matchers = [] # innermost-first, excludes the root gate
|
|
150
|
+
node = right
|
|
151
|
+
while node.is_a?(Constraint) && node.base
|
|
152
|
+
matchers.unshift(node.matcher)
|
|
153
|
+
node = node.base
|
|
154
|
+
end
|
|
155
|
+
root = node
|
|
156
|
+
return nil unless root.is_a?(Constraint) && SemanticMatcher.nominal?(root.matcher)
|
|
157
|
+
return nil unless Subtyping.subtype?(Subtyping.resolved_output(left), root)
|
|
158
|
+
|
|
159
|
+
# Stack right's refinements onto left; Constraint.narrow intersects Ranges
|
|
160
|
+
# so `Integer[0..100] >> Integer[0..]` collapses to `Integer[0..100]`.
|
|
161
|
+
matchers.reduce(left) { |acc, m| Constraint.narrow(acc, m) }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# LAST RUNG. A typed step runs its boundary types as steps, so a plain type next
|
|
165
|
+
# to one can move into the matching slot: `Types::Integer >> a_proc >>
|
|
166
|
+
# Types::Float` becomes the single `(Integer -> Float)` instead of a three-node
|
|
167
|
+
# chain with an `Any` hop in the middle. The node owns the conditions (see
|
|
168
|
+
# Function#absorb_output / #absorb_input) and at most one side can accept, since
|
|
169
|
+
# each requires the OTHER side to be a value-preserving type — which a typed step
|
|
170
|
+
# never is, and a seam between two of those is #fuse_with's.
|
|
171
|
+
#
|
|
172
|
+
# Last in the ladder, so a pair another rule reduces gets that reduction instead:
|
|
173
|
+
# `#[]` on a transform re-parents (`String.transform(:to_i)[0..10]`), and a
|
|
174
|
+
# redundant gate is dropped rather than absorbed.
|
|
175
|
+
def absorb_boundary(left, right)
|
|
176
|
+
left.absorb_output(right) || right.absorb_input(left)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Narrow `left` by attribute constraint `avm`: intersect it into `left`'s
|
|
180
|
+
# existing clause on the same attribute+base type (mirroring how
|
|
181
|
+
# Constraint.narrow intersects Range matchers), or stack it on when there is
|
|
182
|
+
# no such clause. Always reduces (never bails) — an AVM is a value-narrowing
|
|
183
|
+
# refinement, so there is no duplicated type gate to keep it apart.
|
|
184
|
+
def narrow_attribute(left, avm)
|
|
185
|
+
merge_attribute_into(left, avm) || Conjunction.build(left, avm)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# `left` rebuilt with `avm` merged into its matching same-attribute clause,
|
|
189
|
+
# or nil when `left` has none (the caller then stacks). Prefers the outermost
|
|
190
|
+
# (most-recently-added) clause.
|
|
191
|
+
def merge_attribute_into(left, avm)
|
|
192
|
+
case left
|
|
193
|
+
when AttributeValueMatch
|
|
194
|
+
return nil unless left.attr_name == avm.attr_name && compatible_base?(left.type, avm.type)
|
|
195
|
+
|
|
196
|
+
merged = intersect_attribute_values(left.value, avm.value)
|
|
197
|
+
return nil if merged.nil?
|
|
198
|
+
return Types::Never if merged.equal?(Constraint::EMPTY) # unsatisfiable clause ⇒ bottom
|
|
199
|
+
|
|
200
|
+
AttributeValueMatch.new(left.type, left.attr_name, merged)
|
|
201
|
+
when Conjunction
|
|
202
|
+
# A conjunct that folds to Never makes the whole node uninhabitable ⇒ Never.
|
|
203
|
+
if (right = merge_attribute_into(left.children[1], avm))
|
|
204
|
+
right.is_a?(NeverClass) ? right : Conjunction.build(left.children[0], right)
|
|
205
|
+
elsif (leftc = merge_attribute_into(left.children[0], avm))
|
|
206
|
+
leftc.is_a?(NeverClass) ? leftc : Conjunction.build(leftc, left.children[1])
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Intersect two attribute-constraint values into a single value, `nil` to keep
|
|
212
|
+
# the two clauses stacked, or `Constraint::EMPTY` when the overlap is provably
|
|
213
|
+
# empty (the caller turns that into Never). Raw Ranges/Sets intersect to their
|
|
214
|
+
# (possibly narrower, possibly empty) overlap via Constraint.merge_matchers; a
|
|
215
|
+
# Plumb-typed value reduces only by subsumption — keeping the narrower — and
|
|
216
|
+
# otherwise stays stacked (intersecting two arbitrary Plumb types into one
|
|
217
|
+
# clause isn't representable).
|
|
218
|
+
def intersect_attribute_values(a, b)
|
|
219
|
+
return a if a == b
|
|
220
|
+
|
|
221
|
+
if a.is_a?(Composable) || b.is_a?(Composable)
|
|
222
|
+
return a if Subtyping.value_subtype?(a, b)
|
|
223
|
+
return b if Subtyping.value_subtype?(b, a)
|
|
224
|
+
|
|
225
|
+
nil
|
|
226
|
+
else
|
|
227
|
+
Constraint.merge_matchers(a, b)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Two same-attribute clauses may merge when their base types are subtype-
|
|
232
|
+
# comparable — so a clause built on `String` and one built on the accumulated
|
|
233
|
+
# `String.where(size: …)` (as chained `#where` produces) still fold together.
|
|
234
|
+
def compatible_base?(a, b)
|
|
235
|
+
a == b || Subtyping.subtype?(a, b) || Subtyping.subtype?(b, a)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# In `left >> right`, is `right` a no-op that `left` already guarantees?
|
|
239
|
+
# True when `right` preserves values AND every value `left` produces already
|
|
240
|
+
# satisfies it (`left <= right`), so `right` can neither reject nor change
|
|
241
|
+
# them — eg. `String.where(size: 3..10) >> String.where(size: 0..)` drops the
|
|
242
|
+
# vacuous `size: 0..`. This is what reduce_step does for a Constraint chain,
|
|
243
|
+
# generalized to any value-preserving refinement (a `where`/AVM And, a nested
|
|
244
|
+
# Or). It tests REAL subsumption via Subtyping.subtype?, not check_composable!'s type-
|
|
245
|
+
# compat check — a value-narrowing refinement (AVM) opts out of the latter
|
|
246
|
+
# (its #input_type is Any), so check_composable! can't tell it apart.
|
|
247
|
+
def redundant_refinement?(left, right)
|
|
248
|
+
# `right` is the dropped side (kept: left). Don't drop it if it carries
|
|
249
|
+
# wrapper identity Subtyping.subtype? now sees through — unless it equals left,
|
|
250
|
+
# where left already IS that identity. eg. `String[EMAIL] >> Types::Email`
|
|
251
|
+
# keeps the And so the :email node (and its JSON-schema format) survives.
|
|
252
|
+
if Subtyping.value_preserving?(right) && Subtyping.subtype?(left, right) &&
|
|
253
|
+
(left == right || !Subtyping.identity_wrapper?(right))
|
|
254
|
+
return true
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
redundant_record_refinement?(left, right)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# The record case of `redundant_refinement?`. A HashClass is NOT value-
|
|
261
|
+
# preserving in general — a non-inclusive record drops undeclared keys — so
|
|
262
|
+
# the generic test above never fires for it. `left >> right` (both records)
|
|
263
|
+
# still reduces to `left` when `right` merely re-validates every value `left`
|
|
264
|
+
# produces without dropping or changing anything. Sound sufficient conditions:
|
|
265
|
+
#
|
|
266
|
+
# - both are plain records (no typed/pattern keys — only literal keys and an
|
|
267
|
+
# optional `_` catch-all — so key-keeping is decidable);
|
|
268
|
+
# - the same declared (literal) key set — `right` drops none of `left`'s keys
|
|
269
|
+
# and requires none `left` lacks;
|
|
270
|
+
# - `left <= right` — `right`'s fields are supertypes with compatible
|
|
271
|
+
# optionality, so it rejects nothing `left` emits;
|
|
272
|
+
# - every `right` field is value-preserving — `right` coerces nothing;
|
|
273
|
+
# - if `left` carries a catch-all (so it emits arbitrary extra keys), `right`
|
|
274
|
+
# carries a value-preserving catch-all that covers it — otherwise `right`
|
|
275
|
+
# would drop or reject those extra keys.
|
|
276
|
+
#
|
|
277
|
+
# Anything short keeps the And (`right` might drop keys or convert values —
|
|
278
|
+
# eg. the front/back coercion `Hash[price: Int] >> Hash[price: Int.build(Money)]`
|
|
279
|
+
# must NOT collapse).
|
|
280
|
+
def redundant_record_refinement?(left, right)
|
|
281
|
+
return false unless left.is_a?(HashClass) && right.is_a?(HashClass)
|
|
282
|
+
return false unless plain_record?(left) && plain_record?(right)
|
|
283
|
+
return false unless same_literal_keys?(left, right)
|
|
284
|
+
return false unless catch_all_preserved?(left, right)
|
|
285
|
+
return false unless Subtyping.subtype?(left, right)
|
|
286
|
+
|
|
287
|
+
right.literal_fields.all? { |_key, field| Subtyping.value_preserving?(field) }
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# A record whose only keys are literal names plus an optional `_` catch-all
|
|
291
|
+
# (no typed/pattern keys, whose key-keeping we don't reason about here).
|
|
292
|
+
def plain_record?(hash) = hash.matcher_fields.all? { |key, _| key.catch_all? }
|
|
293
|
+
|
|
294
|
+
# Do two records declare the same set of literal key names?
|
|
295
|
+
def same_literal_keys?(left, right)
|
|
296
|
+
lk = left.literal_fields.keys
|
|
297
|
+
rk = right.literal_fields.keys
|
|
298
|
+
lk.size == rk.size && lk.all? { |k| rk.any? { |o| o.eql?(k) } }
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Does `right` keep and preserve `left`'s catch-all tail (the keys `left` emits
|
|
302
|
+
# beyond its declared ones)? Vacuously true when `left` has no catch-all.
|
|
303
|
+
def catch_all_preserved?(left, right)
|
|
304
|
+
lc = left.catch_all_type
|
|
305
|
+
return true if lc.nil?
|
|
306
|
+
|
|
307
|
+
rc = right.catch_all_type
|
|
308
|
+
!rc.nil? && Subtyping.value_preserving?(rc) && Subtyping.subtype?(lc, rc)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Join-dual of `reduce_step`: absorption for `a | b`. If one branch's value set
|
|
312
|
+
# is contained in another's, the union equals the wider branch
|
|
313
|
+
# (`a ∪ b == b` when `a <= b`), so the narrower is dropped — and duplicate
|
|
314
|
+
# branches dedupe. Returns the surviving type, or nil when nothing can go.
|
|
315
|
+
#
|
|
316
|
+
# A JOIN IS N-ARY. `A | B | C` is stored as a nested pair but means one flat branch
|
|
317
|
+
# set, so absorption has to see all of it — comparing only the two operands makes
|
|
318
|
+
# the result depend on the order they were written:
|
|
319
|
+
#
|
|
320
|
+
# Numeric | String | Integer => Numeric | String (Integer absorbed)
|
|
321
|
+
# Integer | String | Numeric => (Integer | String) | Numeric
|
|
322
|
+
#
|
|
323
|
+
# Both accept the same values, but the second keeps `Integer` even though
|
|
324
|
+
# `Integer <= Numeric`, because `subtype?(Union(Integer, String), Numeric)` requires
|
|
325
|
+
# EVERY branch to be within Numeric. The redundant branch then costs a failed match
|
|
326
|
+
# on every value falling through to it.
|
|
327
|
+
#
|
|
328
|
+
# So flatten, absorb across the set, re-fold. Order among survivors is preserved —
|
|
329
|
+
# a join is commutative, and keeping it stable avoids churning `#inspect` and a
|
|
330
|
+
# JSON Schema's `anyOf` order.
|
|
331
|
+
def reduce_union(a, b)
|
|
332
|
+
branches = union_branches(a) + union_branches(b)
|
|
333
|
+
survivors = absorb_branches(branches)
|
|
334
|
+
return nil if survivors.size == branches.size # nothing to drop — leave the pair alone
|
|
335
|
+
return survivors.first if survivors.size == 1
|
|
336
|
+
|
|
337
|
+
survivors.drop(1).reduce(survivors.first) do |acc, branch|
|
|
338
|
+
factor_union(acc, branch) || Disjunction.build(acc, branch)
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# ONLY Union, never Or: a choice is left-biased and its branches may convert, so its
|
|
343
|
+
# order is semantic and dropping one is not a type-level decision.
|
|
344
|
+
def union_branches(type)
|
|
345
|
+
type.is_a?(Union) ? type.children.flat_map { |c| union_branches(c) } : [type]
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Drop every branch another one already covers, keeping first-seen order.
|
|
349
|
+
def absorb_branches(branches)
|
|
350
|
+
branches.each_with_object([]) do |candidate, survivors|
|
|
351
|
+
next if survivors.any? { |s| absorbs?(s, candidate) }
|
|
352
|
+
|
|
353
|
+
survivors.reject! { |s| absorbs?(candidate, s) }
|
|
354
|
+
survivors << candidate
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Does `wider` cover `narrower`, such that dropping `narrower` changes nothing?
|
|
359
|
+
#
|
|
360
|
+
# Guarded to VALUE-PRESERVING branches: `subtype?` identifies a Function by its
|
|
361
|
+
# OUTPUT type, so `subtype?(String->Integer, Numeric)` holds even though that branch
|
|
362
|
+
# accepts Strings a bare Numeric rejects, and absorbing would drop a coercion.
|
|
363
|
+
# Identical branches dedupe regardless — the survivor IS the dropped node.
|
|
364
|
+
def absorbs?(wider, narrower)
|
|
365
|
+
return true if wider == narrower
|
|
366
|
+
return false unless Subtyping.value_preserving?(wider) && Subtyping.value_preserving?(narrower)
|
|
367
|
+
# Never absorb across a wrapper: subtype? sees through Policy/Metadata/Node, so
|
|
368
|
+
# the drop would lose the identity one carries (eg. `Types::Email |
|
|
369
|
+
# Types::String` must keep both). @see Subtyping.identity_wrapper?
|
|
370
|
+
return false if Subtyping.identity_wrapper?(wider) || Subtyping.identity_wrapper?(narrower)
|
|
371
|
+
|
|
372
|
+
Subtyping.subtype?(narrower, wider)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Distributive factoring — the join-dual of reduce_union's absorption:
|
|
376
|
+
# `(P >> A) | (P >> B)` factors to `P >> (A | B)` when the shared left prefix
|
|
377
|
+
# `P` is value-preserving, so `P` is validated once instead of per branch.
|
|
378
|
+
#
|
|
379
|
+
# SOUNDNESS. The un-factored union runs `P` once per branch (the Or re-runs
|
|
380
|
+
# it in the right branch when the left fails). Factoring runs it once, which
|
|
381
|
+
# is behaviour-preserving iff `P` is referentially transparent. A VALUE-
|
|
382
|
+
# PRESERVING `P` guarantees this: it never alters the value, so both forms
|
|
383
|
+
# feed the divergent suffixes the identical input. The guard is per prefix
|
|
384
|
+
# STEP (below), so the suffixes may be anything (incl. transforms) and a
|
|
385
|
+
# transform prefix — whose purity is unprovable — halts the shared prefix.
|
|
386
|
+
#
|
|
387
|
+
# Both branches are flattened to their `>>` step lists (#steps unwraps already-
|
|
388
|
+
# factored `:refined_union` nodes, so a third branch folds into an existing
|
|
389
|
+
# `P >> (…)` rather than re-checking `P` — n-ary folding). The longest common
|
|
390
|
+
# value-preserving step prefix is pulled out; the divergent tails become the
|
|
391
|
+
# Or. Runs AFTER reduce_union, so a prefix consuming a whole branch was
|
|
392
|
+
# already absorbed. The result is decorated `:refined_union` so visitors fold
|
|
393
|
+
# the type-less disjunction into P's type spec; runtime is the plain And.
|
|
394
|
+
def factor_union(a, b)
|
|
395
|
+
sa = steps(a)
|
|
396
|
+
sb = steps(b)
|
|
397
|
+
k = common_step_prefix(sa, sb)
|
|
398
|
+
return nil if k.zero? # disjoint prefixes — nothing shared
|
|
399
|
+
return nil if k == sa.size || k == sb.size # one is a prefix of the other (absorption's job)
|
|
400
|
+
|
|
401
|
+
inner = Disjunction.build(rebuild(sa.drop(k)), rebuild(sb.drop(k)))
|
|
402
|
+
Conjunction.build(rebuild(sa.take(k)), inner).as_node(:refined_union)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# Flatten a type into its `>>` execution steps. A fused Constraint chain
|
|
406
|
+
# (`String[/d/]`) unfolds to its base then a bare matcher refinement; an And
|
|
407
|
+
# to its two sides; an already-factored `:refined_union` node is peeled so its
|
|
408
|
+
# shared prefix re-exposes for n-ary folding. Everything else (root gate,
|
|
409
|
+
# transform, Or, container) is atomic. Only `:refined_union` nodes are peeled
|
|
410
|
+
# — Metadata/Policy/other Nodes carry identity we must not factor away.
|
|
411
|
+
def steps(type)
|
|
412
|
+
type = type.type if type.is_a?(Composable::Node) && type.node_name == :refined_union
|
|
413
|
+
case type
|
|
414
|
+
when Conjunction then type.children.flat_map { |c| steps(c) }
|
|
415
|
+
when Constraint then type.base ? steps(type.base) + [Constraint.new(type.matcher)] : [type]
|
|
416
|
+
else [type]
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Length of the longest leading run of steps the two lists agree on AND that
|
|
421
|
+
# is value-preserving — the sound-to-factor shared prefix.
|
|
422
|
+
def common_step_prefix(sa, sb)
|
|
423
|
+
max = sa.size < sb.size ? sa.size : sb.size
|
|
424
|
+
i = 0
|
|
425
|
+
i += 1 while i < max && sa[i] == sb[i] && Subtyping.value_preserving?(sa[i])
|
|
426
|
+
i
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# Re-fold a step list into a type, fusing consecutive Constraint refinements
|
|
430
|
+
# back into a Constraint chain (`[String, /d/] → String[/d/]`) and using And at
|
|
431
|
+
# a non-fusable boundary (a transform or an Or suffix).
|
|
432
|
+
def rebuild(list)
|
|
433
|
+
list.reduce { |left, step| compose_step(left, step) }
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def compose_step(left, right)
|
|
437
|
+
if left.is_a?(Constraint) && right.is_a?(Constraint) && right.base.nil?
|
|
438
|
+
Constraint.narrow(left, right.matcher)
|
|
439
|
+
else
|
|
440
|
+
Conjunction.build(left, right)
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
end
|
data/lib/plumb/or.rb
CHANGED
|
@@ -1,40 +1,37 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'plumb/composable'
|
|
4
|
+
require 'plumb/disjunction'
|
|
4
5
|
|
|
5
6
|
module Plumb
|
|
7
|
+
# LEFT-BIASED CHOICE — try `left`, fall back to `right`, built by
|
|
8
|
+
# `Composable#|` when some branch changes the value. The all-branches-preserving
|
|
9
|
+
# case (a genuine set union) is {Plumb::Union}; see {Plumb::Disjunction}.
|
|
10
|
+
#
|
|
11
|
+
# A choice's ends differ, because a converting branch accepts an input the join
|
|
12
|
+
# of the outputs would reject: `Integer | String.transform(:to_i)` consumes
|
|
13
|
+
# `Integer | String` and produces `Integer | Integer`.
|
|
6
14
|
class Or
|
|
7
15
|
include Composable
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
private def _inspect
|
|
19
|
-
%((#{@left.inspect} | #{@right.inspect}))
|
|
16
|
+
include Disjunction
|
|
17
|
+
|
|
18
|
+
# (A | B).output_type == A.output_type | B.output_type. A converting branch
|
|
19
|
+
# produces something other than what it consumed, so this genuinely has to map
|
|
20
|
+
# over the branches — which is exactly what Union does NOT have to do.
|
|
21
|
+
# #input_type is shared with Union; see Disjunction#input_type.
|
|
22
|
+
def output_type
|
|
23
|
+
l = @left.output_type
|
|
24
|
+
r = @right.output_type
|
|
25
|
+
l.equal?(@left) && r.equal?(@right) ? self : Disjunction.build(l, r)
|
|
20
26
|
end
|
|
21
27
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
# Decrease Array allocations slightly
|
|
31
|
-
# OR can be really expensive in composite ORed types
|
|
32
|
-
left_errors = left_result.errors.is_a?(Array) ? left_result.errors : [left_result.errors]
|
|
33
|
-
right_errors = right_result.errors.is_a?(Array) ? right_result.errors.first : right_result.errors
|
|
34
|
-
left_errors << right_errors
|
|
35
|
-
|
|
36
|
-
right_result.invalid(errors: left_errors)
|
|
37
|
-
end
|
|
38
|
-
end
|
|
28
|
+
# A disjunction preserves the value only if EVERY branch does — a branch
|
|
29
|
+
# that transforms (a coercion) changes it when taken. Recurses through the
|
|
30
|
+
# cached accessor so shared subtrees are memoized once.
|
|
31
|
+
#
|
|
32
|
+
# Kept computed rather than hardcoded false: Disjunction.build only routes the
|
|
33
|
+
# converting case here, but `Or.new` remains callable directly (the Decorator
|
|
34
|
+
# rebuilds by class), so an Or may still hold two preserving branches.
|
|
35
|
+
def value_preserving? = children.all? { |c| Plumb::Subtyping.value_preserving?(c) }
|
|
39
36
|
end
|
|
40
37
|
end
|
data/lib/plumb/pipeline.rb
CHANGED
|
@@ -17,6 +17,11 @@ module Plumb
|
|
|
17
17
|
def call(result)
|
|
18
18
|
@block.call(@step, result)
|
|
19
19
|
end
|
|
20
|
+
|
|
21
|
+
# Opaque middleware wrapper (its wrapped step may be a plain proc), so its
|
|
22
|
+
# input/output types are unknown — Any (top). Opts out of #>> checks.
|
|
23
|
+
def input_type = Types::Any
|
|
24
|
+
def output_type = Types::Any
|
|
20
25
|
end
|
|
21
26
|
|
|
22
27
|
class << self
|
|
@@ -38,8 +43,7 @@ module Plumb
|
|
|
38
43
|
attr_reader :children
|
|
39
44
|
|
|
40
45
|
def initialize(type: Types::Any, freeze_after: true, &setup)
|
|
41
|
-
|
|
42
|
-
@children = [type].freeze
|
|
46
|
+
self.type = type
|
|
43
47
|
@around_blocks = self.class.around_blocks.dup
|
|
44
48
|
|
|
45
49
|
configure(&setup) if block_given?
|
|
@@ -50,17 +54,51 @@ module Plumb
|
|
|
50
54
|
@type.call(result)
|
|
51
55
|
end
|
|
52
56
|
|
|
57
|
+
# A Pipeline is a transparent wrapper around its accumulated composition, so
|
|
58
|
+
# it delegates its type-flow to that inner type. When the inner steps are
|
|
59
|
+
# opaque (plain procs/around-wrapped steps) those resolve to Any and the
|
|
60
|
+
# Pipeline still opts out of #>> composition type-checks; when they carry
|
|
61
|
+
# real types, the Pipeline participates accurately.
|
|
62
|
+
def input_type = @type.input_type
|
|
63
|
+
def output_type = @type.output_type
|
|
64
|
+
|
|
65
|
+
# Use the accumulated composition as this pipeline's subtype identity. Merely
|
|
66
|
+
# delegating input/output flow would let composition checks work while leaving
|
|
67
|
+
# the Pipeline unrelated to the same type inside containers.
|
|
68
|
+
# @return [Composable]
|
|
69
|
+
def subtype_identity = @type
|
|
70
|
+
|
|
71
|
+
# @return [Boolean] whether the accumulated composition preserves values
|
|
72
|
+
def value_preserving? = @type.value_preserving?
|
|
73
|
+
|
|
74
|
+
# Add a step. A pipeline is a sequence of validators/coercions that
|
|
75
|
+
# progressively narrows its data, so steps are **non-strict** by default:
|
|
76
|
+
# `#step` chains with `#/`, skipping the composition type-check (a later step
|
|
77
|
+
# may legitimately narrow what an earlier one produced). Use `#step!` when you
|
|
78
|
+
# want the strict `#>>` check — a build-time error if a step could never
|
|
79
|
+
# accept the previous step's output.
|
|
80
|
+
#
|
|
81
|
+
# pl.step(type_or_callable) # non-strict chain (via #/)
|
|
82
|
+
# pl.step!(type_or_callable) # strict #>> chain (type-checked)
|
|
83
|
+
# pl.step(output_type) { |r| ... } # a Function: validates the current
|
|
84
|
+
# # output, runs the block, and declares
|
|
85
|
+
# # `output_type` as the produced type
|
|
53
86
|
def step(callable = nil, &block)
|
|
54
|
-
callable
|
|
55
|
-
|
|
56
|
-
raise ArgumentError,
|
|
57
|
-
"#step expects an interface #call(Result) Result, but got #{callable.inspect}"
|
|
58
|
-
end
|
|
87
|
+
add_step(callable, strict: false, &block)
|
|
88
|
+
end
|
|
59
89
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
90
|
+
# Strict counterpart of `#step`: chains with `#>>`, so it raises
|
|
91
|
+
# `Plumb::TypeError` at build time if the step can't accept what the pipeline
|
|
92
|
+
# currently produces. See #step.
|
|
93
|
+
#
|
|
94
|
+
# NOTE the `output_type` + block form cannot fail this check: the step's input type
|
|
95
|
+
# is DERIVED from what the pipeline already produces (see #add_step), so the two
|
|
96
|
+
# match by construction and `#step!` behaves as `#step` there. That is not a gap —
|
|
97
|
+
# `pl.step!(::Integer) { … }` after a String pipeline is legitimate, because the
|
|
98
|
+
# block is what does the converting; only the value it RETURNS is checked, against
|
|
99
|
+
# the declared output type.
|
|
100
|
+
def step!(callable = nil, &block)
|
|
101
|
+
add_step(callable, strict: true, &block)
|
|
64
102
|
end
|
|
65
103
|
|
|
66
104
|
def around(callable = nil, &block)
|
|
@@ -70,6 +108,56 @@ module Plumb
|
|
|
70
108
|
|
|
71
109
|
private
|
|
72
110
|
|
|
111
|
+
# #children has to track @type. Composable#== compares children and every visitor
|
|
112
|
+
# walks them, so capturing them once in #initialize — before `configure` had added
|
|
113
|
+
# any steps — meant a Pipeline reported no steps at all: `pl.children` was
|
|
114
|
+
# `[Types::Any]` however many steps it had, any two pipelines with the same
|
|
115
|
+
# starting type compared `==`, and visitors saw an empty composition.
|
|
116
|
+
#
|
|
117
|
+
# Assigned together through here so the two cannot drift, and eagerly rather than
|
|
118
|
+
# lazily because #initialize freezes the Pipeline afterwards.
|
|
119
|
+
private def type=(new_type)
|
|
120
|
+
@type = new_type
|
|
121
|
+
@children = [new_type].freeze
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Shared body for #step / #step!. Both forms chain with `#>>` when strict and `#/`
|
|
125
|
+
# otherwise; only what gets chained differs.
|
|
126
|
+
#
|
|
127
|
+
# The `output_type` + block form used to bypass the operators entirely, building
|
|
128
|
+
# `Function.new(@type, out, block)` — using the Function's INPUT slot as the chain
|
|
129
|
+
# link, so the accumulated pipeline became the new step's input check. That runs
|
|
130
|
+
# correctly, but it never reaches #>> / #/ and therefore never reaches the
|
|
131
|
+
# optimiser: each block step nested inside the last, and consecutive ones could not
|
|
132
|
+
# reduce.
|
|
133
|
+
#
|
|
134
|
+
# It is built as an ordinary step and chained like every other one instead. Its
|
|
135
|
+
# declared input is what the pipeline currently PRODUCES — the previous step's
|
|
136
|
+
# output type, or the pipeline's initial type when it is the first step — so the
|
|
137
|
+
# step reports honestly what it accepts, and the boundary between two of them is
|
|
138
|
+
# provable by construction, which is what lets Function#fuse_with collapse
|
|
139
|
+
# consecutive block steps into a single Function rather than nesting them.
|
|
140
|
+
#
|
|
141
|
+
# The block form is deliberately not passed through #prepare_step or the `around`
|
|
142
|
+
# wrappers, which is how it has always behaved.
|
|
143
|
+
def add_step(callable, strict:, &block)
|
|
144
|
+
if !callable.nil? && block
|
|
145
|
+
callable = Function.new(Plumb::Subtyping.resolved_output(@type), Composable.wrap(callable), block)
|
|
146
|
+
else
|
|
147
|
+
callable ||= block
|
|
148
|
+
unless is_a_step?(callable)
|
|
149
|
+
raise ArgumentError,
|
|
150
|
+
"#step expects an interface #call(Result) Result, but got #{callable.inspect}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
callable = prepare_step(callable)
|
|
154
|
+
callable = @around_blocks.reverse.reduce(callable) { |cl, bl| AroundStep.new(cl, bl) } if @around_blocks.any?
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
self.type = strict ? (@type >> callable) : (@type / callable)
|
|
158
|
+
self
|
|
159
|
+
end
|
|
160
|
+
|
|
73
161
|
def configure(&setup)
|
|
74
162
|
case setup.arity
|
|
75
163
|
when 1
|