plumb 0.2.0.beta.1 → 0.2.0.beta.3
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 +160 -93
- data/lib/plumb/array_class.rb +72 -9
- data/lib/plumb/codec.rb +275 -140
- data/lib/plumb/composable.rb +2 -2
- data/lib/plumb/hash_class.rb +5 -4
- data/lib/plumb/hash_map.rb +1 -5
- data/lib/plumb/stream_class.rb +1 -5
- data/lib/plumb/types.rb +21 -4
- data/lib/plumb/version.rb +1 -1
- metadata +1 -1
data/lib/plumb/array_class.rb
CHANGED
|
@@ -45,22 +45,17 @@ module Plumb
|
|
|
45
45
|
def output_type = Plumb::Subtyping.map_children(self) { |c| Plumb::Subtyping.resolved_output(c) }
|
|
46
46
|
|
|
47
47
|
def concurrent
|
|
48
|
-
|
|
48
|
+
concurrent_class.new(element_type:)
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def stream
|
|
52
52
|
StreamClass.new(element_type:)
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
+
# A lenient version of this Array: it accepts any Array and emits one with only
|
|
56
|
+
# the valid elements, dropping the rest. @see FilteredArray
|
|
55
57
|
def filtered
|
|
56
|
-
|
|
57
|
-
identity: [:filtered_array, element_type]) do |result|
|
|
58
|
-
arr = result.value.each.with_object([]) do |e, memo|
|
|
59
|
-
r = element_type.resolve(e)
|
|
60
|
-
memo << r.value if r.valid?
|
|
61
|
-
end
|
|
62
|
-
result.valid!(arr)
|
|
63
|
-
end
|
|
58
|
+
filtered_class.new(element_type:)
|
|
64
59
|
end
|
|
65
60
|
|
|
66
61
|
def call(result)
|
|
@@ -78,6 +73,12 @@ module Plumb
|
|
|
78
73
|
|
|
79
74
|
attr_reader :element_type
|
|
80
75
|
|
|
76
|
+
# Named, not hardcoded, so the two combine in EITHER order: each subclass points
|
|
77
|
+
# at the variant that keeps what it already is, instead of the second call
|
|
78
|
+
# dropping the first.
|
|
79
|
+
def concurrent_class = ConcurrentArrayClass
|
|
80
|
+
def filtered_class = FilteredArray
|
|
81
|
+
|
|
81
82
|
def _inspect
|
|
82
83
|
%(Array[#{element_type}])
|
|
83
84
|
end
|
|
@@ -136,6 +137,68 @@ module Plumb
|
|
|
136
137
|
|
|
137
138
|
[values, errors]
|
|
138
139
|
end
|
|
140
|
+
|
|
141
|
+
def filtered_class = ConcurrentFilteredArray
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Same element type as an ArrayClass, but drops invalid elements instead of
|
|
145
|
+
# rejecting the whole Array. Being an ArrayClass is what keeps it rewritable: a
|
|
146
|
+
# visitor — or a Codec — that maps the element type rebuilds a FilteredArray, not
|
|
147
|
+
# a plain one. @see HashMap::FilteredHashMap, the same arrangement for maps.
|
|
148
|
+
class FilteredArray < self
|
|
149
|
+
# It drops elements, so it changes the value whatever its element type does.
|
|
150
|
+
def value_preserving? = false
|
|
151
|
+
|
|
152
|
+
# Lenient: it never rejects an Array, so as a #>> consumer it accepts any
|
|
153
|
+
# enumerable — without this `Types::Array >> Array[String].filtered` is an
|
|
154
|
+
# illegal narrowing.
|
|
155
|
+
def accepted_type = Types::Each
|
|
156
|
+
|
|
157
|
+
# `Array[T].filtered.stream` IS `Array[T].stream.filtered` — a filtered Stream
|
|
158
|
+
# drops the same elements, lazily.
|
|
159
|
+
def stream = super.filtered
|
|
160
|
+
|
|
161
|
+
def call(result)
|
|
162
|
+
return result.invalid!(errors: 'is not an Array') unless ::Array === result.value
|
|
163
|
+
|
|
164
|
+
result.valid!(valid_elements(result.value))
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
def concurrent_class = ConcurrentFilteredArray
|
|
170
|
+
|
|
171
|
+
# The only thing the concurrent variant replaces. No errors to collect, unlike
|
|
172
|
+
# #map_array_elements — an invalid element is simply not there.
|
|
173
|
+
def valid_elements(array)
|
|
174
|
+
array.each.with_object([]) do |e, memo|
|
|
175
|
+
r = element_type.resolve(e)
|
|
176
|
+
memo << r.value if r.valid?
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def _inspect = "Array[#{element_type}].filtered"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Both at once, reached from either side (`.filtered.concurrent`,
|
|
184
|
+
# `.concurrent.filtered`), and itself under both.
|
|
185
|
+
class ConcurrentFilteredArray < FilteredArray
|
|
186
|
+
private
|
|
187
|
+
|
|
188
|
+
def filtered_class = ConcurrentFilteredArray
|
|
189
|
+
|
|
190
|
+
# A rejected future re-raises, as ConcurrentArrayClass does: an element step
|
|
191
|
+
# that RAISED is a bug to surface, not an element to drop.
|
|
192
|
+
def valid_elements(array)
|
|
193
|
+
futures = array.map { |e| Concurrent::Future.execute { element_type.resolve(e) } }
|
|
194
|
+
|
|
195
|
+
futures.each_with_object([]) do |future, memo|
|
|
196
|
+
r = future.value # blocks until settled; nil when rejected
|
|
197
|
+
raise future.reason if future.rejected?
|
|
198
|
+
|
|
199
|
+
memo << r.value if r.valid?
|
|
200
|
+
end
|
|
201
|
+
end
|
|
139
202
|
end
|
|
140
203
|
end
|
|
141
204
|
end
|
data/lib/plumb/codec.rb
CHANGED
|
@@ -32,9 +32,8 @@ module Plumb
|
|
|
32
32
|
# algebra, so parsing, subtyping and visitors work on it unchanged. It works
|
|
33
33
|
# on any type, not just Hash schemas: `JSONCodec >> Types::Date` returns the
|
|
34
34
|
# matched encoder's decode step.
|
|
35
|
+
#
|
|
35
36
|
class Codec
|
|
36
|
-
include Composable
|
|
37
|
-
|
|
38
37
|
class << self
|
|
39
38
|
# Register one or more Encoder subclasses, in matching-priority order.
|
|
40
39
|
# The registry is inheritable: subclassing a codec extends it, and a
|
|
@@ -46,7 +45,7 @@ module Plumb
|
|
|
46
45
|
raise ArgumentError, "expected an Encoder subclass, got #{enc.inspect}"
|
|
47
46
|
end
|
|
48
47
|
|
|
49
|
-
|
|
48
|
+
encoders << enc
|
|
50
49
|
end
|
|
51
50
|
self
|
|
52
51
|
end
|
|
@@ -56,154 +55,196 @@ module Plumb
|
|
|
56
55
|
# nothing. Real encoders always match first, so a noop never shadows a
|
|
57
56
|
# registered encoder for the same type.
|
|
58
57
|
def noop(*types)
|
|
59
|
-
types.each { |t|
|
|
58
|
+
types.each { |t| noop_types << Composable.wrap(t) }
|
|
60
59
|
self
|
|
61
60
|
end
|
|
62
61
|
|
|
63
|
-
#
|
|
64
|
-
# win ties in
|
|
65
|
-
|
|
62
|
+
# A subclass starts from a COPY of its parent's registries and appends its own,
|
|
63
|
+
# so `encoders` reads inherited-first, own-last (later registrations win ties in
|
|
64
|
+
# matching). Copied at subclass time rather than resolved at read time because
|
|
65
|
+
# registration happens in a class body: a parent is complete before a subclass
|
|
66
|
+
# of it exists.
|
|
67
|
+
def inherited(subclass)
|
|
68
|
+
super
|
|
69
|
+
subclass.encoders = encoders.dup
|
|
70
|
+
subclass.noop_types = noop_types.dup
|
|
71
|
+
end
|
|
66
72
|
|
|
67
|
-
|
|
68
|
-
def noop_types =
|
|
73
|
+
def encoders = @encoders ||= []
|
|
74
|
+
def noop_types = @noop_types ||= []
|
|
69
75
|
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def |(other) = instance | other
|
|
75
|
-
def &(other) = instance & other
|
|
76
|
-
def for(type) = instance.for(type)
|
|
77
|
-
def to_plumb_type(op:, left:) = instance.to_plumb_type(op:, left:)
|
|
78
|
-
def to_composable = instance
|
|
79
|
-
def call(result) = instance.call(result)
|
|
76
|
+
# Only #inherited seeds a registry; everything else registers through
|
|
77
|
+
# #encoder / #noop.
|
|
78
|
+
attr_writer :encoders, :noop_types
|
|
79
|
+
protected :encoders=, :noop_types=
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
# Decode direction: rewrite `other` so it accepts the encoders' input form
|
|
82
|
+
# and produces the output values `other` describes. The rewritten type
|
|
83
|
+
# REPLACES the composition — no codec node remains.
|
|
84
|
+
def >>(other)
|
|
85
|
+
Rewriter.new(self, :decode).call(Composable.wrap(other))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Both directions for a type, as a [decoding, encoding] pair:
|
|
89
|
+
#
|
|
90
|
+
# decoder, encoder = Plumb::Codec::JSON.for(Person)
|
|
91
|
+
# decoder.parse(input_data) # => a Person hash
|
|
92
|
+
# encoder.parse(person) # => output structures
|
|
93
|
+
#
|
|
94
|
+
# @param type [Composable, Object]
|
|
95
|
+
# @return [Array(Composable, Composable)]
|
|
96
|
+
def for(type)
|
|
97
|
+
type = Composable.wrap(type)
|
|
98
|
+
[self >> type, type >> self]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Encode direction, reached when the codec is the RIGHT operand
|
|
102
|
+
# (`Person >> JSONCodec` — see Composable.resolve_operand): build an encode
|
|
103
|
+
# rewrite of what `left` produces. Composable#>> then composes
|
|
104
|
+
# `And(left, rewrite)`: the left validates its input once, the
|
|
105
|
+
# rewrite encodes. Building from `left`'s OUTPUT (not `left` itself)
|
|
106
|
+
# avoids re-running its coercions on already-parsed values.
|
|
107
|
+
def to_plumb_type(op:, left:)
|
|
108
|
+
unless op == :>>
|
|
109
|
+
raise Plumb::TypeError, "#{inspect} only composes with #>> (got #{op}); a Codec is not a value type"
|
|
110
|
+
end
|
|
82
111
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
112
|
+
left = Composable.wrap(left)
|
|
113
|
+
# A plain-include struct wraps as an opaque Step (output Any), so its
|
|
114
|
+
# rewrite target is the struct node itself, not its resolved output.
|
|
115
|
+
target = Plumb::Attributes.struct_class(left) ? left : Plumb::Subtyping.resolved_output(left)
|
|
116
|
+
Rewriter.new(self, :encode).call(target)
|
|
88
117
|
end
|
|
89
118
|
|
|
90
|
-
def
|
|
91
|
-
|
|
92
|
-
end
|
|
119
|
+
def |(_other) = raise Plumb::TypeError, "#{inspect} only composes with #>>; a Codec is not a value type"
|
|
120
|
+
alias & |
|
|
93
121
|
|
|
94
|
-
|
|
122
|
+
# A Codec is not a value type, and has no node to stand in for one. Both of
|
|
123
|
+
# these say so at the point of the mistake — without #to_composable,
|
|
124
|
+
# Composable.wrap would see a `#call` and build an opaque step out of the class.
|
|
125
|
+
def to_composable = raise(Plumb::TypeError, "#{inspect} is not a type; compose it with a type via #>>")
|
|
95
126
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
@encoders = self.class.encoders + extra_encoders
|
|
100
|
-
@noop_types = self.class.noop_types
|
|
101
|
-
@noop_union = @noop_types.reduce(:|)
|
|
102
|
-
freeze
|
|
103
|
-
end
|
|
127
|
+
def call(_result)
|
|
128
|
+
raise Plumb::TypeError, "#{inspect} is not a runtime type; compose it with a type via #>>"
|
|
129
|
+
end
|
|
104
130
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
131
|
+
# The best matching encoder for `type`, or nil. Matching is against each
|
|
132
|
+
# encoder's output type — schemas are written in output
|
|
133
|
+
# terms in both directions. Most-specific wins; equivalent types
|
|
134
|
+
# tie-break to the last registered; incomparable multi-matches raise.
|
|
135
|
+
def encoder_for(type, path = BLANK_ARRAY)
|
|
136
|
+
matches = encoders.select { |e| Plumb::Subtyping.subtype?(type, e.output_type) }
|
|
137
|
+
return nil if matches.empty?
|
|
138
|
+
return matches.first if matches.size == 1
|
|
111
139
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
# @param type [Composable, Object]
|
|
119
|
-
# @return [Array(Composable, Composable)]
|
|
120
|
-
def for(type)
|
|
121
|
-
type = Composable.wrap(type)
|
|
122
|
-
[self >> type, type >> self]
|
|
123
|
-
end
|
|
140
|
+
minimal = matches.reject do |e|
|
|
141
|
+
# e is dominated when another match's output type is strictly narrower.
|
|
142
|
+
matches.any? do |o|
|
|
143
|
+
!o.equal?(e) && Plumb::Subtyping.strict_subtype?(o.output_type, e.output_type)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
124
146
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
def to_plumb_type(op:, left:)
|
|
132
|
-
unless op == :>>
|
|
133
|
-
raise Plumb::TypeError, "#{inspect} only composes with #>> (got #{op}); a Codec is not a value type"
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
left = Composable.wrap(left)
|
|
137
|
-
# A plain-include struct wraps as an opaque Step (output Any), so its
|
|
138
|
-
# rewrite target is the struct node itself, not its resolved output.
|
|
139
|
-
target = Plumb::Attributes.struct_class(left) ? left : Plumb::Subtyping.resolved_output(left)
|
|
140
|
-
Rewriter.new(self, :encode).call(target)
|
|
141
|
-
end
|
|
147
|
+
first = minimal.first
|
|
148
|
+
unless minimal.all? { |e| Plumb::Subtyping.equivalent?(e.output_type, first.output_type) }
|
|
149
|
+
raise Plumb::TypeError,
|
|
150
|
+
"#{inspect}: #{at_path(path)} (#{type.inspect}) matches multiple incomparable encoders: " \
|
|
151
|
+
"#{minimal.map(&:inspect).join(', ')}. Register a more specific encoder or restructure."
|
|
152
|
+
end
|
|
142
153
|
|
|
143
|
-
|
|
144
|
-
|
|
154
|
+
minimal.last
|
|
155
|
+
end
|
|
145
156
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
157
|
+
# Is `type` (one side of it, per direction) covered by the registered noop
|
|
158
|
+
# types? Decode checks what the type ACCEPTS (it will be fed raw input
|
|
159
|
+
# data); encode checks what it PRODUCES (its output lands in the encoded
|
|
160
|
+
# document).
|
|
161
|
+
def noop?(type, direction)
|
|
162
|
+
return false unless noop_union
|
|
149
163
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
minimal = matches.reject do |e|
|
|
160
|
-
# e is dominated when another match's output type is strictly narrower.
|
|
161
|
-
matches.any? do |o|
|
|
162
|
-
!o.equal?(e) && Plumb::Subtyping.strict_subtype?(o.output_type, e.output_type)
|
|
163
|
-
end
|
|
164
|
+
side = direction == :decode ? Plumb::Subtyping.accepted_type(type) : Plumb::Subtyping.resolved_output(type)
|
|
165
|
+
# A pure filter with an opaque side is judged by the type itself: a
|
|
166
|
+
# bare-matcher Constraint (a branch of a factored refinement union, eg.
|
|
167
|
+
# the `String[/\Atrue\z/i] | String['1']` boolean input type) reports Any
|
|
168
|
+
# as its accepted type, but as a value-preserving refinement it IS its
|
|
169
|
+
# own honest description.
|
|
170
|
+
side = type if side.is_a?(AnyClass) && Plumb::Subtyping.value_preserving?(type)
|
|
171
|
+
Plumb::Subtyping.subtype?(side, noop_union)
|
|
164
172
|
end
|
|
165
173
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
174
|
+
# Is this concrete VALUE covered by the noop types? Used for Static nodes,
|
|
175
|
+
# whose fixed value can be validated directly — subtyping over the node
|
|
176
|
+
# can't relate an atomic Static to a container noop (`Static[[]]` vs
|
|
177
|
+
# `Types::Array`), but the value itself can just be checked.
|
|
178
|
+
def noop_value?(value)
|
|
179
|
+
return false unless noop_union
|
|
180
|
+
|
|
181
|
+
noop_union === value
|
|
171
182
|
end
|
|
172
183
|
|
|
173
|
-
|
|
184
|
+
def at_path(path) = path.empty? ? 'the root type' : "field `#{path.join('.')}`"
|
|
185
|
+
|
|
186
|
+
# Named, so an anonymous `Class.new(Codec)` still says what it carries.
|
|
187
|
+
def inspect = "#{name || superclass.name}[#{encoders.map(&:inspect).join(', ')}]"
|
|
188
|
+
|
|
189
|
+
private
|
|
190
|
+
|
|
191
|
+
# Derived from #noop_types, so resolved once — after boot, when the first
|
|
192
|
+
# composition asks.
|
|
193
|
+
def noop_union
|
|
194
|
+
return @noop_union if defined?(@noop_union)
|
|
195
|
+
|
|
196
|
+
@noop_union = noop_types.reduce(:|)
|
|
197
|
+
end
|
|
174
198
|
end
|
|
175
199
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
#
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
200
|
+
Entry = Data.define(:decoder, :encoder)
|
|
201
|
+
NoEntryError = Class.new(KeyError)
|
|
202
|
+
|
|
203
|
+
# An INSTANCE is a registry of composed [decoder, encoder] pairs, so the
|
|
204
|
+
# per-message path is just #parse. Keys are the app's own — a tag for
|
|
205
|
+
# self-describing payloads, or the type itself:
|
|
206
|
+
#
|
|
207
|
+
# registry = Plumb::Codec::JSON.new do |c|
|
|
208
|
+
# c.register('person.created', Person) # tag -> type
|
|
209
|
+
# c.register(Types::Date) # key defaults to the type
|
|
210
|
+
# end
|
|
211
|
+
# registry.decode('person.created', payload)
|
|
212
|
+
# registry.encode(Types::Date, Date.today)
|
|
213
|
+
#
|
|
214
|
+
# Built with a block it is FROZEN — a closed set, declared at boot. Built
|
|
215
|
+
# without one it stays open and composes type keys on first use, so an app
|
|
216
|
+
# need not enumerate them; #freeze seals it later.
|
|
217
|
+
def initialize(&)
|
|
218
|
+
@entries = {}
|
|
219
|
+
@lock = Mutex.new
|
|
220
|
+
return unless block_given?
|
|
221
|
+
|
|
222
|
+
yield self
|
|
223
|
+
freeze
|
|
191
224
|
end
|
|
192
225
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
def noop_value?(value)
|
|
198
|
-
return false unless @noop_union
|
|
226
|
+
def freeze
|
|
227
|
+
@entries.freeze
|
|
228
|
+
super
|
|
229
|
+
end
|
|
199
230
|
|
|
200
|
-
|
|
231
|
+
# Named, not splatted: both sides are types that #parse, so a swapped pair would
|
|
232
|
+
# decode where it should encode without anything raising.
|
|
233
|
+
def register(key, type = key)
|
|
234
|
+
raise FrozenError, "#{inspect} is sealed; register before freezing it" if frozen?
|
|
235
|
+
|
|
236
|
+
entry = build_entry(type)
|
|
237
|
+
@lock.synchronize { @entries[key] = entry }
|
|
238
|
+
self
|
|
201
239
|
end
|
|
202
240
|
|
|
203
|
-
def
|
|
241
|
+
def key?(key) = !read(key).nil?
|
|
242
|
+
|
|
243
|
+
def decode(key, payload) = entry(key).decoder.parse(payload)
|
|
244
|
+
def encode(key, payload) = entry(key).encoder.parse(payload)
|
|
204
245
|
|
|
205
|
-
|
|
206
|
-
|
|
246
|
+
def inspect
|
|
247
|
+
%(#<#{self.class}:#{object_id} [#{@entries.size} entries]>)
|
|
207
248
|
end
|
|
208
249
|
|
|
209
250
|
# The deep rewrite walker. Top-down, per node:
|
|
@@ -293,11 +334,16 @@ module Plumb
|
|
|
293
334
|
return NodeMapper.map(type) { |t| visit(t, path) }
|
|
294
335
|
end
|
|
295
336
|
|
|
337
|
+
# Before encoder matching, for the Static reason: a source IS a subtype of what
|
|
338
|
+
# it declares, so an encoder would replace the code that produces the value.
|
|
339
|
+
return visit_source(type, path) if source_step?(type)
|
|
340
|
+
|
|
296
341
|
enc = encoder_for(type, path)
|
|
297
342
|
return replace(type, enc, path) if enc
|
|
298
343
|
|
|
299
344
|
case type
|
|
300
345
|
when HashClass then visit_hash(type, path)
|
|
346
|
+
when FilteredHash then visit_filtered_hash(type, path)
|
|
301
347
|
when ArrayClass, StreamClass then visit_array(type, path)
|
|
302
348
|
when TupleClass then visit_tuple(type, path)
|
|
303
349
|
when HashMap then visit_hash_map(type, path)
|
|
@@ -311,13 +357,37 @@ module Plumb
|
|
|
311
357
|
end
|
|
312
358
|
end
|
|
313
359
|
|
|
314
|
-
# A leaf: nothing to recurse into structurally. It survives when
|
|
315
|
-
# format already carries it (the noop check), or — decoding — when the
|
|
360
|
+
# A leaf: nothing to recurse into structurally. It survives when it is OPAQUE,
|
|
361
|
+
# when the format already carries it (the noop check), or — decoding — when the
|
|
316
362
|
# codec can bridge what it CONSUMES.
|
|
317
363
|
def visit_leaf(type, path)
|
|
364
|
+
return type if opaque_step?(type)
|
|
365
|
+
|
|
318
366
|
bridge_input(type, path) || noop_or_fail(type, path)
|
|
319
367
|
end
|
|
320
368
|
|
|
369
|
+
# An OPAQUE step (`Any -> Any`) is a black box — a `#generate` generator, a bare
|
|
370
|
+
# proc in a schema. It names no type to rewrite, so it passes through, and what
|
|
371
|
+
# it emits lands unencoded, exactly as its `Any` output says.
|
|
372
|
+
def opaque_step?(type) = type.is_a?(Plumb::TypedStep) && type.opaque?
|
|
373
|
+
|
|
374
|
+
# A SOURCE step declares `Any -> T`: it produces a T out of whatever it is
|
|
375
|
+
# handed. The `#default { }` generator is one.
|
|
376
|
+
def source_step?(type)
|
|
377
|
+
type.is_a?(Plumb::TypedStep) && type.input_type.is_a?(AnyClass) && !type.output_type.is_a?(AnyClass)
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# DECODE: leave it alone. Accepting `Any` it already accepts the encoded form,
|
|
381
|
+
# and it produces the decoded value itself (`Date.default { Date.today }` yields
|
|
382
|
+
# a Date, not a wire string).
|
|
383
|
+
#
|
|
384
|
+
# ENCODE: the rewrite is composed AFTER the type it encodes (see
|
|
385
|
+
# Codec#to_plumb_type), so the source has already run and the T it declared is
|
|
386
|
+
# what sits here. Rewriting the node would run the generator a second time.
|
|
387
|
+
def visit_source(type, path)
|
|
388
|
+
@direction == :decode ? type : visit(type.output_type, path)
|
|
389
|
+
end
|
|
390
|
+
|
|
321
391
|
# DECODE: a converting leaf (a Function, a Plumb::Implementation — any node
|
|
322
392
|
# whose accepted type is a distinct node) consumes values the encoded
|
|
323
393
|
# document does not carry: `Hash[time: Time] -> Thing` wants a Time, a JSON
|
|
@@ -443,12 +513,10 @@ module Plumb
|
|
|
443
513
|
# constant) still rewrite their input once.
|
|
444
514
|
input = enc.input_type_for(type)
|
|
445
515
|
rewritten_input = @input_memo.fetch(type) do
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
@input_stack.pop
|
|
451
|
-
end
|
|
516
|
+
@input_stack.push(enc)
|
|
517
|
+
@input_memo[type] = visit(input, path + ["<#{enc.inspect} input>"])
|
|
518
|
+
ensure
|
|
519
|
+
@input_stack.pop
|
|
452
520
|
end
|
|
453
521
|
|
|
454
522
|
# Splice the input into the step unless it is the encoder's declared
|
|
@@ -480,6 +548,21 @@ module Plumb
|
|
|
480
548
|
end
|
|
481
549
|
end
|
|
482
550
|
|
|
551
|
+
# DECODE rewrites the schema a filter FILTERS and rebuilds the filter around it,
|
|
552
|
+
# so decoding happens inside it, per field: #bridge_input's spliced step would
|
|
553
|
+
# decode the whole hash first, making one unreadable field fatal — what
|
|
554
|
+
# .filtered exists to avoid. (The filtered Array and HashMap need none of this;
|
|
555
|
+
# being containers they rewrite through #visit_array / #visit_hash_map.)
|
|
556
|
+
#
|
|
557
|
+
# ENCODE: `resolved_output` reduces the filter to its relaxed schema before the
|
|
558
|
+
# rewrite, so this is only reached defensively.
|
|
559
|
+
def visit_filtered_hash(type, path)
|
|
560
|
+
return visit(type.output_type, path) unless @direction == :decode
|
|
561
|
+
|
|
562
|
+
schema = visit(type.input_type, path)
|
|
563
|
+
schema.equal?(type.input_type) ? type : schema.filtered
|
|
564
|
+
end
|
|
565
|
+
|
|
483
566
|
def visit_array(type, path)
|
|
484
567
|
return noop_or_fail(type, path) if type.children.first.is_a?(AnyClass) # untyped — a leaf
|
|
485
568
|
|
|
@@ -507,11 +590,16 @@ module Plumb
|
|
|
507
590
|
NodeMapper.map_children(type) { |variant| visit(variant, path) }
|
|
508
591
|
end
|
|
509
592
|
|
|
593
|
+
# Rewriting can collapse two branches onto the SAME node — encoding a
|
|
594
|
+
# `#default { }`, both resolve to the type. Keep one: the second is unreachable,
|
|
595
|
+
# and a failing encode would otherwise report its errors twice.
|
|
510
596
|
def visit_or(type, path)
|
|
511
597
|
left, right = type.children
|
|
512
598
|
l = visit(left, path)
|
|
513
599
|
r = visit(right, path)
|
|
514
|
-
l.equal?(left) && r.equal?(right)
|
|
600
|
+
return type if l.equal?(left) && r.equal?(right)
|
|
601
|
+
|
|
602
|
+
l == r ? l : Disjunction.build(l, r)
|
|
515
603
|
end
|
|
516
604
|
|
|
517
605
|
# A MEET: a data-bearing type refined by pure filters (a #where's
|
|
@@ -560,27 +648,41 @@ module Plumb
|
|
|
560
648
|
# Rewriting it too decodes twice: #bridge_input splices `b`'s own decode step in
|
|
561
649
|
# front, the already-decoded value hits a step expecting the encoded form, and the
|
|
562
650
|
# pipeline rejects everything. A step only counts as having consumed the wire if
|
|
563
|
-
# its rewrite actually CHANGED it
|
|
651
|
+
# its rewrite actually CHANGED it — or if it is a black box, which says the same
|
|
652
|
+
# thing: what it hands on is unknown. `Types::Integer.generate { 1 }` is
|
|
653
|
+
# `(opaque >> Integer)`, and rewriting that Integer into the codec's
|
|
654
|
+
# `String -> Integer` step would feed it the generated Integer and reject
|
|
655
|
+
# everything.
|
|
564
656
|
def visit_composition(type, path)
|
|
565
657
|
left, right = type.children
|
|
566
658
|
l = pure_refinement?(left) ? left : visit(left, path)
|
|
567
|
-
|
|
568
|
-
# `left`'s rewrite left it unchanged.
|
|
569
|
-
wire_consumed = @direction == :decode && !l.equal?(left)
|
|
570
|
-
r = pure_refinement?(right) || wire_consumed ? right : visit(right, path)
|
|
659
|
+
r = pure_refinement?(right) || wire_consumed?(left, l) ? right : visit(right, path)
|
|
571
660
|
|
|
572
661
|
l.equal?(left) && r.equal?(right) ? type : Conjunction.build(l, r)
|
|
573
662
|
end
|
|
574
663
|
|
|
664
|
+
# Encoding, nothing consumes a wire: every step is rewritten in place.
|
|
665
|
+
def wire_consumed?(left, rewritten)
|
|
666
|
+
return false unless @direction == :decode
|
|
667
|
+
|
|
668
|
+
!rewritten.equal?(left) || opaque_step?(rewritten) || source_step?(rewritten)
|
|
669
|
+
end
|
|
670
|
+
|
|
575
671
|
# A pure refinement carries no encodable type — it filters the adjacent
|
|
576
672
|
# type's values. A baseless Constraint refining a sibling qualifies, but a
|
|
577
673
|
# base-type Constraint — `Types::Date` IS `Constraint(::Date)` — is data an
|
|
578
674
|
# encoder must see (eg. in `Types::Date.where(year: ...)` ==
|
|
579
675
|
# `And(Constraint(::Date), AVM)`), and so is a baseless Module gate, which
|
|
580
676
|
# names a type rather than filtering one.
|
|
677
|
+
#
|
|
678
|
+
# `Any` is the TRIVIAL filter, so it qualifies too — but only ALONGSIDE a sibling
|
|
679
|
+
# that carries a type; on its own it is still a leaf, and still an error. It gets
|
|
680
|
+
# here from an opaque step's output slot, which is what `resolved_output` leaves
|
|
681
|
+
# behind (`Integer.generate { 1 }` resolves to `(Any >> Integer)`): the value is
|
|
682
|
+
# unknown until the Integer beside it vouches for it, and that is what to encode.
|
|
581
683
|
def pure_refinement?(child)
|
|
582
684
|
case child
|
|
583
|
-
when AttributeValueMatch, ValueClass, Not then true
|
|
685
|
+
when AttributeValueMatch, ValueClass, Not, AnyClass then true
|
|
584
686
|
when Constraint then child.base.nil? && !child.matcher.is_a?(::Module)
|
|
585
687
|
else false
|
|
586
688
|
end
|
|
@@ -608,7 +710,6 @@ module Plumb
|
|
|
608
710
|
'Register an encoder for it, or declare it with .noop.'
|
|
609
711
|
end
|
|
610
712
|
end
|
|
611
|
-
|
|
612
713
|
end
|
|
613
714
|
|
|
614
715
|
# ------------------------------------------------------------------
|
|
@@ -634,9 +735,10 @@ module Plumb
|
|
|
634
735
|
def decode(str) = ::Date.parse(str)
|
|
635
736
|
end
|
|
636
737
|
|
|
637
|
-
# Time <=> ISO 8601 date-time string ("2024-08-30T20:15:
|
|
738
|
+
# Time <=> ISO 8601 date-time string ("2024-08-30T20:15:23.456789Z").
|
|
739
|
+
#
|
|
638
740
|
class TimeEncoder < Encoder[Types::String[TIME_EXPR].metadata(format: 'date-time') => Types::Time]
|
|
639
|
-
def encode(time) = time.iso8601
|
|
741
|
+
def encode(time) = time.iso8601(6)
|
|
640
742
|
def decode(str) = ::Time.parse(str)
|
|
641
743
|
end
|
|
642
744
|
|
|
@@ -791,5 +893,38 @@ module Plumb
|
|
|
791
893
|
encoder DateEncoder, TimeEncoder, SymbolEncoder, DecimalEncoder
|
|
792
894
|
encoder URIEncoder, HTTPURIEncoder, FileURIEncoder
|
|
793
895
|
end
|
|
896
|
+
|
|
897
|
+
private
|
|
898
|
+
|
|
899
|
+
def entry(key)
|
|
900
|
+
read(key) || lazy_entry(key) || raise(NoEntryError, "no encoder/decoder registered for #{key}")
|
|
901
|
+
end
|
|
902
|
+
|
|
903
|
+
# Sealed, @entries can never change again, so reads need no synchronization —
|
|
904
|
+
# the shared-global case is also the cheapest one.
|
|
905
|
+
def read(key) = frozen? ? @entries[key] : @lock.synchronize { @entries[key] }
|
|
906
|
+
|
|
907
|
+
# Fill in on demand, so an app need not enumerate every type it exchanges.
|
|
908
|
+
# Only for keys that ARE types: an app-owned tag ('person.created') names
|
|
909
|
+
# nothing the codec could compile, and still raises. Freezing is how an app
|
|
910
|
+
# declares its set closed — a sealed registry never fills in.
|
|
911
|
+
#
|
|
912
|
+
# Keys match by value, so a type literal built fresh per call adds an entry
|
|
913
|
+
# per call. Constants and classes are stable; sealing rules it out entirely.
|
|
914
|
+
def lazy_entry(key)
|
|
915
|
+
return nil if frozen? || !(key.is_a?(Composable) || key.is_a?(::Module))
|
|
916
|
+
|
|
917
|
+
# Composed OUTSIDE the lock, as TypeCache does: holding it across a rewrite
|
|
918
|
+
# would convoy every other thread's lookups behind one cold type. Racing
|
|
919
|
+
# threads both compose; the rewrite is a pure function of the type, so the
|
|
920
|
+
# loser's copy is equivalent and simply dropped.
|
|
921
|
+
entry = build_entry(key)
|
|
922
|
+
@lock.synchronize { @entries[key] ||= entry }
|
|
923
|
+
end
|
|
924
|
+
|
|
925
|
+
def build_entry(type)
|
|
926
|
+
decoder, encoder = self.class.for(type)
|
|
927
|
+
Entry.new(decoder:, encoder:)
|
|
928
|
+
end
|
|
794
929
|
end
|
|
795
930
|
end
|