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
data/lib/plumb/composable.rb
CHANGED
|
@@ -14,14 +14,32 @@ module Plumb
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
ParseError = Class.new(::TypeError)
|
|
17
|
+
# Raised by Composable#>> when chaining two steps whose types are provably
|
|
18
|
+
# incompatible (the left's #output_type is not a subtype of the right's
|
|
19
|
+
# #input_type).
|
|
20
|
+
TypeError = Class.new(::TypeError)
|
|
17
21
|
Undefined = UndefinedClass.new.freeze
|
|
18
22
|
|
|
19
23
|
BLANK_STRING = ''
|
|
20
24
|
BLANK_ARRAY = [].freeze
|
|
21
25
|
BLANK_HASH = {}.freeze
|
|
22
|
-
BLANK_RESULT = Result.wrap(Undefined)
|
|
23
26
|
NOOP = ->(result) { result }
|
|
24
27
|
|
|
28
|
+
# Ruby's explicit conversion methods and the type each produces. Lets
|
|
29
|
+
# `#transform(:to_i)` expand to a typed transform to Integer (using `:to_i` as
|
|
30
|
+
# the callable), instead of spelling out `#transform(Integer, &:to_i)`.
|
|
31
|
+
COERCION_METHODS = {
|
|
32
|
+
to_s: ::String,
|
|
33
|
+
to_sym: ::Symbol,
|
|
34
|
+
to_i: ::Integer,
|
|
35
|
+
to_f: ::Float,
|
|
36
|
+
to_r: ::Rational,
|
|
37
|
+
to_c: ::Complex,
|
|
38
|
+
to_a: ::Array,
|
|
39
|
+
to_h: ::Hash,
|
|
40
|
+
to_proc: ::Proc
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
25
43
|
module Callable
|
|
26
44
|
def resolve(value = Undefined)
|
|
27
45
|
call(Result.wrap(value))
|
|
@@ -49,6 +67,11 @@ module Plumb
|
|
|
49
67
|
# define a #node_name method on the Composable instance
|
|
50
68
|
# #node_name is used by Visitors to determine the type of node.
|
|
51
69
|
def self.included(base)
|
|
70
|
+
# An anonymous class (`Class.new { include Composable }`) has no name to
|
|
71
|
+
# derive from: leave #node_name to the instance method below, which reads
|
|
72
|
+
# the class name at call time (by then it may have been assigned one).
|
|
73
|
+
return unless base.name
|
|
74
|
+
|
|
52
75
|
nname = base.name.split('::').last
|
|
53
76
|
nname.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
|
|
54
77
|
nname.downcase!
|
|
@@ -81,7 +104,7 @@ module Plumb
|
|
|
81
104
|
|
|
82
105
|
def inspect = name.to_s
|
|
83
106
|
|
|
84
|
-
def node_name = self.class.name.split('::').last.to_sym
|
|
107
|
+
def node_name = (self.class.name || 'anonymous').split('::').last.to_sym
|
|
85
108
|
end
|
|
86
109
|
|
|
87
110
|
# Override #=== and #== for Composable instances.
|
|
@@ -99,15 +122,87 @@ module Plumb
|
|
|
99
122
|
end
|
|
100
123
|
end
|
|
101
124
|
|
|
125
|
+
# Compares nodes by exact class and children. Use `instance_of?`, not `is_a?`:
|
|
126
|
+
# a subclass may change behavior without changing #children, making parent/subclass
|
|
127
|
+
# equality asymmetric. Subtyping and optimization short-circuit on `==`, so that
|
|
128
|
+
# asymmetry could discard the narrower node before its behavioral guards run.
|
|
129
|
+
# @param other [Object]
|
|
130
|
+
# @return [Boolean]
|
|
102
131
|
def ==(other)
|
|
103
|
-
other.
|
|
132
|
+
other.instance_of?(self.class) && other.respond_to?(:children) && other.children == children
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Subtype/subset operator. `a <= b` is true when every value described by
|
|
136
|
+
# `self` is also described by `other` (`other` may be a raw Ruby class or
|
|
137
|
+
# value; it is normalized). Delegates to the generic Plumb::Subtyping engine.
|
|
138
|
+
# @param other [Composable, Class, Object]
|
|
139
|
+
# @return [Boolean]
|
|
140
|
+
def <=(other)
|
|
141
|
+
Plumb::Subtyping.subtype?(self, other)
|
|
104
142
|
end
|
|
143
|
+
|
|
144
|
+
# `self` is a supertype of `other`: every value of `other` is a value of
|
|
145
|
+
# `self`.
|
|
146
|
+
def >=(other)
|
|
147
|
+
Plumb::Subtyping.subtype?(Composable.wrap(other), self)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Strict subtype / supertype.
|
|
151
|
+
def <(other) = (self <= other) && !(self >= other)
|
|
152
|
+
def >(other) = (self >= other) && !(self <= other)
|
|
153
|
+
|
|
154
|
+
# Leaf hook for Plumb::Subtyping.subtype?, called once the algebra
|
|
155
|
+
# (Intersection/Or/top, and the #subtype_identity projection that replaces a
|
|
156
|
+
# converting node with what it produces) has been peeled away. It must NOT
|
|
157
|
+
# delegate back to #<= (that would recurse); it recurses only through
|
|
158
|
+
# Plumb::Subtyping.subtype?.
|
|
159
|
+
#
|
|
160
|
+
# Default behaviour:
|
|
161
|
+
# 1. reflexive structural equality;
|
|
162
|
+
# 2. atomic leaves (a single raw matcher/value) compared via Ruby
|
|
163
|
+
# semantics (Plumb::Subtyping.atomic_subtype?);
|
|
164
|
+
# 3. covariant containers — same class with pairwise-subtype children,
|
|
165
|
+
# which covers Array/Tuple/HashMap/Stream and any custom container that
|
|
166
|
+
# exposes #children, for free.
|
|
167
|
+
#
|
|
168
|
+
# Override for bespoke leaf relations (see HashClass width/depth subtyping).
|
|
169
|
+
# @param other [Composable]
|
|
170
|
+
# @return [Boolean]
|
|
171
|
+
def subtype_of?(other)
|
|
172
|
+
return true if self == other
|
|
173
|
+
|
|
174
|
+
if Plumb::Subtyping.atomic?(self) && Plumb::Subtyping.atomic?(other)
|
|
175
|
+
# #children contains only the matcher, but a refinement also describes its
|
|
176
|
+
# base. Check both or an unrelated atomic value may pass on matcher overlap
|
|
177
|
+
# alone (for example, Value[5.0] against Integer[1..5]).
|
|
178
|
+
if other.respond_to?(:base) && other.base && !Plumb::Subtyping.subtype?(self, other.base)
|
|
179
|
+
return false
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
return Plumb::Subtyping.atomic_subtype?(children.first, other.children.first)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
return false unless other.instance_of?(self.class)
|
|
186
|
+
return false if children.empty? || children.size != other.children.size
|
|
187
|
+
|
|
188
|
+
children.zip(other.children).all? { |c, o| Plumb::Subtyping.subtype?(c, o) }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Mirror of #subtype_of?, for relations the *supertype* owns and the subtype
|
|
192
|
+
# can't know about (eg. Interface duck-typing: any type is a subtype of an
|
|
193
|
+
# Interface whose methods its values support). Consulted by
|
|
194
|
+
# Plumb::Subtyping.subtype? only after #subtype_of? declines. Default: no —
|
|
195
|
+
# only the subtype side decides. Recurse via Plumb::Subtyping.subtype?, never
|
|
196
|
+
# #<=. Override for bespoke supertype behaviour (see InterfaceClass).
|
|
197
|
+
# @param other [Composable]
|
|
198
|
+
# @return [Boolean]
|
|
199
|
+
def supertype_of?(other) = false
|
|
105
200
|
end
|
|
106
201
|
|
|
107
202
|
# Composable mixes in composition methods to classes.
|
|
108
203
|
# such as #>>, #|, #not, and others.
|
|
109
204
|
# Any Composable class can participate in Plumb compositions.
|
|
110
|
-
# A host object only needs to implement the Step interface `call(Result
|
|
205
|
+
# A host object only needs to implement the Step interface `call(Result) => Result`
|
|
111
206
|
module Composable
|
|
112
207
|
include Callable
|
|
113
208
|
|
|
@@ -127,14 +222,22 @@ module Plumb
|
|
|
127
222
|
#
|
|
128
223
|
# @example
|
|
129
224
|
# ten = Composable.wrap(10)
|
|
130
|
-
# ten.resolve(10) # => Result
|
|
131
|
-
# ten.resolve(11) # => Result
|
|
225
|
+
# ten.resolve(10) # => a valid Result
|
|
226
|
+
# ten.resolve(11) # => an invalid Result
|
|
132
227
|
#
|
|
133
228
|
# @param callable [Object]
|
|
134
229
|
# @return [Composable]
|
|
135
230
|
def self.wrap(callable)
|
|
136
231
|
if callable.is_a?(Composable)
|
|
137
232
|
callable
|
|
233
|
+
elsif callable.respond_to?(:to_composable)
|
|
234
|
+
# The context-free resolution hook for objects that become a type on
|
|
235
|
+
# demand: an Encoder class resolves to its default (declared) direction; a
|
|
236
|
+
# Codec raises, having no type to become. Used in schema literals,
|
|
237
|
+
# `Array[Enc]`, `#/`, etc. The composition operators (#>>, #|, #&)
|
|
238
|
+
# consult #to_plumb_type BEFORE wrapping, so a composed operand can
|
|
239
|
+
# resolve against context and never reaches this branch.
|
|
240
|
+
wrap(callable.to_composable)
|
|
138
241
|
elsif callable.is_a?(::Hash)
|
|
139
242
|
HashClass.new(schema: callable)
|
|
140
243
|
elsif callable.is_a?(::Array)
|
|
@@ -148,12 +251,31 @@ module Plumb
|
|
|
148
251
|
end
|
|
149
252
|
Types::Array[element_type]
|
|
150
253
|
elsif callable.respond_to?(:call)
|
|
151
|
-
|
|
254
|
+
Function.opaque(callable)
|
|
255
|
+
elsif Constraint.literal_matcher?(callable)
|
|
256
|
+
# Equality literals use ValueClass; broader matchers use Constraint.
|
|
257
|
+
ValueClass.new(callable)
|
|
152
258
|
else
|
|
153
|
-
|
|
259
|
+
Constraint.new(callable)
|
|
154
260
|
end
|
|
155
261
|
end
|
|
156
262
|
|
|
263
|
+
# Resolve `other` against a composition context and wrap it as a
|
|
264
|
+
# Composable — the full normalization for the RIGHT operand of a
|
|
265
|
+
# composition operator. Consults the operand's #to_plumb_type hook when it
|
|
266
|
+
# has one (raw values — procs, hash literals — don't, and skip straight to
|
|
267
|
+
# wrapping), so callers never need a #respond_to? check. EVERY custom
|
|
268
|
+
# operator implementation should route its operand through here.
|
|
269
|
+
#
|
|
270
|
+
# @param other [Object] the right operand
|
|
271
|
+
# @param op [Symbol] the composition operator being resolved (:>>, :| or :&)
|
|
272
|
+
# @param left [Composable] the left operand
|
|
273
|
+
# @return [Composable]
|
|
274
|
+
def self.resolve_operand(other, op:, left:)
|
|
275
|
+
other = other.to_plumb_type(op:, left:) if other.respond_to?(:to_plumb_type)
|
|
276
|
+
wrap(other)
|
|
277
|
+
end
|
|
278
|
+
|
|
157
279
|
# A helper to wrap a block in a Step that will defer execution.
|
|
158
280
|
# This so that types can be used recursively in compositions.
|
|
159
281
|
# @example
|
|
@@ -165,35 +287,228 @@ module Plumb
|
|
|
165
287
|
Deferred.new(definition || block)
|
|
166
288
|
end
|
|
167
289
|
|
|
290
|
+
def input_type = self
|
|
291
|
+
def output_type = self
|
|
292
|
+
|
|
293
|
+
# Composition-context resolution hook, consulted on the RIGHT operand of
|
|
294
|
+
# #>>, #| and #& before wrapping/type-checking — via
|
|
295
|
+
# Composable.resolve_operand, which any custom operator implementation
|
|
296
|
+
# should route its operand through. It lets an operand
|
|
297
|
+
# resolve itself against the composition context: an Encoder picks the
|
|
298
|
+
# direction to run in from what `left` produces; a Codec builds an encode
|
|
299
|
+
# rewrite of `left`'s output. The returned value replaces the operand in
|
|
300
|
+
# the composition. Default: identity — every ordinary type composes as
|
|
301
|
+
# itself.
|
|
302
|
+
#
|
|
303
|
+
# @param op [Symbol] the composition operator (:>>, :| or :&)
|
|
304
|
+
# @param left [Composable] the left operand
|
|
305
|
+
# @return [Composable]
|
|
306
|
+
def to_plumb_type(op:, left:) = self
|
|
307
|
+
|
|
308
|
+
# The type that carries this node's identity for the subtype relation. A
|
|
309
|
+
# value-preserving type IS its own identity (the default). A value-converting
|
|
310
|
+
# type is identified by what it *produces*, so it projects onto a DISTINCT
|
|
311
|
+
# type (see Function#subtype_identity => output_type): Plumb::Subtyping.subtype?
|
|
312
|
+
# reduces `a <= b` to `produced(a) <= b` before consulting the leaf hooks.
|
|
313
|
+
#
|
|
314
|
+
# CONTRACT: only return a value other than `self` when that value is a
|
|
315
|
+
# genuinely different node. Returning `self` here is a no-op (subtype? guards
|
|
316
|
+
# with `!equal?(self)`, so it simply won't reduce); returning a node whose own
|
|
317
|
+
# #subtype_identity loops back would recurse forever. This is the extension
|
|
318
|
+
# point for building custom transforming types that play well with subtyping
|
|
319
|
+
# WITHOUT subclassing Function.
|
|
320
|
+
def subtype_identity = self
|
|
321
|
+
|
|
322
|
+
# The type this step accepts as the consumer of a `left >> self` chain — the
|
|
323
|
+
# values its #call processes without rejecting outright. Defaults to what it
|
|
324
|
+
# takes as input (its resolved #input_type): right for plain matchers and for
|
|
325
|
+
# conversion/consumer types (Function, Stream, Pipeline — they accept their
|
|
326
|
+
# declared input, so they need no override). Two kinds of type override it:
|
|
327
|
+
# - a refinement (And): its #input_type is only the type the chain opens
|
|
328
|
+
# with and would drop the constraint it adds, so it accepts its *output*;
|
|
329
|
+
# - a Hash: it relaxes each field to what that field accepts.
|
|
330
|
+
# Consulted by Plumb::Subtyping when checking `#>>`.
|
|
331
|
+
def accepted_type = Plumb::Subtyping.resolved_input(self)
|
|
332
|
+
|
|
333
|
+
# Whether running this step twice in a row is the same as running it once,
|
|
334
|
+
# i.e. it validates without changing the value. Lets `#>>` drop a redundant
|
|
335
|
+
# `X >> X`. Default false; only types that never transform the value opt in
|
|
336
|
+
# (see Constraint). A transform must NOT be idempotent — `X >> X` would
|
|
337
|
+
# apply it twice.
|
|
338
|
+
def idempotent? = false
|
|
339
|
+
|
|
340
|
+
# Whether this type returns its input value UNCHANGED on success — a
|
|
341
|
+
# coreflexive refinement (a pure filter). Lets `#|` absorb a redundant
|
|
342
|
+
# branch (`Integer | Numeric == Numeric`) without dropping a coercion. See
|
|
343
|
+
# Optimizer.reduce_union, which memoizes this per frozen node in TypeCache.
|
|
344
|
+
# Default false; refinements opt in, transforms stay false. A covariant
|
|
345
|
+
# container (Array/Tuple/HashMap) preserves the value exactly when all its
|
|
346
|
+
# element/child types do — so `Array[Integer] >> Array[Numeric]` collapses
|
|
347
|
+
# like the scalar `Integer >> Numeric` — while a container that reshapes the
|
|
348
|
+
# value (a filtered map dropping entries, a record dropping undeclared keys)
|
|
349
|
+
# stays false. A stronger property than #idempotent? (value-preserving ⟹
|
|
350
|
+
# idempotent).
|
|
351
|
+
def value_preserving? = false
|
|
352
|
+
|
|
353
|
+
# Fuse `self >> other` into a single node when the runtime checks at the
|
|
354
|
+
# boundary between them are provably redundant, or nil when fusion doesn't
|
|
355
|
+
# apply. A reduction rung in Optimizer.reduce_step, so both `#>>` and `#/`
|
|
356
|
+
# reach it. Implemented by Function (transform fusion) and CovariantFusion
|
|
357
|
+
# (the functor law for containers).
|
|
358
|
+
def fuse_with(_other) = nil
|
|
359
|
+
|
|
360
|
+
# Can Function#fuse_with drop this node's boundary checks? Only the node
|
|
361
|
+
# knows: the answer is yes exactly when its #call runs the standard
|
|
362
|
+
# input -> fn -> output mapping, so the checks removed at a seam are checks
|
|
363
|
+
# it would really have run. Default false — a node opts in by saying so.
|
|
364
|
+
# @see Function#fusable_step?
|
|
365
|
+
def fusable_step? = false
|
|
366
|
+
|
|
367
|
+
# BOUNDARY ABSORPTION — the one-sided companions to #fuse_with, for a seam
|
|
368
|
+
# where only one side is a typed step and the other is a plain type. A typed
|
|
369
|
+
# step already RUNS its boundary types as steps (`result.map(input).map(fn)
|
|
370
|
+
# .map(output)`), so a neighbouring type can move into the matching slot and
|
|
371
|
+
# the node then does the same work in one hop instead of two.
|
|
372
|
+
#
|
|
373
|
+
# `self >> type` -> #absorb_output(type), asked of the LEFT (the step)
|
|
374
|
+
# `type >> self` -> #absorb_input(type), asked of the RIGHT (the step)
|
|
375
|
+
#
|
|
376
|
+
# Return the rebuilt node, or nil to decline. Implemented by Function (which
|
|
377
|
+
# owns the soundness conditions) and re-associated by And, so a step in the
|
|
378
|
+
# middle of a chain is reachable. Reached from Optimizer.reduce_step as its
|
|
379
|
+
# LAST rung: absorption only fires where no other reduction applies.
|
|
380
|
+
def absorb_output(_type) = nil
|
|
381
|
+
def absorb_input(_type) = nil
|
|
382
|
+
|
|
168
383
|
# Chain two composable objects together.
|
|
169
384
|
# A.K.A "and" or "sequence"
|
|
170
385
|
# @example
|
|
171
386
|
# Step1 >> Step2 >> Step3
|
|
172
387
|
#
|
|
388
|
+
# Type-checks the composition by subsumption: everything `self` produces
|
|
389
|
+
# must be acceptable to `other` (`self`'s output a subtype of `other`'s
|
|
390
|
+
# input), else it raises Plumb::TypeError — eg. `String >> Integer`, or
|
|
391
|
+
# `Integer[0..40] >> Integer[2..10]` (the left can emit values the right
|
|
392
|
+
# rejects). To narrow a value, use `#[]` / `#transform(...)[...]` (a
|
|
393
|
+
# refinement is a runtime-checked cast, built directly and not subtype-
|
|
394
|
+
# checked). The check is permissive only where types are unknown: opaque
|
|
395
|
+
# steps (plain procs, transforms, narrowing matchers) report Any and opt out.
|
|
396
|
+
#
|
|
173
397
|
# @param other [Composable]
|
|
398
|
+
# @raise [Plumb::TypeError] when `self`'s output is not a subtype of `other`'s input.
|
|
174
399
|
# @return [And]
|
|
175
400
|
def >>(other)
|
|
176
|
-
|
|
401
|
+
other = Composable.resolve_operand(other, op: :>>, left: self)
|
|
402
|
+
# `X >> X` is redundant for a value-preserving validator (eg. Types::String):
|
|
403
|
+
# validating the same value twice is the same as once. Gated on #idempotent?
|
|
404
|
+
# so transforms — where `X >> X` would apply the change twice — never collapse.
|
|
405
|
+
return self if idempotent? && self == other
|
|
406
|
+
|
|
407
|
+
Plumb::Subtyping.check_composable!(self, other)
|
|
408
|
+
# Drop what `other` re-asserts that `self` already guarantees. reduce_step
|
|
409
|
+
# folds a base-type gate into a Constraint chain (`Integer[0..100] >>
|
|
410
|
+
# Integer[-10..110]` -> `Integer[0..100]`, `::Integer` validated once);
|
|
411
|
+
# redundant_refinement? does the same for any value-preserving `other` that
|
|
412
|
+
# `self` already subsumes (`String.where(size: 3..10) >> .where(size: 0..)`
|
|
413
|
+
# -> the former). A non-redundant `other` (a transform, or a narrowing
|
|
414
|
+
# refinement) stays an And.
|
|
415
|
+
Plumb::Optimizer.rewrite_step(self, other)
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Compose like #>> but WITHOUT the strict subtype check — the escape hatch
|
|
419
|
+
# for chains the checker can't prove safe but you know are (eg. a narrowing
|
|
420
|
+
# like `Types::Integer / Types::Integer[1..10]`, or feeding a producer whose
|
|
421
|
+
# output you know the right side accepts). You assert the composition is
|
|
422
|
+
# valid; it is still runtime-checked when data flows through. Reduces and
|
|
423
|
+
# builds the same refinement as #>> (just skipping the build-time check), so
|
|
424
|
+
# the result participates in subtyping like any other refinement. The `/` reads as
|
|
425
|
+
# `Pathname#/` does — "join the next segment". When `self` is the Any top the
|
|
426
|
+
# right side stands alone, consistent with #[].
|
|
427
|
+
#
|
|
428
|
+
# @param other [Composable]
|
|
429
|
+
# @return [Composable]
|
|
430
|
+
def /(other)
|
|
431
|
+
constrain(Composable.wrap(other))
|
|
177
432
|
end
|
|
178
433
|
|
|
179
434
|
# Chain two composable objects together as a disjunction ("or").
|
|
435
|
+
# When one value-preserving branch subsumes the other (`Integer | Numeric`,
|
|
436
|
+
# or `X | X`), the union absorbs to the wider branch — see
|
|
437
|
+
# Optimizer.reduce_union. Functions/containers never reduce (they may accept
|
|
438
|
+
# inputs the survivor rejects), so coercion unions are preserved.
|
|
180
439
|
#
|
|
181
440
|
# @param other [Composable]
|
|
182
|
-
# @return [
|
|
441
|
+
# @return [Composable]
|
|
183
442
|
def |(other)
|
|
184
|
-
|
|
443
|
+
other = Composable.resolve_operand(other, op: :|, left: self)
|
|
444
|
+
return self if other.is_a?(NeverClass) # X | Never == X
|
|
445
|
+
|
|
446
|
+
Plumb::Optimizer.rewrite_union(self, other)
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# Intersection ("and"/meet) — the symmetric dual of #|. Builds the greatest
|
|
450
|
+
# lower bound of `self` and `other`: the type describing values that satisfy
|
|
451
|
+
# BOTH. Unlike #>>, it is order-independent and not subtype-checked. The
|
|
452
|
+
# reducer (Subtyping.intersect) narrows where it can — intersecting Ranges/
|
|
453
|
+
# Sets (`Integer[2..] & Integer[0..100]` == `Integer[2..100]`), covariant
|
|
454
|
+
# containers, and distributing over unions — and collapses a PROVABLY-empty
|
|
455
|
+
# intersection to `Types::Never` (`Integer[2..10] & Integer[11..100]`,
|
|
456
|
+
# `String & Integer`). When it can prove neither a narrowing nor emptiness it
|
|
457
|
+
# falls back to `Conjunction.build` — a runtime intersection where both sides
|
|
458
|
+
# must pass.
|
|
459
|
+
#
|
|
460
|
+
# @param other [Composable]
|
|
461
|
+
# @return [Composable]
|
|
462
|
+
def &(other)
|
|
463
|
+
other = Composable.resolve_operand(other, op: :&, left: self)
|
|
464
|
+
Plumb::Subtyping.intersect(self, other) || Conjunction.build(self, other)
|
|
185
465
|
end
|
|
186
466
|
|
|
187
467
|
# Transform value. Requires specifying the resulting type of the value after transformation.
|
|
188
468
|
# @example
|
|
189
469
|
# Types::String.transform(Types::Symbol, &:to_sym)
|
|
190
470
|
#
|
|
191
|
-
#
|
|
471
|
+
# Shorthand: a single conversion symbol (see COERCION_METHODS) expands to a
|
|
472
|
+
# typed transform to the method's result type, using the symbol as the
|
|
473
|
+
# callable. When the input's base Ruby type is known, it also validates that
|
|
474
|
+
# the type actually responds to the method.
|
|
475
|
+
# @example
|
|
476
|
+
# Types::String.transform(:to_i) # => Transform to Integer, via :to_i
|
|
477
|
+
# Types::Integer.transform(:to_sym) # raises: Integer has no #to_sym
|
|
478
|
+
#
|
|
479
|
+
# @param target_type [Class, Symbol] the output type, or a conversion symbol
|
|
192
480
|
# @param callable [#call, nil] a callable that will be applied to the value, or nil if block provided
|
|
193
481
|
# @param block [Proc] a block that will be applied to the value, or nil if callable provided
|
|
194
|
-
# @return [
|
|
482
|
+
# @return [Function]
|
|
195
483
|
def transform(target_type, callable = nil, &block)
|
|
196
|
-
|
|
484
|
+
if target_type.is_a?(::Symbol) && callable.nil? && block.nil? && (out = COERCION_METHODS[target_type])
|
|
485
|
+
return coercion_transform(target_type, out)
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
# Explicit output type + a coercion symbol as the callable
|
|
489
|
+
# (eg. `#transform(::Numeric, :to_f)`). Since the symbol's result type is
|
|
490
|
+
# known, we can compare it against the declared output at composition time:
|
|
491
|
+
# - `ret <= target_type` => the produced value is always within the
|
|
492
|
+
# declared type, so the runtime output check is redundant (guaranteed).
|
|
493
|
+
# - `ret` and `target_type` disjoint (neither is a subtype of the other)
|
|
494
|
+
# => no value can be an instance of both, so the transform would reject
|
|
495
|
+
# EVERY input. That's a composition error, not a runtime one — raise now.
|
|
496
|
+
# - otherwise (`target_type < ret`, a genuine narrowing) => may or may not
|
|
497
|
+
# hold at runtime; leave the output check to do its job.
|
|
498
|
+
if callable.is_a?(::Symbol) && block.nil? && (ret = COERCION_METHODS[callable])
|
|
499
|
+
guaranteed = false
|
|
500
|
+
if target_type.is_a?(::Module)
|
|
501
|
+
if ret <= target_type
|
|
502
|
+
guaranteed = true
|
|
503
|
+
elsif !(target_type <= ret)
|
|
504
|
+
raise ArgumentError,
|
|
505
|
+
":#{callable} produces a #{ret}, which is never a #{target_type}"
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
return transform_step(target_type, callable.to_proc, guaranteed:)
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
transform_step(target_type, callable || block || Plumb::NOOP)
|
|
197
512
|
end
|
|
198
513
|
|
|
199
514
|
# Pass the value through an arbitrary validation
|
|
@@ -202,9 +517,11 @@ module Plumb
|
|
|
202
517
|
#
|
|
203
518
|
# @param errors [String] error message to use when validation fails
|
|
204
519
|
# @param block [Proc] a block that will be applied to the value
|
|
205
|
-
# @return [
|
|
520
|
+
# @return [Constraint]
|
|
206
521
|
def check(errors = 'did not pass the check', &block)
|
|
207
|
-
|
|
522
|
+
# A refinement, not a sequence: build the matcher over `self` as its base so
|
|
523
|
+
# the checked value keeps `self`'s type (`User.check { … }` is still a User).
|
|
524
|
+
Constraint.new(block, base: self, error: errors, label: errors)
|
|
208
525
|
end
|
|
209
526
|
|
|
210
527
|
# Return a new Step with added metadata, or build step metadata if no argument is provided.
|
|
@@ -253,7 +570,7 @@ module Plumb
|
|
|
253
570
|
# @param value [Object]
|
|
254
571
|
# @rerurn [And]
|
|
255
572
|
def value(val)
|
|
256
|
-
|
|
573
|
+
constrain(ValueClass.new(val))
|
|
257
574
|
end
|
|
258
575
|
|
|
259
576
|
# Alias of `#[]`
|
|
@@ -262,12 +579,47 @@ module Plumb
|
|
|
262
579
|
# email = Types::String['@']
|
|
263
580
|
#
|
|
264
581
|
# @param args [Array<Object>]
|
|
265
|
-
# @return [
|
|
582
|
+
# @return [Constraint]
|
|
266
583
|
def match(*args)
|
|
267
|
-
|
|
268
|
-
|
|
584
|
+
# A refinement returns a base-carrying Constraint directly (not an
|
|
585
|
+
# `And(self, matcher)`): the matcher records `self` as its base, so it
|
|
586
|
+
# subtypes and composes as "a `self` narrowed by the matcher". When `self`
|
|
587
|
+
# is the Any top the matcher stands alone (`Any[String]` == the String
|
|
588
|
+
# matcher), matching the collapsing `AnyClass#>>` provides. Routed through
|
|
589
|
+
# Constraint.narrow so stacked Range refinements intersect
|
|
590
|
+
# (`Integer[0..100][10..]` == `Integer[10..100]`).
|
|
591
|
+
base = is_a?(AnyClass) ? nil : self
|
|
592
|
+
# A bare equality literal uses ValueClass so `Any[5]` and `Value[5]` share
|
|
593
|
+
# one subtype identity. A based literal remains a Constraint.
|
|
594
|
+
return ValueClass.new(args.first) if base.nil? && args.size == 1 && Constraint.literal_matcher?(args.first)
|
|
595
|
+
|
|
596
|
+
Constraint.narrow(base, *args)
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
# Sugar over #match: a splatted list of values becomes a Set membership
|
|
600
|
+
# matcher, so `Integer[1, 2, 3]` == `Integer[Set[1, 2, 3]]` (and composes /
|
|
601
|
+
# reduces like any Set constraint). A single argument is used as-is — a Range,
|
|
602
|
+
# Regexp, Set, class or literal value.
|
|
603
|
+
# @example
|
|
604
|
+
# Types::String['a', 'b', 'c'] # one of these three strings
|
|
605
|
+
def [](*args) = match(args.size > 1 ? ::Set.new(args) : args.first)
|
|
606
|
+
|
|
607
|
+
# Narrow `self` with a constraint. A constraint refines rather than
|
|
608
|
+
# sequences a new type, so this bypasses the #>> composition type-check
|
|
609
|
+
# (eg. `Generic[::URI::HTTP]` narrows a URI to an HTTP URI). When `self` is
|
|
610
|
+
# the Any top type the constraint stands alone (`Any[::String]` == the
|
|
611
|
+
# String matcher), preserving the collapsing that `AnyClass#>>` provides.
|
|
612
|
+
# Applies the same base-type reduction as #>> (see Optimizer.reduce_step), so
|
|
613
|
+
# `Integer[0..40] / Integer[2..10]` re-parents to `Integer[0..40][2..10]`
|
|
614
|
+
# rather than re-checking `::Integer`. `reduce_step` bails for a non-Constraint
|
|
615
|
+
# constraint (eg. #value's ValueClass), leaving the And.
|
|
616
|
+
# @param constraint [Composable]
|
|
617
|
+
# @return [Composable]
|
|
618
|
+
private def constrain(constraint)
|
|
619
|
+
return constraint if is_a?(AnyClass)
|
|
269
620
|
|
|
270
|
-
|
|
621
|
+
Plumb::Optimizer.rewrite_refinement(self, constraint)
|
|
622
|
+
end
|
|
271
623
|
|
|
272
624
|
# Support #as_node.
|
|
273
625
|
class Node
|
|
@@ -296,6 +648,17 @@ module Plumb
|
|
|
296
648
|
|
|
297
649
|
def call(result) = type.call(result)
|
|
298
650
|
|
|
651
|
+
# A Node is a transparent wrapper (it only re-labels its node_name): it
|
|
652
|
+
# delegates type-flow to the wrapped type.
|
|
653
|
+
def input_type = type.input_type
|
|
654
|
+
def output_type = type.output_type
|
|
655
|
+
def value_preserving? = type.value_preserving?
|
|
656
|
+
|
|
657
|
+
# Inspect as the wrapped type. Constant-assigned nodes (Types::Boolean,
|
|
658
|
+
# Email) are renamed by constant assignment and ignore this; a runtime node
|
|
659
|
+
# (eg. a factored :refined_union) would otherwise show "Composable::Node".
|
|
660
|
+
private def _inspect = type.inspect
|
|
661
|
+
|
|
299
662
|
# Two nodes are equal when they wrap the same type with the same
|
|
300
663
|
# node_name and args. The default Composable#== compares #children,
|
|
301
664
|
# but a Node holds its identity in @node_name/@type/@args, so without
|
|
@@ -326,8 +689,14 @@ module Plumb
|
|
|
326
689
|
# type = Types::Array.where(size: 1..10)
|
|
327
690
|
# type = Types::String.where(bytesize: 1..10)
|
|
328
691
|
#
|
|
329
|
-
# @param attrs [Hash]
|
|
692
|
+
# @param attrs [Hash{Symbol, String => Object}] attribute name => matcher
|
|
330
693
|
def where(attrs)
|
|
694
|
+
unless attrs.is_a?(::Hash) && !attrs.empty?
|
|
695
|
+
raise ArgumentError,
|
|
696
|
+
'#where expects a non-empty Hash of attribute => matcher ' \
|
|
697
|
+
"(eg. `where(size: 1..10)`), got #{attrs.inspect}"
|
|
698
|
+
end
|
|
699
|
+
|
|
331
700
|
attrs.reduce(self) do |t, (name, value)|
|
|
332
701
|
t >> AttributeValueMatch.new(t, name, value)
|
|
333
702
|
end
|
|
@@ -344,11 +713,11 @@ module Plumb
|
|
|
344
713
|
# Mode 1.b: #policy(:name) a single policy without an argument
|
|
345
714
|
# Mode 2: #policy(p1: value, p2: value) multiple policies with arguments
|
|
346
715
|
# The latter mode will be expanded to multiple #policy calls.
|
|
347
|
-
# @return [
|
|
716
|
+
# @return [Composable]
|
|
348
717
|
def policy(*args, &blk)
|
|
349
718
|
case args
|
|
350
719
|
in [::Symbol => name, *rest] # #policy(:name, arg)
|
|
351
|
-
types =
|
|
720
|
+
types = Plumb.resolve_base_types(output_type).uniq
|
|
352
721
|
|
|
353
722
|
bargs = [self]
|
|
354
723
|
arg = Undefined
|
|
@@ -385,7 +754,45 @@ module Plumb
|
|
|
385
754
|
# @param factory_method [Symbol] method to call on the class to instantiate it.
|
|
386
755
|
# @return [And]
|
|
387
756
|
def build(cns, factory_method = :new, &block)
|
|
388
|
-
|
|
757
|
+
# Without a block the callable is a fresh lambda per call, so name what the
|
|
758
|
+
# step actually IS — the (class, factory method) pair — as its identity.
|
|
759
|
+
transform_step(cns, block || ->(value) { cns.send(factory_method, value) },
|
|
760
|
+
identity: block || [cns, factory_method])
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
# Build a Function that validates the input (self), applies a value-level
|
|
764
|
+
# callable, and declares `target_type` as the (validated) output type.
|
|
765
|
+
# @param identity [Object, nil] what the step IS, for Function#==. Defaults to
|
|
766
|
+
# `callable`; pass it explicitly when `callable` is itself built fresh here
|
|
767
|
+
# and so would differ between two identical constructions (see #build).
|
|
768
|
+
private def transform_step(target_type, callable, guaranteed: false, identity: nil)
|
|
769
|
+
klass = guaranteed ? GuaranteedFunction : Function
|
|
770
|
+
# Flip the cursor in place with the transformed value — the transform owns
|
|
771
|
+
# the result it is handed (the argument is evaluated first, reading the
|
|
772
|
+
# pre-transform value), so no fresh Result is needed.
|
|
773
|
+
#
|
|
774
|
+
# The wrapper lambda is new on every call, so it cannot serve as the node's
|
|
775
|
+
# identity — `callable` (the caller's own) does.
|
|
776
|
+
klass.new(self, Composable.wrap(target_type),
|
|
777
|
+
->(result) { result.valid!(callable.call(result.value)) },
|
|
778
|
+
identity: identity || callable)
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
# Expand `#transform(:to_i)` into a typed transform to `output_type`, using
|
|
782
|
+
# the conversion symbol itself as the callable. If the input's base Ruby
|
|
783
|
+
# type(s) can be resolved, validate that they define the method (so
|
|
784
|
+
# `Types::Integer.transform(:to_sym)` fails loudly at build time).
|
|
785
|
+
private def coercion_transform(method_name, output_type)
|
|
786
|
+
bases = Plumb.resolve_base_types(self)
|
|
787
|
+
unless bases.empty? || bases.all? { |k| k.is_a?(::Module) && k.method_defined?(method_name) }
|
|
788
|
+
raise Plumb::TypeError,
|
|
789
|
+
"cannot #transform(#{method_name.inspect}): " \
|
|
790
|
+
"#{bases.map(&:inspect).join(' / ')} does not define ##{method_name}"
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
# The output type IS the coercion method's result type, so the produced
|
|
794
|
+
# value is guaranteed to match — skip the runtime output check.
|
|
795
|
+
transform_step(output_type, method_name.to_proc, guaranteed: true)
|
|
389
796
|
end
|
|
390
797
|
|
|
391
798
|
# Always return a static value, regardless of the input.
|
|
@@ -398,12 +805,6 @@ module Plumb
|
|
|
398
805
|
# @param value [Object]
|
|
399
806
|
# @return [And]
|
|
400
807
|
def static(value)
|
|
401
|
-
my_type = Array(metadata[:type]).first
|
|
402
|
-
unless my_type.nil? || value.instance_of?(my_type)
|
|
403
|
-
raise ArgumentError,
|
|
404
|
-
"can't set a static #{value.class} value for a #{my_type} step"
|
|
405
|
-
end
|
|
406
|
-
|
|
407
808
|
StaticClass.new(value) >> self
|
|
408
809
|
end
|
|
409
810
|
|
|
@@ -419,7 +820,9 @@ module Plumb
|
|
|
419
820
|
generator ||= block
|
|
420
821
|
raise ArgumentError, 'expected a generator' unless generator.respond_to?(:call)
|
|
421
822
|
|
|
422
|
-
|
|
823
|
+
Function.opaque(inspect: 'generator', identity: [:generate, generator]) do |r|
|
|
824
|
+
r.valid(generator.call)
|
|
825
|
+
end >> self
|
|
423
826
|
end
|
|
424
827
|
|
|
425
828
|
# Build a Plumb::Pipeline with this object as the starting step.
|
|
@@ -445,18 +848,29 @@ module Plumb
|
|
|
445
848
|
JSONSchemaVisitor.call(self, root:)
|
|
446
849
|
end
|
|
447
850
|
|
|
851
|
+
# Render this composition as a Mermaid `flowchart`. `>>` becomes sequential
|
|
852
|
+
# arrows and `|` becomes a fork. See Plumb::MermaidVisitor.
|
|
853
|
+
# @option direction [String] flowchart direction ('LR', 'TB', …)
|
|
854
|
+
# @return [String]
|
|
855
|
+
def to_mermaid(direction: 'LR')
|
|
856
|
+
MermaidVisitor.call(self, direction:)
|
|
857
|
+
end
|
|
858
|
+
|
|
448
859
|
# Build a step that will invoke one or more methods on the value.
|
|
449
860
|
# Ex 1: Types::String.invoke(:downcase)
|
|
450
861
|
# Ex 2: Types::Array.invoke(:[], 1)
|
|
451
862
|
# Ex 3 chain of methods: Types::String.invoke([:downcase, :to_sym])
|
|
452
|
-
# @return [
|
|
863
|
+
# @return [Composable]
|
|
453
864
|
def invoke(*args, &block)
|
|
454
865
|
case args
|
|
455
866
|
in [::Symbol => method_name, *rest]
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
)
|
|
867
|
+
label = [method_name.inspect, rest.inspect].join(' ')
|
|
868
|
+
# The block is new on each call, but the step it implements is determined
|
|
869
|
+
# by the method and its arguments — so name that as the identity, and two
|
|
870
|
+
# `invoke(:downcase)` steps stay #== (see Function#==).
|
|
871
|
+
self >> Function.opaque(inspect: label, identity: [:invoke, method_name, rest, block]) do |result|
|
|
872
|
+
result.valid(result.value.public_send(method_name, *rest, &block))
|
|
873
|
+
end
|
|
460
874
|
in [Array => methods] if methods.all? { |m| m.is_a?(Symbol) }
|
|
461
875
|
methods.reduce(self) { |step, method| step.invoke(method) }
|
|
462
876
|
else
|
|
@@ -468,7 +882,5 @@ end
|
|
|
468
882
|
|
|
469
883
|
require 'plumb/deferred'
|
|
470
884
|
require 'plumb/attribute_value_match'
|
|
471
|
-
require 'plumb/transform'
|
|
472
885
|
require 'plumb/policy'
|
|
473
|
-
require 'plumb/build'
|
|
474
886
|
require 'plumb/metadata'
|