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,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'plumb/composable'
|
|
4
|
+
require 'plumb/function'
|
|
5
|
+
|
|
6
|
+
module Plumb
|
|
7
|
+
# A reversible, type-aware transform between two representations of a value —
|
|
8
|
+
# an input type and an output type. Neither side need be a "wire" format: a
|
|
9
|
+
# serialized string and a parsed Ruby object, or one in-memory data structure
|
|
10
|
+
# and another, are equally valid. Declared class-based, with the types given
|
|
11
|
+
# as a one-pair Hash literal:
|
|
12
|
+
#
|
|
13
|
+
# DateRange = Types::Range[Types::Date]
|
|
14
|
+
# JSONDateRange = Types::Hash[from: Types::Date, to: Types::Date]
|
|
15
|
+
#
|
|
16
|
+
# class JSONDateRangeEncoder < Plumb::Encoder[JSONDateRange => DateRange]
|
|
17
|
+
# def encode(range) = { from: range.begin, to: range.end }
|
|
18
|
+
# def decode(hash) = hash[:from]..hash[:to]
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# The DEFAULT direction is the declared one (`input => output`, running
|
|
22
|
+
# #decode) — an encoder composes like a normal Function:
|
|
23
|
+
#
|
|
24
|
+
# JSONDateRange >> JSONDateRangeEncoder >> DateRange # decode
|
|
25
|
+
#
|
|
26
|
+
# Composed next to a type that matches its OUTPUT side instead, it
|
|
27
|
+
# transparently runs the inverse (#encode, input/output swapped):
|
|
28
|
+
#
|
|
29
|
+
# DateRange >> JSONDateRangeEncoder >> JSONDateRange # encode
|
|
30
|
+
#
|
|
31
|
+
# When the context gives no signal (schema literals, `#/`, `.parse`, an
|
|
32
|
+
# opaque/Any neighbour) the default direction is used; `.decoding` /
|
|
33
|
+
# `.encoding` are the explicit escape hatches. Each direction is a plain
|
|
34
|
+
# {Function} whose proc runs the encoder's method — so subtyping, `#>>`
|
|
35
|
+
# composition checks and reductions, visitors and the JSON Schema all work
|
|
36
|
+
# on it with no encoder-specific machinery.
|
|
37
|
+
class Encoder
|
|
38
|
+
# Build the parameterized superclass: `Encoder[Input => Output]`.
|
|
39
|
+
# The pair is a one-pair Hash literal; both sides are wrapped as Plumb
|
|
40
|
+
# types (a raw Hash becomes a HashClass, so
|
|
41
|
+
# `Encoder[{from: Date} => DateRange]` works).
|
|
42
|
+
#
|
|
43
|
+
# @param pair [Hash] a one-pair Hash: input type => output type
|
|
44
|
+
# @return [Class]
|
|
45
|
+
def self.[](pair)
|
|
46
|
+
unless pair.is_a?(::Hash) && pair.size == 1
|
|
47
|
+
raise ArgumentError,
|
|
48
|
+
"#{self}[Input => Output] expects a one-pair Hash (eg. Encoder[Types::String => Types::Date]), " \
|
|
49
|
+
"got #{pair.inspect}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
input, output = pair.first
|
|
53
|
+
input = Composable.wrap(input)
|
|
54
|
+
output = Composable.wrap(output)
|
|
55
|
+
# Singleton methods (unlike class ivars) are inherited by the user's
|
|
56
|
+
# subclass, so `class Foo < Encoder[A => B]` gets .input_type/.output_type.
|
|
57
|
+
Class.new(self) do
|
|
58
|
+
define_singleton_method(:input_type) { input }
|
|
59
|
+
define_singleton_method(:output_type) { output }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
class << self
|
|
64
|
+
def input_type
|
|
65
|
+
raise ArgumentError,
|
|
66
|
+
"#{inspect} declares no types; define it as `class #{inspect} < Plumb::Encoder[Input => Output]`"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
alias output_type input_type
|
|
70
|
+
|
|
71
|
+
# The input type to rewrite for a particular matched output type.
|
|
72
|
+
# Fixed for a normal encoder — the declared input type, regardless of what
|
|
73
|
+
# matched. A GENERIC encoder (one whose output_type is a container top like
|
|
74
|
+
# Types::Range, matching any Range[member]) overrides this to BUILD its input
|
|
75
|
+
# from the matched node's structure, so `Range[Date]` yields a
|
|
76
|
+
# `from: Date, to: Date` input and `Range[Integer]` a `from: Integer, …` one.
|
|
77
|
+
# The codec then rewrites that member type through itself as usual. Mirrors
|
|
78
|
+
# how the Rewriter reads an Array's element type off the matched node.
|
|
79
|
+
# @param matched_type [Composable] the type this encoder matched
|
|
80
|
+
# @return [Composable]
|
|
81
|
+
def input_type_for(_matched_type) = input_type
|
|
82
|
+
|
|
83
|
+
# The default step: the declared `input_type -> output_type` direction,
|
|
84
|
+
# running #decode. A plain Function — #call validates the input type,
|
|
85
|
+
# runs the encoder's method, and validates the produced value against
|
|
86
|
+
# the output type (a wrong return value becomes an invalid Result, not
|
|
87
|
+
# silent corruption); subtyping identity, accepted type and `#>>` checks
|
|
88
|
+
# all come with Function. Memoized per subclass (steps are frozen).
|
|
89
|
+
# @return [Function]
|
|
90
|
+
def decoding
|
|
91
|
+
@decoding ||= build_step(:decode, input_type, output_type)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The inverse step: `output_type -> input_type`, running #encode.
|
|
95
|
+
# @return [Function]
|
|
96
|
+
def encoding
|
|
97
|
+
@encoding ||= build_step(:encode, output_type, input_type)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# A direction step with one or both sides substituted — used by
|
|
101
|
+
# Codec::Rewriter to splice in a rewritten input type or a narrowed
|
|
102
|
+
# output type, keeping each rewritten field a single Function node.
|
|
103
|
+
#
|
|
104
|
+
# The substitutions name the ENCODER's declared sides, not the step's
|
|
105
|
+
# positions, so the caller doesn't have to transpose them per direction:
|
|
106
|
+
# `input_side` always replaces the declared input, `output_side` the
|
|
107
|
+
# declared output, whichever end of the step each lands on.
|
|
108
|
+
# @return [Function]
|
|
109
|
+
def step(direction, input_side: nil, output_side: nil)
|
|
110
|
+
base = direction == :decode ? decoding : encoding
|
|
111
|
+
return base unless input_side || output_side
|
|
112
|
+
|
|
113
|
+
in_t, out_t = direction == :decode ? [input_side, output_side] : [output_side, input_side]
|
|
114
|
+
Function.new(in_t || base.input_type, out_t || base.output_type, base.fn)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Composition-context direction pick when the encoder is the RIGHT
|
|
118
|
+
# operand (see Composable#to_plumb_type). For `left >> Enc`, orient by
|
|
119
|
+
# what `left` produces; for a union/intersection, branches describe the
|
|
120
|
+
# same produced value, so orient by what the sibling produces relative to
|
|
121
|
+
# what each direction produces. Falls back to the default direction —
|
|
122
|
+
# the ordinary composition check raises if it genuinely doesn't fit.
|
|
123
|
+
def to_plumb_type(op:, left:)
|
|
124
|
+
produced = Plumb::Subtyping.resolved_output(Composable.wrap(left))
|
|
125
|
+
# `left >> Enc` feeds `left`'s output into the step; in a union/
|
|
126
|
+
# intersection the branches describe the same produced value.
|
|
127
|
+
pick_direction(produced, op == :>> ? :consumes : :produces)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Left-position composition (`Enc >> X`, `Enc | X`, ...): a Class has no
|
|
131
|
+
# Composable operators, so these singleton versions orient against the
|
|
132
|
+
# right operand and delegate to the oriented step.
|
|
133
|
+
def >>(other) = oriented_against(other, :>>) >> other
|
|
134
|
+
def |(other) = oriented_against(other, :|) | other
|
|
135
|
+
def &(other) = oriented_against(other, :&) & other
|
|
136
|
+
|
|
137
|
+
def /(other) = decoding / other
|
|
138
|
+
|
|
139
|
+
# Direct use runs the default direction — also the Composable.wrap hook
|
|
140
|
+
# for context-free positions (schema literals, `Array[Enc]`, `#/`).
|
|
141
|
+
def to_composable = decoding
|
|
142
|
+
def call(result) = decoding.call(result)
|
|
143
|
+
def resolve(...) = decoding.resolve(...)
|
|
144
|
+
def parse(...) = decoding.parse(...)
|
|
145
|
+
|
|
146
|
+
# Value-level conveniences for each direction.
|
|
147
|
+
def encode(value) = encoding.parse(value)
|
|
148
|
+
def decode(value) = decoding.parse(value)
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
# Build one direction as a plain Function. The transform proc wraps the
|
|
153
|
+
# encoder's method: exceptions raised inside #encode/#decode become
|
|
154
|
+
# invalid Results.
|
|
155
|
+
def build_step(direction, in_type, out_type)
|
|
156
|
+
if instance_method(direction).owner == Encoder
|
|
157
|
+
raise ArgumentError, "#{inspect} must implement ##{direction} to be used in this direction"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
run = new.freeze.method(direction)
|
|
161
|
+
encoder_class = self
|
|
162
|
+
step_proc = lambda do |result|
|
|
163
|
+
result.valid!(run.call(result.value))
|
|
164
|
+
rescue StandardError => e
|
|
165
|
+
result.invalid!(errors: "#{encoder_class.inspect}##{direction} failed: #{e.message}")
|
|
166
|
+
end
|
|
167
|
+
Function.new(in_type, out_type, step_proc)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Orientation for the LEFT-position operators above, where the encoder
|
|
171
|
+
# must PRODUCE something the sibling fits: for `>>` the sibling consumes
|
|
172
|
+
# what we produce, so orient by what it ACCEPTS; in a union/intersection
|
|
173
|
+
# both sides describe the same resulting value, so orient by what it
|
|
174
|
+
# produces.
|
|
175
|
+
def oriented_against(other, op)
|
|
176
|
+
other = Composable.wrap(other)
|
|
177
|
+
produced = op == :>> ? Plumb::Subtyping.accepted_type(other) : Plumb::Subtyping.resolved_output(other)
|
|
178
|
+
pick_direction(produced, :produces)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Pick a direction by matching `candidate` against the two declared sides.
|
|
182
|
+
# `orient` says which relation `candidate` stands in to this encoder:
|
|
183
|
+
# :consumes — it is fed INTO the step (a `>>` neighbour's output), so it
|
|
184
|
+
# must match the side the step reads;
|
|
185
|
+
# :produces — it is what the step must yield (a union sibling's value,
|
|
186
|
+
# or a `>>` right side's accepted input).
|
|
187
|
+
# Decode (the declared direction) wins ties and is the fallback: encode is
|
|
188
|
+
# chosen only when it is the unambiguous fit, so an ambiguous, opaque or
|
|
189
|
+
# unmatched candidate keeps the declared direction and the ordinary
|
|
190
|
+
# composition check reports it if it genuinely doesn't fit.
|
|
191
|
+
def pick_direction(candidate, orient)
|
|
192
|
+
decode_side, encode_side = orient == :consumes ? [input_type, output_type] : [output_type, input_type]
|
|
193
|
+
return encoding if Plumb::Subtyping.subtype?(candidate, encode_side) &&
|
|
194
|
+
!Plumb::Subtyping.subtype?(candidate, decode_side)
|
|
195
|
+
|
|
196
|
+
decoding
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# The value-level contract. These are also the sentinels build_step checks
|
|
201
|
+
# (`instance_method(direction).owner == Encoder`), so using an encoder in a
|
|
202
|
+
# direction it doesn't implement fails at BUILD time with a better message
|
|
203
|
+
# than these — which is why they stay deliberately plain.
|
|
204
|
+
def encode(_value) = raise(NotImplementedError, "#{self.class} must implement #encode(value)")
|
|
205
|
+
def decode(_value) = raise(NotImplementedError, "#{self.class} must implement #decode(value)")
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'plumb/composable'
|
|
4
|
+
require 'plumb/typed_step'
|
|
5
|
+
|
|
6
|
+
module Plumb
|
|
7
|
+
# A value-converting join, built by `Composable#transform` / `#build`.
|
|
8
|
+
# Unlike `And` (a refinement), a `Function` changes the value: it validates
|
|
9
|
+
# the input (`input_type`), applies `fn`, then declares `output_type`
|
|
10
|
+
# as the produced type. The input constraints do NOT carry through to the
|
|
11
|
+
# output, so for subtyping a `Function` is identified by what it *produces*
|
|
12
|
+
# (its `output_type`). See Plumb::Subtyping.subtype?.
|
|
13
|
+
class Function
|
|
14
|
+
include Composable
|
|
15
|
+
include TypedStep
|
|
16
|
+
|
|
17
|
+
# Build a typed function from an input => output pair and a
|
|
18
|
+
# callable that produces the new value.
|
|
19
|
+
#
|
|
20
|
+
# Plumb::Function[String => Integer] { |result| result.valid(result.value.size) }
|
|
21
|
+
# Plumb::Function[callable, String => Integer]
|
|
22
|
+
#
|
|
23
|
+
# The callable takes and returns a Result (unlike Composable#transform, whose
|
|
24
|
+
# block takes a value). With no types, both ends default to Types::Any and
|
|
25
|
+
# this delegates to .opaque (use that directly if you want an #inspect label).
|
|
26
|
+
# When input and output are the same type and there's nothing to apply, this
|
|
27
|
+
# is just that type, so it's returned as-is.
|
|
28
|
+
#
|
|
29
|
+
# @param args [Array] (in => out), (callable), or (callable, in => out)
|
|
30
|
+
# @yield [Result] Result => Result
|
|
31
|
+
# @return [Composable]
|
|
32
|
+
def self.[](*args, &block)
|
|
33
|
+
input_type = Types::Any
|
|
34
|
+
output_type = Types::Any
|
|
35
|
+
fn = block || NOOP
|
|
36
|
+
|
|
37
|
+
case args
|
|
38
|
+
in []
|
|
39
|
+
# no types, no callable: defaults apply
|
|
40
|
+
in [clb, Hash => inout] if clb.respond_to?(:call)
|
|
41
|
+
raise ArgumentError, 'expected a callable or a block, not both' if block
|
|
42
|
+
|
|
43
|
+
fn = clb
|
|
44
|
+
input_type, output_type = __set_types(inout)
|
|
45
|
+
in [clb] if clb.respond_to?(:call)
|
|
46
|
+
raise ArgumentError, 'expected a callable or a block, not both' if block
|
|
47
|
+
|
|
48
|
+
fn = clb
|
|
49
|
+
in [Hash => inout]
|
|
50
|
+
input_type, output_type = __set_types(inout)
|
|
51
|
+
else
|
|
52
|
+
raise ArgumentError,
|
|
53
|
+
'expected Plumb::Function[input_type => output_type], Plumb::Function[callable] ' \
|
|
54
|
+
"or Plumb::Function[callable, input_type => output_type]. Got #{args.inspect}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
return input_type if input_type == output_type && fn == NOOP
|
|
58
|
+
|
|
59
|
+
if fn == NOOP
|
|
60
|
+
raise ArgumentError,
|
|
61
|
+
"defined a function (#{input_type} -> #{output_type}), but no explicit " \
|
|
62
|
+
"transform block or callable given. \n" \
|
|
63
|
+
"Should be Plumb::Function[#{input_type} => #{output_type}] { |r| r.valid(new_value_here) }"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
return opaque(fn) if input_type == Types::Any && output_type == Types::Any
|
|
67
|
+
|
|
68
|
+
new(input_type, output_type, fn)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Build an OPAQUE function around a bare `#call(Result) => Result` callable
|
|
72
|
+
# — see #opaque?. This is what `Composable.wrap` produces for anything
|
|
73
|
+
# callable, and the canonical builder for the no-declared-types case:
|
|
74
|
+
# `Function[callable]` is sugar for it and returns the same thing.
|
|
75
|
+
#
|
|
76
|
+
# It exists separately only because it takes an `inspect` label, which `.[]`
|
|
77
|
+
# cannot: declaring any keyword there would make Ruby route the bare hash in
|
|
78
|
+
# `Function[callable, String => Integer]` into keywords ("unknown keyword:
|
|
79
|
+
# String") and break that form.
|
|
80
|
+
#
|
|
81
|
+
# A GuaranteedFunction, because an opaque function's output check IS `Any`,
|
|
82
|
+
# which no value can fail: skipping it is provably redundant rather than a
|
|
83
|
+
# shortcut, which is exactly what that subclass encodes. (The input check is
|
|
84
|
+
# `Any` too and equally redundant, but #call has to run *some* input check
|
|
85
|
+
# for typed functions, so that hop stays.)
|
|
86
|
+
#
|
|
87
|
+
# Takes the callable positionally, or as a block:
|
|
88
|
+
#
|
|
89
|
+
# Plumb::Function.opaque(some_callable)
|
|
90
|
+
# Plumb::Function.opaque(inspect: 'filtered') { |result| result.valid(...) }
|
|
91
|
+
#
|
|
92
|
+
# @param callable [#call, nil] Result => Result
|
|
93
|
+
# @param inspect [String, nil] label to #inspect as, instead of the types
|
|
94
|
+
# @param identity [Object, nil] what the step IS, for #==. An opaque function
|
|
95
|
+
# declares no types, so its callable is all that distinguishes it; pass a
|
|
96
|
+
# deterministic token when the block is built fresh per call (see
|
|
97
|
+
# Composable#invoke).
|
|
98
|
+
# @yield [Result] Result => Result
|
|
99
|
+
# @return [GuaranteedFunction]
|
|
100
|
+
def self.opaque(callable = nil, inspect: nil, identity: nil, &block)
|
|
101
|
+
raise ArgumentError, 'expected a callable or a block, not both' if callable && block
|
|
102
|
+
|
|
103
|
+
fn = callable || block
|
|
104
|
+
raise ArgumentError, 'expected a callable or a block' unless fn.respond_to?(:call)
|
|
105
|
+
|
|
106
|
+
GuaranteedFunction.new(Types::Any, Types::Any, fn, inspect:, identity:, wrapper: true)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def self.__set_types(inout)
|
|
110
|
+
return [Types::Any, Types::Any] if inout.empty?
|
|
111
|
+
raise ArgumentError, 'expected single key input_type => output_type' if inout.size > 1
|
|
112
|
+
|
|
113
|
+
input_type = Composable.wrap(inout.keys.first)
|
|
114
|
+
output_type = Composable.wrap(inout.values.first)
|
|
115
|
+
[input_type, output_type]
|
|
116
|
+
end
|
|
117
|
+
private_class_method :__set_types
|
|
118
|
+
|
|
119
|
+
# `fn` is the value-level callable — `#call(Result) => Result`. Exposed so
|
|
120
|
+
# the Decorator can rebuild the node around it, and so an opaque function
|
|
121
|
+
# can be resolved back to the object it wraps (see Plumb::Attributes.struct_class).
|
|
122
|
+
attr_reader :children, :input_type, :output_type, :fn, :identity
|
|
123
|
+
|
|
124
|
+
# @param inspect [String, nil] label to #inspect as, instead of the types
|
|
125
|
+
# @param identity [Object, nil] what this function IS, for `#==` — see #==.
|
|
126
|
+
# Defaults to `fn`, which is correct whenever `fn` is the caller's own
|
|
127
|
+
# callable rather than a wrapper built around it.
|
|
128
|
+
# @param wrapper [Boolean] whether this node merely WRAPS a caller-supplied
|
|
129
|
+
# callable — see #wraps_callable?. Set by .opaque, the only builder that does.
|
|
130
|
+
def initialize(input_type, output_type, fn = Plumb::NOOP, inspect: nil, identity: nil, wrapper: false)
|
|
131
|
+
@input_type = input_type
|
|
132
|
+
@output_type = output_type
|
|
133
|
+
@fn = fn
|
|
134
|
+
@inspect_label = inspect
|
|
135
|
+
@identity = identity || fn
|
|
136
|
+
@wraps_callable = wrapper
|
|
137
|
+
@children = [input_type, output_type].freeze
|
|
138
|
+
freeze
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Is this node a plain WRAPPER around a callable the caller handed to Plumb (what
|
|
142
|
+
# `Composable.wrap` / `.opaque` build), rather than a #transform / #build /
|
|
143
|
+
# coercion whose #fn is a lambda Plumb built around a value-level block? It is what
|
|
144
|
+
# singles out a node whose #fn is worth reaching for — a struct class, or a proc a
|
|
145
|
+
# decorator wants to replace. @see Plumb::Attributes.struct_class
|
|
146
|
+
#
|
|
147
|
+
# DECLARED at construction, and not derivable from the types, which say nothing
|
|
148
|
+
# about it: boundary absorption moves a neighbouring type INTO a wrapper's slot, so
|
|
149
|
+
# `Types::Integer >> some_proc` is a wrapper with a typed end. @see #absorb_input
|
|
150
|
+
def wraps_callable? = @wraps_callable
|
|
151
|
+
|
|
152
|
+
# Equal when they declare the same types AND apply the same transformation.
|
|
153
|
+
#
|
|
154
|
+
# The default Composable#== compares #children, which for a Function is only
|
|
155
|
+
# `[input_type, output_type]` — so it called any two `String -> Integer` steps
|
|
156
|
+
# equal regardless of what they do, and any two OPAQUE functions equal regardless
|
|
157
|
+
# of their callable. A reducer reading #== as node identity then dropped one:
|
|
158
|
+
# `wrap(proc_a) & wrap(proc_b)` returned just `proc_a`.
|
|
159
|
+
#
|
|
160
|
+
# Compared on #identity, not #fn, because #transform wraps the caller's callable in
|
|
161
|
+
# a FRESH lambda per call — comparing #fn would make two identically-built
|
|
162
|
+
# transforms unequal, and with them every schema containing one. #identity is the
|
|
163
|
+
# caller's own callable, or a deterministic token the builder chose (see
|
|
164
|
+
# Composable#build), so two anonymous blocks still compare unequal.
|
|
165
|
+
def ==(other)
|
|
166
|
+
other.is_a?(self.class) &&
|
|
167
|
+
other.input_type == input_type &&
|
|
168
|
+
other.output_type == output_type &&
|
|
169
|
+
other.identity == identity
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# A Function's #children are [input_type, output_type], so rebuilding it around
|
|
173
|
+
# new ones also has to carry the callable, the inspect label and the #==
|
|
174
|
+
# identity — none of which a caller can see. @see Plumb::NodeMapper
|
|
175
|
+
def with_children(children)
|
|
176
|
+
self.class.new(children[0], children[1], @fn,
|
|
177
|
+
inspect: @inspect_label, identity: @identity, wrapper: @wraps_callable)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private def _inspect
|
|
181
|
+
@inspect_label || %((#{@input_type.inspect} -> #{@output_type.inspect}))
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def call(result)
|
|
185
|
+
result.map(@input_type).map(@fn).map(@output_type)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Wraps this function with its output check for fusion.
|
|
189
|
+
# @return [Proc]
|
|
190
|
+
# @see #fuse_with
|
|
191
|
+
protected def fn_with_output_check
|
|
192
|
+
fn = @fn
|
|
193
|
+
out = @output_type
|
|
194
|
+
->(result) { result.map(fn).map(out) }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Fuses compatible functions while retaining this function's output check;
|
|
198
|
+
# declared subtype compatibility does not prove what the callable returns.
|
|
199
|
+
# Returns nil (no fusion) unless:
|
|
200
|
+
# - both #calls are the standard input->fn->output mapping (see #fusible?);
|
|
201
|
+
# - what self produces is a DIRECT subtype of what other declares as
|
|
202
|
+
# input. check_composable! is looser — it relaxes container fields via
|
|
203
|
+
# accepted_type (the `encode >> decode` case), and there other's input
|
|
204
|
+
# check does real per-field work and must keep running;
|
|
205
|
+
# - the dropped check is value-preserving: a coercing input type changes the
|
|
206
|
+
# value, so it must keep running.
|
|
207
|
+
#
|
|
208
|
+
# @param other [Function]
|
|
209
|
+
# @return [Function, nil]
|
|
210
|
+
def fuse_with(other)
|
|
211
|
+
return nil unless fusable_step? && other.fusable_step?
|
|
212
|
+
return nil unless Plumb::Subtyping.subtype?(@output_type, other.input_type)
|
|
213
|
+
return nil unless Plumb::Subtyping.value_preserving?(other.input_type)
|
|
214
|
+
# Converting output checks must remain explicit between nodes.
|
|
215
|
+
return nil unless !checks_output? || Plumb::Subtyping.value_preserving?(@output_type)
|
|
216
|
+
|
|
217
|
+
fn1 = checks_output? ? fn_with_output_check : @fn
|
|
218
|
+
fn2 = other.fn
|
|
219
|
+
# Rebuild as one of the two standard classes, not `other.class`: a node is
|
|
220
|
+
# fusable because it kept #call, which says nothing about its constructor,
|
|
221
|
+
# and this one takes (input, output, fn). What has to survive is whether the
|
|
222
|
+
# final output check still runs — so ask `other`, which may not be a
|
|
223
|
+
# Function at all (see Implementation::TypeInterface).
|
|
224
|
+
klass = other.checks_output? ? Function : GuaranteedFunction
|
|
225
|
+
klass.new(@input_type, other.output_type, ->(result) { result.map(fn1).map(fn2) },
|
|
226
|
+
identity: [identity, other.identity])
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Take `type` as this node's OUTPUT slot, for `self >> type` — so
|
|
230
|
+
# `(Integer -> Any) >> Types::Float` becomes `(Integer -> Float)`, one node
|
|
231
|
+
# running one Float check instead of two nodes running an Any hop and a Float
|
|
232
|
+
# check. @see Composable#absorb_output for the shape; the conditions are here.
|
|
233
|
+
#
|
|
234
|
+
# The conditions are about THE SLOT — what is dropped — not about the neighbour.
|
|
235
|
+
# For `F(B => C) >> D` taking D into the C slot:
|
|
236
|
+
#
|
|
237
|
+
# - C is CHECKED (a plain Function). Its check goes, so C must not TRANSFORM
|
|
238
|
+
# (`value_preserving?(C)`, or the value the node produces changes) and D must
|
|
239
|
+
# reject everything C rejected. `D <= C` states the latter only while D is
|
|
240
|
+
# value-preserving: the subtype relation identifies a converting D by what it
|
|
241
|
+
# PRODUCES, and what matters here is what it ACCEPTS. `Types::Static[10] <=
|
|
242
|
+
# Types::Integer` holds — its value IS an Integer — yet a Static accepts
|
|
243
|
+
# anything, so absorbing it would swallow the dropped Integer check and turn an
|
|
244
|
+
# invalid `'abc'` into a valid `10`.
|
|
245
|
+
# - C is UNCHECKED (a GuaranteedFunction). Nothing is dropped, so there is nothing
|
|
246
|
+
# to prove and D may convert freely — it runs in the slot exactly where the
|
|
247
|
+
# no-op ran. When the guarantee already implies D there is nothing to win, and
|
|
248
|
+
# that redundant gate is reduce_step's to drop, so decline. Otherwise D takes the
|
|
249
|
+
# slot and the node becomes a plain Function, since the check has to run: the
|
|
250
|
+
# guarantee goes with the type that carried it.
|
|
251
|
+
#
|
|
252
|
+
# Validity, value and order are preserved either way. The ERROR TEXT of a value both
|
|
253
|
+
# C and D would reject becomes D's, the same trade Optimizer.reduce_union makes when
|
|
254
|
+
# it absorbs a union branch.
|
|
255
|
+
def absorb_output(type)
|
|
256
|
+
return nil unless absorbable? && !step_neighbour?(type)
|
|
257
|
+
|
|
258
|
+
if checks_output?
|
|
259
|
+
return nil unless Plumb::Subtyping.value_preserving?(@output_type)
|
|
260
|
+
return nil unless Plumb::Subtyping.value_preserving?(type)
|
|
261
|
+
return nil unless Plumb::Subtyping.subtype?(type, @output_type)
|
|
262
|
+
|
|
263
|
+
with_children([@input_type, type])
|
|
264
|
+
else
|
|
265
|
+
return nil if Plumb::Subtyping.subtype?(@output_type, type)
|
|
266
|
+
|
|
267
|
+
Function.new(@input_type, type, @fn, identity: @identity, wrapper: @wraps_callable)
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Take `type` as this node's INPUT slot, for `type >> self` — so
|
|
272
|
+
# `Types::Integer >> ->(r) { ... }` becomes `(Integer -> Any)` rather than
|
|
273
|
+
# `And(Integer, (Any -> Any))`. With #absorb_output, that is what collapses
|
|
274
|
+
# `Types::Integer >> ->(r) { r } >> Types::Float` to `(Integer -> Float)`:
|
|
275
|
+
# a little typed pipeline with arbitrary code in the middle.
|
|
276
|
+
#
|
|
277
|
+
# Again the conditions are about THE SLOT. For `A >> F(B => C)` taking A into the B
|
|
278
|
+
# slot, B's check is what goes, so B must not TRANSFORM and A must reject everything
|
|
279
|
+
# B rejected — `value_preserving?(B) && A <= B`. Here `A <= B` says exactly the right
|
|
280
|
+
# thing whatever A is: the relation identifies a converting A by what it PRODUCES,
|
|
281
|
+
# and what B sees IS what A produced. Whether A preserves the value is irrelevant —
|
|
282
|
+
# A runs either way, in the slot or beside it.
|
|
283
|
+
#
|
|
284
|
+
# SCOPE: only an `Any` slot, where both conditions hold trivially and no check is
|
|
285
|
+
# dropped at all, so errors are preserved verbatim too. The general form is the
|
|
286
|
+
# left-hand gate drop Optimizer declines, and costs what that costs; see the note in
|
|
287
|
+
# its header.
|
|
288
|
+
def absorb_input(type)
|
|
289
|
+
return nil unless absorbable? && !step_neighbour?(type)
|
|
290
|
+
return nil unless @input_type.is_a?(AnyClass)
|
|
291
|
+
|
|
292
|
+
with_children([type, @output_type])
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# May a neighbouring type move into one of this node's slots? Only when #call is
|
|
296
|
+
# the canonical mapping — a replaced #call runs different checks, and nothing can
|
|
297
|
+
# be assumed about the slots (the same question #fusable_step? asks) — and only
|
|
298
|
+
# when this node renders as its TYPES. A LABELLED function (#generate, #invoke,
|
|
299
|
+
# the :default and :rescue policies, a filtered container) inspects as its label
|
|
300
|
+
# instead, so a type absorbed into a slot would vanish from #inspect; for those the
|
|
301
|
+
# label is the more useful reading, and the type runs as its own step.
|
|
302
|
+
private def absorbable? = standard_call? && @inspect_label.nil?
|
|
303
|
+
|
|
304
|
+
# Is `type` itself a TYPED STEP? Then the seam belongs to #fuse_with rather than
|
|
305
|
+
# here: it composes the two fns instead of nesting one node inside the other's slot,
|
|
306
|
+
# and where fusion declines (two opaque wrappers) both nodes stay so that each #fn
|
|
307
|
+
# remains reachable. A ROUTING rule, not a soundness one — which rewrite owns the
|
|
308
|
+
# pair. Everything else is a type, and a type runs as a step whether it sits in a
|
|
309
|
+
# slot or beside one.
|
|
310
|
+
private def step_neighbour?(type) = type.is_a?(Plumb::TypedStep)
|
|
311
|
+
|
|
312
|
+
# DERIVED, not declared: this family's #call is Function's full
|
|
313
|
+
# input -> fn -> output or GuaranteedFunction's input -> fn (whose missing
|
|
314
|
+
# output check is provably redundant, not skipped). Anything else replaced it.
|
|
315
|
+
#
|
|
316
|
+
# Asking the method table rather than trusting a class list makes the answer
|
|
317
|
+
# fail-safe — a new subclass with custom semantics is excluded because it
|
|
318
|
+
# overrode #call, not because someone remembered to exclude it. FilteredHash
|
|
319
|
+
# (per-field validation, neither boundary check) is caught that way, so this
|
|
320
|
+
# file no longer has to know that class exists.
|
|
321
|
+
# @see Plumb::TypedStep#fusable_step?
|
|
322
|
+
def standard_call?
|
|
323
|
+
owner = method(:call).owner
|
|
324
|
+
owner.equal?(Function) || owner.equal?(GuaranteedFunction)
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# A Function whose output check is known *at build time* to be redundant, so
|
|
329
|
+
# it is simply not run. Two ways to earn that: the proc provably produces
|
|
330
|
+
# output_type (eg. a `:to_i` coercion always yields an Integer), or the
|
|
331
|
+
# output_type is `Any`, which nothing can fail — the opaque case, see
|
|
332
|
+
# Function.opaque. Choosing the class at build time means
|
|
333
|
+
# #call carries no per-call `guaranteed?` branch — the check is simply absent.
|
|
334
|
+
# It inherits Function's node_name (:function) and `is_a?(Function)`, so
|
|
335
|
+
# visitors, JSON-schema, subtyping and the decorator treat it as a Function;
|
|
336
|
+
# only the produced value's output re-validation is dropped. @output_type is
|
|
337
|
+
# still kept as metadata (inference / JSON schema / subtyping).
|
|
338
|
+
class GuaranteedFunction < Function
|
|
339
|
+
def call(result)
|
|
340
|
+
result.map(@input_type).map(@fn)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# The whole point of the class: no output check runs, so a seam has none to
|
|
344
|
+
# drop here. @see Plumb::TypedStep#checks_output?
|
|
345
|
+
def checks_output? = false
|
|
346
|
+
end
|
|
347
|
+
end
|