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/codec.rb
ADDED
|
@@ -0,0 +1,886 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'plumb/composable'
|
|
4
|
+
require 'plumb/encoder'
|
|
5
|
+
|
|
6
|
+
module Plumb
|
|
7
|
+
# A group of {Encoder}s that applies itself to whole types at composition
|
|
8
|
+
# time. A Codec knows nothing about any particular format — only its
|
|
9
|
+
# encoders, plus a list of pass-through types (`.noop`) already valid in
|
|
10
|
+
# the target format.
|
|
11
|
+
#
|
|
12
|
+
# class JSONCodec < Plumb::Codec
|
|
13
|
+
# noop Types::String, Types::Integer, Types::Float, Types::Numeric,
|
|
14
|
+
# Types::True, Types::False, Types::Nil, Types::Hash, Types::Array
|
|
15
|
+
#
|
|
16
|
+
# encoder JSONDateRangeEncoder # Hash[from: Date, to: Date] <=> DateRange
|
|
17
|
+
# encoder ISODateEncoder # String <=> Date
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# JSONPerson = JSONCodec >> Person # decode: input structures -> Person
|
|
21
|
+
# EncodedPerson = Person >> JSONCodec # encode: Person -> output structures
|
|
22
|
+
#
|
|
23
|
+
# Composing rewrites the type deeply: every field (at any depth) whose type
|
|
24
|
+
# matches an encoder's output type is replaced with the oriented
|
|
25
|
+
# encoder step (a plain Function, see Encoder.step); noop-matched types
|
|
26
|
+
# pass through unchanged; anything else
|
|
27
|
+
# raises Plumb::TypeError at composition time, naming the field path. An
|
|
28
|
+
# encoder's input type is itself rewritten through the same codec, so
|
|
29
|
+
# nested non-native values resolve via other encoders in the group.
|
|
30
|
+
#
|
|
31
|
+
# The Codec leaves no runtime node behind — the result is ordinary Plumb
|
|
32
|
+
# algebra, so parsing, subtyping and visitors work on it unchanged. It works
|
|
33
|
+
# on any type, not just Hash schemas: `JSONCodec >> Types::Date` returns the
|
|
34
|
+
# matched encoder's decode step.
|
|
35
|
+
#
|
|
36
|
+
class Codec
|
|
37
|
+
class << self
|
|
38
|
+
# Register one or more Encoder subclasses, in matching-priority order.
|
|
39
|
+
# The registry is inheritable: subclassing a codec extends it, and a
|
|
40
|
+
# subclass's encoders take precedence over inherited ones when their
|
|
41
|
+
# types are equivalent.
|
|
42
|
+
def encoder(*encoder_classes)
|
|
43
|
+
encoder_classes.each do |enc|
|
|
44
|
+
unless enc.is_a?(::Class) && enc < Encoder
|
|
45
|
+
raise ArgumentError, "expected an Encoder subclass, got #{enc.inspect}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
encoders << enc
|
|
49
|
+
end
|
|
50
|
+
self
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Register pass-through types: values of these types are already valid
|
|
54
|
+
# in the codec's target format and are left untouched — they rewrite to
|
|
55
|
+
# nothing. Real encoders always match first, so a noop never shadows a
|
|
56
|
+
# registered encoder for the same type.
|
|
57
|
+
def noop(*types)
|
|
58
|
+
types.each { |t| noop_types << Composable.wrap(t) }
|
|
59
|
+
self
|
|
60
|
+
end
|
|
61
|
+
|
|
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
|
|
72
|
+
|
|
73
|
+
def encoders = @encoders ||= []
|
|
74
|
+
def noop_types = @noop_types ||= []
|
|
75
|
+
|
|
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
|
+
|
|
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
|
|
111
|
+
|
|
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)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def |(_other) = raise Plumb::TypeError, "#{inspect} only composes with #>>; a Codec is not a value type"
|
|
120
|
+
alias & |
|
|
121
|
+
|
|
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 #>>")
|
|
126
|
+
|
|
127
|
+
def call(_result)
|
|
128
|
+
raise Plumb::TypeError, "#{inspect} is not a runtime type; compose it with a type via #>>"
|
|
129
|
+
end
|
|
130
|
+
|
|
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
|
|
139
|
+
|
|
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
|
|
146
|
+
|
|
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
|
|
153
|
+
|
|
154
|
+
minimal.last
|
|
155
|
+
end
|
|
156
|
+
|
|
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
|
|
163
|
+
|
|
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)
|
|
172
|
+
end
|
|
173
|
+
|
|
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
|
|
182
|
+
end
|
|
183
|
+
|
|
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
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
Entry = Data.define(:decoder, :encoder)
|
|
201
|
+
NoEntryError = Class.new(KeyError)
|
|
202
|
+
|
|
203
|
+
def initialize(&)
|
|
204
|
+
@entries = {}
|
|
205
|
+
return unless block_given?
|
|
206
|
+
|
|
207
|
+
yield self
|
|
208
|
+
freeze
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def freeze
|
|
212
|
+
@entries.freeze
|
|
213
|
+
super
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Named, not splatted: both sides are types that #parse, so a swapped pair would
|
|
217
|
+
# decode where it should encode without anything raising.
|
|
218
|
+
def register(key, type = key)
|
|
219
|
+
decoder, encoder = self.class.for(type)
|
|
220
|
+
@entries[key] = Entry.new(decoder:, encoder:)
|
|
221
|
+
self
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def key?(key) = @entries.key?(key)
|
|
225
|
+
|
|
226
|
+
def decode(key, payload) = entry(key).decoder.parse(payload)
|
|
227
|
+
def encode(key, payload) = entry(key).encoder.parse(payload)
|
|
228
|
+
|
|
229
|
+
def inspect
|
|
230
|
+
%(#<#{self.class}:#{object_id} [#{@entries.size} entries]>)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# The deep rewrite walker. Top-down, per node:
|
|
234
|
+
#
|
|
235
|
+
# 1. Static values, Or/And chains and transparent wrappers recurse into
|
|
236
|
+
# their parts first — they can carry generator machinery (a
|
|
237
|
+
# `.default`'s `Undefined >> Static` guard) that a wholesale
|
|
238
|
+
# replacement would drop (see #visit);
|
|
239
|
+
# 2. a real encoder match replaces the node whole (its input type is
|
|
240
|
+
# recursively rewritten through the same codec, cycle-guarded);
|
|
241
|
+
# 3. structured composites (Hash schemas, typed Arrays/Tuples/HashMaps)
|
|
242
|
+
# recurse into their children — AFTER encoder matching, so an encoder
|
|
243
|
+
# can target a specific composite shape, but BEFORE the noop check,
|
|
244
|
+
# so a generic `noop Types::Hash` can't swallow a structured schema;
|
|
245
|
+
# 4. decoding, a converting leaf (a Function, a Plumb::Implementation, a
|
|
246
|
+
# struct) has what it ACCEPTS rewritten through the codec and put in
|
|
247
|
+
# front of it, so the step is fed the decoded value (see #bridge_input);
|
|
248
|
+
# 5. remaining leaves pass through when noop-covered, otherwise raise with
|
|
249
|
+
# the dotted field path.
|
|
250
|
+
#
|
|
251
|
+
# Untouched subtrees keep their identity (the original node is returned),
|
|
252
|
+
# so noop pass-through adds no nodes.
|
|
253
|
+
class Rewriter
|
|
254
|
+
def initialize(codec, direction)
|
|
255
|
+
@codec = codec
|
|
256
|
+
@direction = direction # :decode | :encode
|
|
257
|
+
@deferred_memo = {}.compare_by_identity
|
|
258
|
+
@input_memo = {}.compare_by_identity
|
|
259
|
+
@match_memo = {}.compare_by_identity
|
|
260
|
+
@noop_memo = {}.compare_by_identity
|
|
261
|
+
@bridge_memo = {}.compare_by_identity
|
|
262
|
+
@input_stack = []
|
|
263
|
+
@root = nil
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def call(type)
|
|
267
|
+
@root = type
|
|
268
|
+
visit(type, BLANK_ARRAY)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
private
|
|
272
|
+
|
|
273
|
+
# Encoder matching and the noop check are pure functions of the (frozen)
|
|
274
|
+
# type node, the codec and the direction — but each costs a subtype? walk
|
|
275
|
+
# per registered encoder, and the same type object recurs across the
|
|
276
|
+
# fields of a schema (a shared `Types::Date` constant, a repeated
|
|
277
|
+
# `Types::String`). Memoize both per rewrite. As with @input_memo, `path`
|
|
278
|
+
# only feeds error text, so the first visit's path is the one reported.
|
|
279
|
+
def encoder_for(type, path)
|
|
280
|
+
@match_memo.fetch(type) { @match_memo[type] = @codec.encoder_for(type, path) }
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def noop?(type)
|
|
284
|
+
@noop_memo.fetch(type) { @noop_memo[type] = @codec.noop?(type, @direction) }
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def visit(type, path)
|
|
288
|
+
# These nodes are handled BEFORE encoder matching, because a wholesale
|
|
289
|
+
# replacement would drop machinery that is subtype-wise invisible:
|
|
290
|
+
#
|
|
291
|
+
# - a Static's fixed value matches an encoder atomically
|
|
292
|
+
# (`Static[a_date]` is a subtype of Date), and replacing it with a
|
|
293
|
+
# step expecting encoded input loses the static behaviour (eg. the
|
|
294
|
+
# default value in a `.default(...)`);
|
|
295
|
+
# - Or/And chains and transparent wrappers can carry generator guards
|
|
296
|
+
# (`(Undefined >> Static) | T`, the #default shape) — the whole
|
|
297
|
+
# composition IS a subtype of T, so an encoder would match and
|
|
298
|
+
# swallow the guard. Recursing rewrites the data-bearing parts and
|
|
299
|
+
# keeps the machinery; an encoder with a union output type still
|
|
300
|
+
# matches at branch level.
|
|
301
|
+
#
|
|
302
|
+
# Untouched subtrees come back identical, so there is no shortcut to
|
|
303
|
+
# take here — a whole-composite noop check would let a container-top
|
|
304
|
+
# noop (`noop Types::Array`) swallow a rewritable union
|
|
305
|
+
# (`Array[Date] | String`).
|
|
306
|
+
case type
|
|
307
|
+
when Deferred then return visit_deferred(type, path)
|
|
308
|
+
when StaticClass then return visit_static(type, path)
|
|
309
|
+
when Disjunction then return visit_or(type, path)
|
|
310
|
+
# Intersection BEFORE Conjunction — it is one too, and the two need
|
|
311
|
+
# opposite treatment (a meet may be reordered, a pipeline may not).
|
|
312
|
+
when Intersection then return visit_intersection(type, path)
|
|
313
|
+
when Conjunction then return visit_composition(type, path)
|
|
314
|
+
# Transparent wrappers: NodeMapper owns how each is rebuilt, so there is no
|
|
315
|
+
# second table of recipes to keep in step with it.
|
|
316
|
+
when Metadata, Policy, Composable::Node
|
|
317
|
+
return NodeMapper.map(type) { |t| visit(t, path) }
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Before encoder matching, for the Static reason: a source IS a subtype of what
|
|
321
|
+
# it declares, so an encoder would replace the code that produces the value.
|
|
322
|
+
return visit_source(type, path) if source_step?(type)
|
|
323
|
+
|
|
324
|
+
enc = encoder_for(type, path)
|
|
325
|
+
return replace(type, enc, path) if enc
|
|
326
|
+
|
|
327
|
+
case type
|
|
328
|
+
when HashClass then visit_hash(type, path)
|
|
329
|
+
when FilteredHash then visit_filtered_hash(type, path)
|
|
330
|
+
when ArrayClass, StreamClass then visit_array(type, path)
|
|
331
|
+
when TupleClass then visit_tuple(type, path)
|
|
332
|
+
when HashMap then visit_hash_map(type, path)
|
|
333
|
+
when TaggedHash then visit_tagged_hash(type, path)
|
|
334
|
+
else
|
|
335
|
+
if (struct = Plumb::Attributes.struct_class(type))
|
|
336
|
+
visit_struct(type, struct, path)
|
|
337
|
+
else
|
|
338
|
+
visit_leaf(type, path)
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# A leaf: nothing to recurse into structurally. It survives when it is OPAQUE,
|
|
344
|
+
# when the format already carries it (the noop check), or — decoding — when the
|
|
345
|
+
# codec can bridge what it CONSUMES.
|
|
346
|
+
def visit_leaf(type, path)
|
|
347
|
+
return type if opaque_step?(type)
|
|
348
|
+
|
|
349
|
+
bridge_input(type, path) || noop_or_fail(type, path)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# An OPAQUE step (`Any -> Any`) is a black box — a `#generate` generator, a bare
|
|
353
|
+
# proc in a schema. It names no type to rewrite, so it passes through, and what
|
|
354
|
+
# it emits lands unencoded, exactly as its `Any` output says.
|
|
355
|
+
def opaque_step?(type) = type.is_a?(Plumb::TypedStep) && type.opaque?
|
|
356
|
+
|
|
357
|
+
# A SOURCE step declares `Any -> T`: it produces a T out of whatever it is
|
|
358
|
+
# handed. The `#default { }` generator is one.
|
|
359
|
+
def source_step?(type)
|
|
360
|
+
type.is_a?(Plumb::TypedStep) && type.input_type.is_a?(AnyClass) && !type.output_type.is_a?(AnyClass)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# DECODE: leave it alone. Accepting `Any` it already accepts the encoded form,
|
|
364
|
+
# and it produces the decoded value itself (`Date.default { Date.today }` yields
|
|
365
|
+
# a Date, not a wire string).
|
|
366
|
+
#
|
|
367
|
+
# ENCODE: the rewrite is composed AFTER the type it encodes (see
|
|
368
|
+
# Codec#to_plumb_type), so the source has already run and the T it declared is
|
|
369
|
+
# what sits here. Rewriting the node would run the generator a second time.
|
|
370
|
+
def visit_source(type, path)
|
|
371
|
+
@direction == :decode ? type : visit(type.output_type, path)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# DECODE: a converting leaf (a Function, a Plumb::Implementation — any node
|
|
375
|
+
# whose accepted type is a distinct node) consumes values the encoded
|
|
376
|
+
# document does not carry: `Hash[time: Time] -> Thing` wants a Time, a JSON
|
|
377
|
+
# document has a String. Rewrite what it ACCEPTS through this codec and put
|
|
378
|
+
# that in front, so the step is fed the decoded value and is itself
|
|
379
|
+
# preserved. This is exactly the rule #visit_struct applies to a struct's
|
|
380
|
+
# schema — a struct IS such a node (`Hash[...] -> Person`) — generalized to
|
|
381
|
+
# every converting node. (Structs keep their own branch: encoding, they
|
|
382
|
+
# also need the `#attributes` extraction.)
|
|
383
|
+
#
|
|
384
|
+
# Returns nil when it does not apply: a leaf that accepts itself (any plain
|
|
385
|
+
# matcher), an opaque one, or one whose accepted side is already native —
|
|
386
|
+
# there the rewrite comes back identical and the noop check decides.
|
|
387
|
+
#
|
|
388
|
+
# ORDERING: this must run BEFORE the noop check, not after. A step
|
|
389
|
+
# accepting a typed Hash is "covered" by a generic `noop Types::Hash` — the
|
|
390
|
+
# same container-top shadowing #visit's ordering avoids for a HashClass
|
|
391
|
+
# node — so deferring to the noop check passes it through unrewritten and
|
|
392
|
+
# yields a decoder that type-errors at runtime.
|
|
393
|
+
def bridge_input(type, path)
|
|
394
|
+
return nil unless @direction == :decode
|
|
395
|
+
|
|
396
|
+
accepted = Plumb::Subtyping.accepted_type(type)
|
|
397
|
+
return nil if accepted.equal?(type) || accepted.is_a?(AnyClass)
|
|
398
|
+
|
|
399
|
+
rewritten = @bridge_memo.fetch(type) do
|
|
400
|
+
@bridge_memo[type] = visit(accepted, path + ["<#{type.inspect} input>"])
|
|
401
|
+
end
|
|
402
|
+
return nil if rewritten.equal?(accepted)
|
|
403
|
+
|
|
404
|
+
# The same node shape #visit_struct builds: ONE Function, whose input is
|
|
405
|
+
# the decoded form and whose output type IS the step (a Function's output
|
|
406
|
+
# stage calls it). So the JSON Schema describes the encoded side only —
|
|
407
|
+
# an `And` would merge the step's own input type back in and leak the
|
|
408
|
+
# decoded type into the wire description.
|
|
409
|
+
Function.new(rewritten, type, Plumb::NOOP)
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# A struct (Types::Data / Plumb::Attributes) is a Hash schema plus a
|
|
413
|
+
# constructor. Decoding, the rewritten schema turns input fields into
|
|
414
|
+
# output values and the class itself builds the instance (Function's
|
|
415
|
+
# output stage CALLS it). Encoding, the class validates/constructs the
|
|
416
|
+
# instance, `#attributes` exposes the output values (shallow — nested
|
|
417
|
+
# structs stay instances and are handled by their own rewritten nodes,
|
|
418
|
+
# unlike the deep #to_h), and the encode-rewritten schema turns them
|
|
419
|
+
# into input values. Either way a single Function node, so accepted/
|
|
420
|
+
# produced types and the JSON Schema stay honest.
|
|
421
|
+
def visit_struct(original, struct, path)
|
|
422
|
+
schema = visit(struct._schema, path)
|
|
423
|
+
if @direction == :decode
|
|
424
|
+
# All fields input-native already: the struct validates and
|
|
425
|
+
# constructs by itself.
|
|
426
|
+
return original if schema.equal?(struct._schema)
|
|
427
|
+
|
|
428
|
+
Function.new(schema, struct, Plumb::NOOP)
|
|
429
|
+
else
|
|
430
|
+
# The lambda is fresh per call, so name what the step IS as its identity —
|
|
431
|
+
# otherwise `(Person >> Codec) == (Person >> Codec)` is false while the
|
|
432
|
+
# decode direction (which passes NOOP) is true. @see Function#==
|
|
433
|
+
Function.new(struct, schema, ->(result) { result.valid!(result.value.attributes) },
|
|
434
|
+
identity: [:struct_encode, struct])
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# A Static ignores its input and emits a fixed value (eg. the default in
|
|
439
|
+
# a `.default(...)` composition). Decoding, that value is part of the
|
|
440
|
+
# output structure the schema declares — keep it as-is.
|
|
441
|
+
#
|
|
442
|
+
# Encoding is different: the suffix runs on values the schema has
|
|
443
|
+
# ALREADY produced (the Static ran when the schema did), and
|
|
444
|
+
# resolved_output collapses the `Undefined >> Static` guard of a
|
|
445
|
+
# #default, leaving the Static as an always-matching first Or branch —
|
|
446
|
+
# re-running it would clobber the actual value with the default. So on
|
|
447
|
+
# encode a Static becomes a CHECKED value: match the static value
|
|
448
|
+
# (against the VALUE, not the node — subtyping can't relate an atomic
|
|
449
|
+
# Static to a container noop), then emit its input form — the value
|
|
450
|
+
# itself when noop-covered, or its encoding, computed once here at
|
|
451
|
+
# composition time (the value is fixed, so its encoding is too).
|
|
452
|
+
def visit_static(type, path)
|
|
453
|
+
return type if @direction == :decode
|
|
454
|
+
|
|
455
|
+
value = type.children.first
|
|
456
|
+
# Encoder match FIRST, then noop — mirrors the invariant that real
|
|
457
|
+
# encoders always take precedence over pass-through types. Otherwise a
|
|
458
|
+
# static default whose value happens to satisfy a broad noop (eg. a
|
|
459
|
+
# BigDecimal under the Numeric noop) would emit raw instead of encoding.
|
|
460
|
+
if (enc = encoder_for(type, path))
|
|
461
|
+
encoded = replace(type, enc, path).parse(value)
|
|
462
|
+
Conjunction.build(ValueClass.new(value), StaticClass.new(encoded.freeze))
|
|
463
|
+
elsif @codec.noop_value?(value)
|
|
464
|
+
ValueClass.new(value)
|
|
465
|
+
else
|
|
466
|
+
noop_or_fail(type, path)
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# Replace a matched type with the oriented Step. The encoder's input
|
|
471
|
+
# type is itself rewritten through this codec — that's what lets
|
|
472
|
+
# `Hash[from: Date, to: Date]` resolve its Dates via a String<=>Date
|
|
473
|
+
# encoder in the same group. The rewritten input type (and, when the
|
|
474
|
+
# matched type is a value-preserving strict subtype of the encoder's
|
|
475
|
+
# output, the narrowed type) are spliced into the Step, so each rewritten
|
|
476
|
+
# field stays a single node.
|
|
477
|
+
def replace(type, enc, path)
|
|
478
|
+
# An encoder whose own input type contains its output type re-matches
|
|
479
|
+
# while we rewrite that input (enc is on the stack). If that output is
|
|
480
|
+
# noop-covered it's a literal acceptance, not a recursive encoding —
|
|
481
|
+
# leave the type alone (eg. Forms' NilEncoder input `String[''] | Nil`,
|
|
482
|
+
# so a nullable field decodes a literal nil). Otherwise it's a genuine
|
|
483
|
+
# input-type cycle.
|
|
484
|
+
if @input_stack.include?(enc)
|
|
485
|
+
return type if noop?(type)
|
|
486
|
+
|
|
487
|
+
raise Plumb::TypeError,
|
|
488
|
+
"#{@codec.inspect}: encoder input-type cycle: #{(@input_stack + [enc]).map(&:inspect).join(' -> ')}"
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# A generic encoder builds its input from the matched type (see
|
|
492
|
+
# Encoder.input_type_for), so the input — and its rewrite — vary per
|
|
493
|
+
# matched type, NOT per encoder: memoize by matched type (direction is
|
|
494
|
+
# fixed per Rewriter; the path only feeds error text on the first
|
|
495
|
+
# visit). Fields sharing a type object (eg. the same `Types::Date`
|
|
496
|
+
# constant) still rewrite their input once.
|
|
497
|
+
input = enc.input_type_for(type)
|
|
498
|
+
rewritten_input = @input_memo.fetch(type) do
|
|
499
|
+
@input_stack.push(enc)
|
|
500
|
+
@input_memo[type] = visit(input, path + ["<#{enc.inspect} input>"])
|
|
501
|
+
ensure
|
|
502
|
+
@input_stack.pop
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# Splice the input into the step unless it is the encoder's declared
|
|
506
|
+
# input type unchanged (then the base step already carries it). A
|
|
507
|
+
# generic encoder's member-specialized input is never that base type, so
|
|
508
|
+
# it is always spliced — the step reports the specific input, not the top.
|
|
509
|
+
input_arg = rewritten_input.equal?(enc.input_type) ? nil : rewritten_input
|
|
510
|
+
enc.step(@direction, input_side: input_arg, output_side: narrowed_side(type, enc))
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# When the matched type is strictly narrower than the encoder's declared
|
|
514
|
+
# output AND is a pure filter, keep it as the output-side check (decode
|
|
515
|
+
# re-validates the produced value against it; encode validates the input
|
|
516
|
+
# against it). A value-CONVERTING narrower type is dropped — re-running
|
|
517
|
+
# its conversion on an already-decoded value would be wrong; the
|
|
518
|
+
# encoder's declared type stands in.
|
|
519
|
+
def narrowed_side(type, enc)
|
|
520
|
+
return nil unless Plumb::Subtyping.value_preserving?(type)
|
|
521
|
+
return nil unless Plumb::Subtyping.strict_subtype?(type, enc.output_type)
|
|
522
|
+
|
|
523
|
+
type
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def visit_hash(type, path)
|
|
527
|
+
return noop_or_fail(type, path) if type._schema.empty? # the bare "any Hash" — a leaf
|
|
528
|
+
|
|
529
|
+
NodeMapper.map_record(type) do |field, key|
|
|
530
|
+
visit(field, path + [key.literal? ? key.to_s : key.inspect])
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
# DECODE rewrites the schema a filter FILTERS and rebuilds the filter around it,
|
|
535
|
+
# so decoding happens inside it, per field: #bridge_input's spliced step would
|
|
536
|
+
# decode the whole hash first, making one unreadable field fatal — what
|
|
537
|
+
# .filtered exists to avoid. (The filtered Array and HashMap need none of this;
|
|
538
|
+
# being containers they rewrite through #visit_array / #visit_hash_map.)
|
|
539
|
+
#
|
|
540
|
+
# ENCODE: `resolved_output` reduces the filter to its relaxed schema before the
|
|
541
|
+
# rewrite, so this is only reached defensively.
|
|
542
|
+
def visit_filtered_hash(type, path)
|
|
543
|
+
return visit(type.output_type, path) unless @direction == :decode
|
|
544
|
+
|
|
545
|
+
schema = visit(type.input_type, path)
|
|
546
|
+
schema.equal?(type.input_type) ? type : schema.filtered
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
def visit_array(type, path)
|
|
550
|
+
return noop_or_fail(type, path) if type.children.first.is_a?(AnyClass) # untyped — a leaf
|
|
551
|
+
|
|
552
|
+
NodeMapper.map_children(type) { |element| visit(element, path + ['[]']) }
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def visit_tuple(type, path)
|
|
556
|
+
members = type.children.each_with_index.map { |m, i| visit(m, path + ["[#{i}]"]) }
|
|
557
|
+
members.zip(type.children).all? { |v, m| v.equal?(m) } ? type : type.of(*members)
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
# Keys are left untouched — key normalization (eg. string keys from the
|
|
561
|
+
# input) is a separate concern (see Types::SymbolizedHash / #symbolized).
|
|
562
|
+
def visit_hash_map(type, path)
|
|
563
|
+
key_type, value_type = type.children
|
|
564
|
+
v = visit(value_type, path + ['{}'])
|
|
565
|
+
# type.class (not HashMap) to preserve FilteredHashMap's leniency.
|
|
566
|
+
v.equal?(value_type) ? type : type.class.new(key_type, v)
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
# A tagged union of Hash variants discriminated by a key: rewrite each
|
|
570
|
+
# variant schema (all HashClasses); the tag key's literal value is a
|
|
571
|
+
# native pass-through, so the discriminator survives.
|
|
572
|
+
def visit_tagged_hash(type, path)
|
|
573
|
+
NodeMapper.map_children(type) { |variant| visit(variant, path) }
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
# Rewriting can collapse two branches onto the SAME node — encoding a
|
|
577
|
+
# `#default { }`, both resolve to the type. Keep one: the second is unreachable,
|
|
578
|
+
# and a failing encode would otherwise report its errors twice.
|
|
579
|
+
def visit_or(type, path)
|
|
580
|
+
left, right = type.children
|
|
581
|
+
l = visit(left, path)
|
|
582
|
+
r = visit(right, path)
|
|
583
|
+
return type if l.equal?(left) && r.equal?(right)
|
|
584
|
+
|
|
585
|
+
l == r ? l : Disjunction.build(l, r)
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# A MEET: a data-bearing type refined by pure filters (a #where's
|
|
589
|
+
# AttributeValueMatch, a #check Constraint, ...). Every side constrains the SAME
|
|
590
|
+
# value and nothing converts, so the sides may be flattened and reordered freely,
|
|
591
|
+
# which is what makes the partition sound.
|
|
592
|
+
#
|
|
593
|
+
# On DECODE the codec replaces the schema, so the refinements are the only
|
|
594
|
+
# validation of the decoded value — keep them AFTER the conversion. On ENCODE the
|
|
595
|
+
# value was already validated upstream and they would run on the encoded form, so
|
|
596
|
+
# drop them.
|
|
597
|
+
def visit_intersection(type, path)
|
|
598
|
+
steps = flatten_intersection(type)
|
|
599
|
+
refinements, data = steps.partition { |s| pure_refinement?(s) }
|
|
600
|
+
rewritten = data.map { |d| visit(d, path) }
|
|
601
|
+
return type if rewritten.each_with_index.all? { |r, i| r.equal?(data[i]) }
|
|
602
|
+
|
|
603
|
+
ordered = @direction == :decode ? rewritten + refinements : rewritten
|
|
604
|
+
ordered.reduce { |l, r| Conjunction.build(l, r) }
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
# Flattens MEETS ONLY. Descending through a composition here is what caused
|
|
608
|
+
# a refinement to be lifted past a conversion — see #visit_composition.
|
|
609
|
+
def flatten_intersection(type)
|
|
610
|
+
type.children.flat_map { |c| c.is_a?(Intersection) ? flatten_intersection(c) : [c] }
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
# A PIPELINE (`And`): each step consumes what the previous one produced, so
|
|
614
|
+
# position is meaning. Nothing may be flattened out of it, reordered, or
|
|
615
|
+
# lifted — visit both sides IN PLACE.
|
|
616
|
+
#
|
|
617
|
+
# The meet treatment above must NOT be applied here. Doing so silently breaks
|
|
618
|
+
# any refinement sitting BEFORE a conversion:
|
|
619
|
+
# `Date.where(year: 2024) >> Date.transform(::String)` is
|
|
620
|
+
# `And(Intersection(Date, AVM), Function)`, and flattening across the And puts
|
|
621
|
+
# the `year` check last — after the Date has become a String, where it can
|
|
622
|
+
# never pass, so the decoder rejects every input.
|
|
623
|
+
#
|
|
624
|
+
# A refinement child is left exactly where it is rather than visited: it
|
|
625
|
+
# carries no encodable type (see #pure_refinement?), so the codec has nothing
|
|
626
|
+
# to rewrite in it, and it validates whatever the step before it produced.
|
|
627
|
+
#
|
|
628
|
+
# DECODE rewrites AT MOST ONE data step — the first facing the wire. `And(a, b)`
|
|
629
|
+
# feeds the input to `a`, and `b` receives what `a` produced, so once `a` accepts
|
|
630
|
+
# the encoded form while still producing what it produced, `b` needs nothing.
|
|
631
|
+
# Rewriting it too decodes twice: #bridge_input splices `b`'s own decode step in
|
|
632
|
+
# front, the already-decoded value hits a step expecting the encoded form, and the
|
|
633
|
+
# pipeline rejects everything. A step only counts as having consumed the wire if
|
|
634
|
+
# its rewrite actually CHANGED it — or if it is a black box, which says the same
|
|
635
|
+
# thing: what it hands on is unknown. `Types::Integer.generate { 1 }` is
|
|
636
|
+
# `(opaque >> Integer)`, and rewriting that Integer into the codec's
|
|
637
|
+
# `String -> Integer` step would feed it the generated Integer and reject
|
|
638
|
+
# everything.
|
|
639
|
+
def visit_composition(type, path)
|
|
640
|
+
left, right = type.children
|
|
641
|
+
l = pure_refinement?(left) ? left : visit(left, path)
|
|
642
|
+
r = pure_refinement?(right) || wire_consumed?(left, l) ? right : visit(right, path)
|
|
643
|
+
|
|
644
|
+
l.equal?(left) && r.equal?(right) ? type : Conjunction.build(l, r)
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
# Encoding, nothing consumes a wire: every step is rewritten in place.
|
|
648
|
+
def wire_consumed?(left, rewritten)
|
|
649
|
+
return false unless @direction == :decode
|
|
650
|
+
|
|
651
|
+
!rewritten.equal?(left) || opaque_step?(rewritten) || source_step?(rewritten)
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
# A pure refinement carries no encodable type — it filters the adjacent
|
|
655
|
+
# type's values. A baseless Constraint refining a sibling qualifies, but a
|
|
656
|
+
# base-type Constraint — `Types::Date` IS `Constraint(::Date)` — is data an
|
|
657
|
+
# encoder must see (eg. in `Types::Date.where(year: ...)` ==
|
|
658
|
+
# `And(Constraint(::Date), AVM)`), and so is a baseless Module gate, which
|
|
659
|
+
# names a type rather than filtering one.
|
|
660
|
+
#
|
|
661
|
+
# `Any` is the TRIVIAL filter, so it qualifies too — but only ALONGSIDE a sibling
|
|
662
|
+
# that carries a type; on its own it is still a leaf, and still an error. It gets
|
|
663
|
+
# here from an opaque step's output slot, which is what `resolved_output` leaves
|
|
664
|
+
# behind (`Integer.generate { 1 }` resolves to `(Any >> Integer)`): the value is
|
|
665
|
+
# unknown until the Integer beside it vouches for it, and that is what to encode.
|
|
666
|
+
def pure_refinement?(child)
|
|
667
|
+
case child
|
|
668
|
+
when AttributeValueMatch, ValueClass, Not, AnyClass then true
|
|
669
|
+
when Constraint then child.base.nil? && !child.matcher.is_a?(::Module)
|
|
670
|
+
else false
|
|
671
|
+
end
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
# Recursive/self-referential types rewrite lazily: the memo ties the knot
|
|
675
|
+
# (all references to one Deferred share one rewritten Deferred), and any
|
|
676
|
+
# unmatched-type error inside the body surfaces at first resolution.
|
|
677
|
+
def visit_deferred(type, path)
|
|
678
|
+
@deferred_memo[type] ||= Deferred.new(-> { visit(type.type, path) })
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
def noop_or_fail(type, path)
|
|
682
|
+
if noop?(type)
|
|
683
|
+
return type if @direction == :decode
|
|
684
|
+
|
|
685
|
+
# Encode: the value at this position is the type's OUTPUT — pass
|
|
686
|
+
# that through, so a coercion field is not re-run on parsed values.
|
|
687
|
+
out = Plumb::Subtyping.resolved_output(type)
|
|
688
|
+
out.equal?(type) ? type : out
|
|
689
|
+
else
|
|
690
|
+
raise Plumb::TypeError,
|
|
691
|
+
"cannot apply #{@codec.inspect} (#{@direction}) to #{@root.inspect}: " \
|
|
692
|
+
"#{@codec.at_path(path)} (#{type.inspect}) matches no encoder and is not covered by its noop types. " \
|
|
693
|
+
'Register an encoder for it, or declare it with .noop.'
|
|
694
|
+
end
|
|
695
|
+
end
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
# ------------------------------------------------------------------
|
|
699
|
+
# Shared, format-neutral encoders — string representations standardized
|
|
700
|
+
# by ISO 8601 (dates, times) and RFC 3986 (URIs) — registered by both
|
|
701
|
+
# built-in codecs, and usable per-field in any schema. Their input types
|
|
702
|
+
# carry `format:` metadata, so generated JSON Schemas describe the fields
|
|
703
|
+
# as eg. `{type: "string", format: "date"}`.
|
|
704
|
+
# ------------------------------------------------------------------
|
|
705
|
+
|
|
706
|
+
TIME_EXPR = /\A\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?\z/
|
|
707
|
+
# Scheme-prefixed URI strings, per RFC 3986 — URI.parse alone is too
|
|
708
|
+
# permissive (a blank string is a valid URI).
|
|
709
|
+
URI_EXPR = /\A[a-z][a-z0-9+\-.]*:/i
|
|
710
|
+
# Decimal or scientific notation — Float#to_s emits scientific for very
|
|
711
|
+
# small/large magnitudes ("1.0e-05"), so the input type must accept what
|
|
712
|
+
# encode produces or a valid Float fails its own round-trip.
|
|
713
|
+
FLOAT_EXPR = /\A-?\d+(\.\d+)?([eE][+-]?\d+)?\z/
|
|
714
|
+
|
|
715
|
+
# Date <=> ISO 8601 date string ("2024-01-30").
|
|
716
|
+
class DateEncoder < Encoder[Types::String[/\A\d{4}-\d{2}-\d{2}\z/].metadata(format: 'date') => Types::Date]
|
|
717
|
+
def encode(date) = date.iso8601
|
|
718
|
+
def decode(str) = ::Date.parse(str)
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
# Time <=> ISO 8601 date-time string ("2024-08-30T20:15:23.456789Z").
|
|
722
|
+
#
|
|
723
|
+
class TimeEncoder < Encoder[Types::String[TIME_EXPR].metadata(format: 'date-time') => Types::Time]
|
|
724
|
+
def encode(time) = time.iso8601(6)
|
|
725
|
+
def decode(str) = ::Time.parse(str)
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
# Symbol <=> String. Ruby data structures use Symbols for enum-ish
|
|
729
|
+
# values (`status: :active`); the encoded form carries them as strings.
|
|
730
|
+
# A narrowed field (`Types::Symbol[:draft, :published]`) keeps its check.
|
|
731
|
+
class SymbolEncoder < Encoder[Types::String => Types::Symbol]
|
|
732
|
+
def encode(sym) = sym.to_s
|
|
733
|
+
def decode(str) = str.to_sym
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
# BigDecimal <=> canonical decimal string ("1.5"). A string, not a
|
|
737
|
+
# number: decimals exist to avoid float precision loss, and JSON numbers
|
|
738
|
+
# are floats. In Codec::JSON this also keeps Decimal fields from silently
|
|
739
|
+
# noop-passing via the Numeric noop (a BigDecimal is not JSON-native).
|
|
740
|
+
class DecimalEncoder < Encoder[Types::String[FLOAT_EXPR] => Types::Decimal]
|
|
741
|
+
def encode(dec) = dec.to_s('F')
|
|
742
|
+
def decode(str) = BigDecimal(str)
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
# URI <=> scheme-prefixed URI string. The narrower HTTP/File variants win
|
|
746
|
+
# most-specific matching for fields typed as such; the produced value is
|
|
747
|
+
# validated against the declared URI class.
|
|
748
|
+
class URIEncoder < Encoder[Types::String[URI_EXPR].metadata(format: 'uri') => Types::URI::Generic]
|
|
749
|
+
def encode(uri) = uri.to_s
|
|
750
|
+
def decode(str) = ::URI.parse(str)
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
# Re-parameterized subclasses: same input type and methods, narrower
|
|
754
|
+
# output — the produced URI is validated against the declared class.
|
|
755
|
+
HTTPURIEncoder = URIEncoder[URIEncoder.input_type => Types::URI::HTTP]
|
|
756
|
+
FileURIEncoder = URIEncoder[URIEncoder.input_type => Types::URI::File]
|
|
757
|
+
|
|
758
|
+
# Range <=> a JSON object `{ from:, to:, exclusive: }`. Generic over the
|
|
759
|
+
# range's member type: it matches ANY `Range[member]` (its output type is
|
|
760
|
+
# the Range top), and #input_type_for builds the input from the matched
|
|
761
|
+
# member — so a `Range[Date]` serializes its endpoints as ISO strings, a
|
|
762
|
+
# `Range[Integer]` as JSON numbers, etc., because the codec rewrites those
|
|
763
|
+
# member-typed `from`/`to` fields through itself. `from`/`to` are nullable
|
|
764
|
+
# for beginless/endless ranges; `exclusive` records whether the end is
|
|
765
|
+
# excluded (`1...5` vs `1..5`).
|
|
766
|
+
class RangeEncoder < Encoder[
|
|
767
|
+
Types::Hash[
|
|
768
|
+
from: Types::Any.nullable,
|
|
769
|
+
to: Types::Any.nullable,
|
|
770
|
+
exclusive: Types::Boolean
|
|
771
|
+
] => Types::Range
|
|
772
|
+
]
|
|
773
|
+
# Specialize the input to the matched range's member type. A non-Range
|
|
774
|
+
# match (should not happen — only RangeClass is a subtype of the Range
|
|
775
|
+
# top) falls back to the generic declared input.
|
|
776
|
+
def self.input_type_for(matched)
|
|
777
|
+
return input_type unless matched.is_a?(Plumb::RangeClass)
|
|
778
|
+
|
|
779
|
+
member = matched.children.first
|
|
780
|
+
Types::Hash[
|
|
781
|
+
from: member.nullable,
|
|
782
|
+
to: member.nullable,
|
|
783
|
+
exclusive: Types::Boolean
|
|
784
|
+
]
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
def encode(range)
|
|
788
|
+
{ from: range.begin, to: range.end, exclusive: range.exclude_end? }
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
def decode(hash)
|
|
792
|
+
::Range.new(hash[:from], hash[:to], hash[:exclusive])
|
|
793
|
+
end
|
|
794
|
+
end
|
|
795
|
+
|
|
796
|
+
# A base codec for JSON-like targets: the scalars native to JSON pass
|
|
797
|
+
# through (structured containers are handled structurally by the rewrite;
|
|
798
|
+
# the bare Hash/Array tops cover untyped ones), and Dates/Times/URIs map
|
|
799
|
+
# to their standard string forms. Subclass it and register encoders:
|
|
800
|
+
#
|
|
801
|
+
# class MyCodec < Plumb::Codec::JSON
|
|
802
|
+
# encoder MoneyEncoder
|
|
803
|
+
# end
|
|
804
|
+
#
|
|
805
|
+
# A subclass encoder for an equivalent type (eg. its own Date encoder)
|
|
806
|
+
# takes precedence over the built-in one.
|
|
807
|
+
class JSON < self
|
|
808
|
+
noop Types::String, Types::Numeric, Types::Integer, Types::Float,
|
|
809
|
+
Types::True, Types::False, Types::Nil,
|
|
810
|
+
Types::Hash, Types::Array
|
|
811
|
+
|
|
812
|
+
encoder DateEncoder, TimeEncoder, SymbolEncoder, DecimalEncoder
|
|
813
|
+
encoder URIEncoder, HTTPURIEncoder, FileURIEncoder
|
|
814
|
+
encoder RangeEncoder
|
|
815
|
+
end
|
|
816
|
+
|
|
817
|
+
# A base codec for string-based formats — HTML forms, query strings, CSV
|
|
818
|
+
# cells — where EVERY value arrives as a string. Unlike Codec::JSON there
|
|
819
|
+
# are almost no native scalars: strings pass through, untyped containers
|
|
820
|
+
# recurse structurally (Rack-style nested params), and everything else
|
|
821
|
+
# maps through an encoder whose input type is a strictly-patterned string.
|
|
822
|
+
#
|
|
823
|
+
# Config = Types::Hash[port: Types::Integer, active: Types::Boolean, on: Types::Date]
|
|
824
|
+
# decoder, encoder = Plumb::Codec::Forms.for(Config)
|
|
825
|
+
# decoder.parse({ port: '80', active: '1', on: '2024-01-30' })
|
|
826
|
+
# # => { port: 80, active: true, on: Date(2024-01-30) }
|
|
827
|
+
class Forms < self
|
|
828
|
+
INTEGER_EXPR = /\A-?\d+\z/
|
|
829
|
+
|
|
830
|
+
class IntegerEncoder < Encoder[Types::String[INTEGER_EXPR] => Integer]
|
|
831
|
+
def encode(int) = int.to_s
|
|
832
|
+
def decode(str) = str.to_i
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
class FloatEncoder < Encoder[Types::String[FLOAT_EXPR] => Float]
|
|
836
|
+
def encode(float) = float.to_s
|
|
837
|
+
def decode(str) = str.to_f
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
# For fields typed as the Numeric union. A more specific field (Integer,
|
|
841
|
+
# Float, Decimal) picks its own encoder via most-specific matching.
|
|
842
|
+
class NumericEncoder < Encoder[Types::String[FLOAT_EXPR] => Numeric]
|
|
843
|
+
def encode(num) = num.is_a?(BigDecimal) ? num.to_s('F') : num.to_s
|
|
844
|
+
# A fractional or scientific string is a Float; a plain integer literal
|
|
845
|
+
# is an Integer ("1e20".to_i would wrongly be 1).
|
|
846
|
+
def decode(str) = str.match?(/[.eE]/) ? str.to_f : str.to_i
|
|
847
|
+
end
|
|
848
|
+
|
|
849
|
+
# Booleans are two encoders: a field typed Types::Boolean is the
|
|
850
|
+
# `True | False` union underneath, and each branch matches its own.
|
|
851
|
+
class TrueEncoder < Encoder[(Types::String[/\Atrue\z/i] | '1') => Types::True]
|
|
852
|
+
def encode(_bool) = 'true'
|
|
853
|
+
def decode(_str) = true
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
class FalseEncoder < Encoder[(Types::String[/\Afalse\z/i] | '0') => Types::False]
|
|
857
|
+
def encode(_bool) = 'false'
|
|
858
|
+
def decode(_str) = false
|
|
859
|
+
end
|
|
860
|
+
|
|
861
|
+
# An empty or absent form field is nil — so `Types::Date | Types::Nil`
|
|
862
|
+
# decodes '' AND a literal nil (valueless params, eg. Rack's
|
|
863
|
+
# `parse_nested_query('x')`) to nil, and '2024-01-30' to a Date. The Nil
|
|
864
|
+
# in the input is left as a literal acceptance during input rewriting (it
|
|
865
|
+
# is noop-covered), not recursively re-encoded.
|
|
866
|
+
class NilEncoder < Encoder[(Types::String[''] | Types::Nil) => Types::Nil]
|
|
867
|
+
def encode(_nil) = ''
|
|
868
|
+
def decode(_value) = nil
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
noop Types::String, Types::Nil, Types::Hash, Types::Array
|
|
872
|
+
|
|
873
|
+
encoder IntegerEncoder, FloatEncoder, NumericEncoder
|
|
874
|
+
encoder TrueEncoder, FalseEncoder, NilEncoder
|
|
875
|
+
# Format-neutral string encoders, shared with Codec::JSON.
|
|
876
|
+
encoder DateEncoder, TimeEncoder, SymbolEncoder, DecimalEncoder
|
|
877
|
+
encoder URIEncoder, HTTPURIEncoder, FileURIEncoder
|
|
878
|
+
end
|
|
879
|
+
|
|
880
|
+
private
|
|
881
|
+
|
|
882
|
+
def entry(key)
|
|
883
|
+
@entries.fetch(key) { raise NoEntryError, "no encoder/decoder registered for #{key}" }
|
|
884
|
+
end
|
|
885
|
+
end
|
|
886
|
+
end
|