plumb 0.3.1 → 0.4.0
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 +6 -4
- data/lib/plumb/attributes.rb +20 -0
- data/lib/plumb/codec.rb +58 -9
- data/lib/plumb/deferred.rb +21 -0
- data/lib/plumb/disjunction.rb +116 -0
- data/lib/plumb/hash_class.rb +16 -5
- data/lib/plumb/key.rb +22 -3
- data/lib/plumb/tagged_hash.rb +6 -3
- data/lib/plumb/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6a6433ce0705844548d8c5488e0a454335eecb2dc385ded5e398dabbce66f8f6
|
|
4
|
+
data.tar.gz: aed47d0aef0f1a1a685ae9e4b164451484b9cd2aa4763f0cfd0293c5bb90ed99
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b9a3ef7bb37d3239cca40a7a925bfad585b4490f02e6292310072a7d7d87361f0a39450cec257dbe7e1567234dd4cbc2c05610efbe245a219a2835cc8ecc1830
|
|
7
|
+
data.tar.gz: f3ea81577b79c024044615d7079acec13d40fc7db7435163301632d70d37dc24bedd23d6720a2d5781476b6b5c8ef308b1e7ee0b0739ded2b1e88953e5b68fb2
|
data/README.md
CHANGED
|
@@ -2031,14 +2031,16 @@ Composing a codec with a type rewrites the type deeply, in either direction:
|
|
|
2031
2031
|
Person = Types::Hash[name: Types::String, dates: DateRange]
|
|
2032
2032
|
|
|
2033
2033
|
JSONPerson = JSONCodec >> Person # decode: JSON structures -> Person
|
|
2034
|
-
JSONPerson.parse({ name
|
|
2034
|
+
JSONPerson.parse({ 'name' => 'Joe', 'dates' => { 'from' => '2024-01-01', 'to' => '2024-02-01' } })
|
|
2035
2035
|
# => { name: 'Joe', dates: Date(2024-01-01)..Date(2024-02-01) }
|
|
2036
2036
|
|
|
2037
2037
|
EncodedPerson = Person >> JSONCodec # encode: Person -> JSON structures
|
|
2038
2038
|
EncodedPerson.parse({ name: 'Joe', dates: Date.new(2024, 1, 1)..Date.new(2024, 2, 1) })
|
|
2039
|
-
# => { name
|
|
2039
|
+
# => { 'name' => 'Joe', 'dates' => { 'from' => '2024-01-01', 'to' => '2024-02-01' } }
|
|
2040
2040
|
```
|
|
2041
2041
|
|
|
2042
|
+
Hash keys are rewritten like values: a Symbol key travels as whatever the codec encodes a Symbol to (a String, for both built-in codecs). Decoding reads `'name'` and emits `:name` — still accepting `:name` itself, with the wire key winning if both are present — and encoding emits `'name'`. `Types::Hash[Types::Symbol, V]` map keys rewrite the same way. A codec with no Symbol encoder leaves keys alone.
|
|
2043
|
+
|
|
2042
2044
|
`Codec.for(type)` returns both directions as a `[decoding, encoding]` pair:
|
|
2043
2045
|
|
|
2044
2046
|
```ruby
|
|
@@ -2154,10 +2156,10 @@ Config = Types::Hash[
|
|
|
2154
2156
|
]
|
|
2155
2157
|
|
|
2156
2158
|
decoder, encoder = Plumb::Codec::Forms.for(Config)
|
|
2157
|
-
decoder.parse({ host
|
|
2159
|
+
decoder.parse({ 'host' => 'http://example.com', 'port' => '80', 'active' => '1', 'starts_on' => '' })
|
|
2158
2160
|
# => { host: URI(...), port: 80, active: true, starts_on: nil }
|
|
2159
2161
|
encoder.parse({ host: URI.parse('http://example.com'), port: 80, active: true, starts_on: nil })
|
|
2160
|
-
# => { host
|
|
2162
|
+
# => { 'host' => 'http://example.com', 'port' => '80', 'active' => 'true', 'starts_on' => '' }
|
|
2161
2163
|
```
|
|
2162
2164
|
|
|
2163
2165
|
`Codec::Forms` replaces the old one-way `Types::Forms` namespace. The input types are strict — actual integers or booleans are *not* accepted on decode, since form data is always strings; apply the codec at the boundary and write schemas in output types.
|
data/lib/plumb/attributes.rb
CHANGED
|
@@ -193,6 +193,13 @@ module Plumb
|
|
|
193
193
|
|
|
194
194
|
def prepare_attributes(attrs) = attrs
|
|
195
195
|
|
|
196
|
+
# @see ._build_validated
|
|
197
|
+
def _assign_validated(attrs)
|
|
198
|
+
@errors = {}
|
|
199
|
+
@attributes = prepare_attributes(attrs)
|
|
200
|
+
freeze
|
|
201
|
+
end
|
|
202
|
+
|
|
196
203
|
module ClassMethods
|
|
197
204
|
def _set_pipeline(pl)
|
|
198
205
|
@_pipeline = pl
|
|
@@ -249,6 +256,19 @@ module Plumb
|
|
|
249
256
|
|
|
250
257
|
MUST_BE_HASH = ['Must be a Hash of attributes'].freeze
|
|
251
258
|
|
|
259
|
+
# An instance from attributes already validated against this struct's schema —
|
|
260
|
+
# a codec decoder's output — without validating again, which would re-run
|
|
261
|
+
# converting fields on converted values. Skips #initialize; #prepare_attributes
|
|
262
|
+
# still runs. Override it when building needs more. A struct with pipeline
|
|
263
|
+
# steps (see .step) is built by #new, as the steps expect.
|
|
264
|
+
# @param attrs [Hash] validated, and owned by the new instance
|
|
265
|
+
# @return [Plumb::Attributes]
|
|
266
|
+
def _build_validated(attrs)
|
|
267
|
+
return new(attrs) unless _pipeline.is_a?(AnyClass)
|
|
268
|
+
|
|
269
|
+
allocate.tap { |instance| instance.send(:_assign_validated, attrs) }
|
|
270
|
+
end
|
|
271
|
+
|
|
252
272
|
# The Plumb::Callable interface
|
|
253
273
|
# @param result [Plumb::Result]
|
|
254
274
|
# @return [Plumb::Result]
|
data/lib/plumb/codec.rb
CHANGED
|
@@ -276,6 +276,7 @@ module Plumb
|
|
|
276
276
|
@match_memo = {}.compare_by_identity
|
|
277
277
|
@noop_memo = {}.compare_by_identity
|
|
278
278
|
@bridge_memo = {}.compare_by_identity
|
|
279
|
+
@key_memo = {}
|
|
279
280
|
@input_stack = []
|
|
280
281
|
@root = nil
|
|
281
282
|
end
|
|
@@ -428,8 +429,9 @@ module Plumb
|
|
|
428
429
|
|
|
429
430
|
# A struct (Types::Data / Plumb::Attributes) is a Hash schema plus a
|
|
430
431
|
# constructor. Decoding, the rewritten schema turns input fields into
|
|
431
|
-
# output values and the
|
|
432
|
-
# output stage
|
|
432
|
+
# output values and the step builds the instance from them without
|
|
433
|
+
# validating again (the output stage then passes the instance through).
|
|
434
|
+
# Encoding, the class validates/constructs the
|
|
433
435
|
# instance, `#attributes` exposes the output values (shallow — nested
|
|
434
436
|
# structs stay instances and are handled by their own rewritten nodes,
|
|
435
437
|
# unlike the deep #to_h), and the encode-rewritten schema turns them
|
|
@@ -442,7 +444,13 @@ module Plumb
|
|
|
442
444
|
# constructs by itself.
|
|
443
445
|
return original if schema.equal?(struct._schema)
|
|
444
446
|
|
|
445
|
-
|
|
447
|
+
# The rewritten schema has validated and converted every field, so the
|
|
448
|
+
# instance is built without running the struct's own schema again.
|
|
449
|
+
build = lambda do |result|
|
|
450
|
+
instance = struct._build_validated(result.value)
|
|
451
|
+
instance.valid? ? result.valid(instance) : result.invalid(instance, errors: instance.errors.to_h)
|
|
452
|
+
end
|
|
453
|
+
Function.new(schema, struct, build, identity: [:struct_decode, struct])
|
|
446
454
|
else
|
|
447
455
|
# The lambda is fresh per call, so name what the step IS as its identity —
|
|
448
456
|
# otherwise `(Person >> Codec) == (Person >> Codec)` is false while the
|
|
@@ -543,9 +551,31 @@ module Plumb
|
|
|
543
551
|
def visit_hash(type, path)
|
|
544
552
|
return noop_or_fail(type, path) if type._schema.empty? # the bare "any Hash" — a leaf
|
|
545
553
|
|
|
546
|
-
|
|
547
|
-
|
|
554
|
+
changed = false
|
|
555
|
+
schema = type._schema.each_with_object({}) do |(key, field), acc|
|
|
556
|
+
mapped = visit(field, path + [key.literal? ? key.to_s : key.inspect])
|
|
557
|
+
wkey = wire_key(key)
|
|
558
|
+
changed ||= !mapped.equal?(field) || !wkey.equal?(key)
|
|
559
|
+
acc[wkey] = mapped
|
|
548
560
|
end
|
|
561
|
+
changed ? type.class.new(schema:) : type
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
# A Symbol key is a value like any other, so its wire name is what the codec
|
|
565
|
+
# encodes a Symbol to — fixed, so computed here. The key is ALIASED: decoding
|
|
566
|
+
# reads the wire name and emits the Symbol, encoding the reverse (see
|
|
567
|
+
# Key#aliased?). A codec with no Symbol encoder leaves keys alone.
|
|
568
|
+
def wire_key(key)
|
|
569
|
+
name = key.to_key
|
|
570
|
+
wire = wire_name(name) if key.literal? && name.is_a?(::Symbol)
|
|
571
|
+
return key if wire.nil? || wire == name
|
|
572
|
+
|
|
573
|
+
to, from = @direction == :decode ? [name, wire] : [wire, name]
|
|
574
|
+
Key.new(to, optional: key.optional?, from:)
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
def wire_name(sym)
|
|
578
|
+
@key_memo.fetch(sym) { @key_memo[sym] = encoder_for(Types::Symbol, BLANK_ARRAY)&.encode(sym) }
|
|
549
579
|
end
|
|
550
580
|
|
|
551
581
|
# DECODE rewrites the schema a filter FILTERS and rebuilds the filter around it,
|
|
@@ -574,20 +604,39 @@ module Plumb
|
|
|
574
604
|
members.zip(type.children).all? { |v, m| v.equal?(m) } ? type : type.of(*members)
|
|
575
605
|
end
|
|
576
606
|
|
|
577
|
-
# Keys are left untouched — key normalization (eg. string keys from the
|
|
578
|
-
# input) is a separate concern (see Types::SymbolizedHash / #symbolized).
|
|
579
607
|
def visit_hash_map(type, path)
|
|
580
608
|
key_type, value_type = type.children
|
|
609
|
+
k = visit_map_key(key_type, path)
|
|
581
610
|
v = visit(value_type, path + ['{}'])
|
|
582
611
|
# type.class (not HashMap) to preserve FilteredHashMap's leniency.
|
|
583
|
-
v.equal?(value_type) ? type : type.class.new(
|
|
612
|
+
k.equal?(key_type) && v.equal?(value_type) ? type : type.class.new(k, v)
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
# Keys rewrite like values, with two differences. A key type the codec
|
|
616
|
+
# can't rewrite stays as it is: maps were keyed by raw input before codecs
|
|
617
|
+
# rewrote keys at all. And decoding still accepts the decoded form, as
|
|
618
|
+
# an aliased literal key does (see #wire_key).
|
|
619
|
+
def visit_map_key(key_type, path)
|
|
620
|
+
k = visit(key_type, path + ['<key>'])
|
|
621
|
+
return k if k.equal?(key_type) || @direction == :encode
|
|
622
|
+
|
|
623
|
+
Disjunction.build(k, key_type)
|
|
624
|
+
rescue Plumb::TypeError
|
|
625
|
+
key_type
|
|
584
626
|
end
|
|
585
627
|
|
|
586
628
|
# A tagged union of Hash variants discriminated by a key: rewrite each
|
|
587
629
|
# variant schema (all HashClasses); the tag key's literal value is a
|
|
588
630
|
# native pass-through, so the discriminator survives.
|
|
589
631
|
def visit_tagged_hash(type, path)
|
|
590
|
-
|
|
632
|
+
base = type.hash_type
|
|
633
|
+
# The bare Hash base is a leaf this codec may not cover; it rewrites to itself anyway.
|
|
634
|
+
base = visit(base, path) unless base._schema.empty?
|
|
635
|
+
variants = type.children.map { |variant| visit(variant, path) }
|
|
636
|
+
key = wire_key(type.key)
|
|
637
|
+
unchanged = base.equal?(type.hash_type) && key.equal?(type.key) &&
|
|
638
|
+
variants.each_with_index.all? { |v, i| v.equal?(type.children[i]) }
|
|
639
|
+
unchanged ? type : type.class.new(base, key, variants)
|
|
591
640
|
end
|
|
592
641
|
|
|
593
642
|
# Rewriting can collapse two branches onto the SAME node — encoding a
|
data/lib/plumb/deferred.rb
CHANGED
|
@@ -10,6 +10,10 @@ module Plumb
|
|
|
10
10
|
@lock = Mutex.new
|
|
11
11
|
@definition = definition
|
|
12
12
|
@cached_type = nil
|
|
13
|
+
# Separate from @lock: materializing the accepted type reaches back here while
|
|
14
|
+
# its own #type holds @lock.
|
|
15
|
+
@accepted_lock = Mutex.new
|
|
16
|
+
@accepted_type = nil
|
|
13
17
|
# freeze
|
|
14
18
|
end
|
|
15
19
|
|
|
@@ -22,6 +26,16 @@ module Plumb
|
|
|
22
26
|
type.call(result)
|
|
23
27
|
end
|
|
24
28
|
|
|
29
|
+
# What the materialized type accepts, as another Deferred, so a forward reference
|
|
30
|
+
# isn't resolved early. Memoized, so a self-reference in the body maps back to it
|
|
31
|
+
# and the recursion closes. Without it a Deferred accepts ITSELF, so a codec's
|
|
32
|
+
# recursive encode rewrite is checked against its own encoded form, and rejected.
|
|
33
|
+
def accepted_type
|
|
34
|
+
@accepted_lock.synchronize do
|
|
35
|
+
@accepted_type ||= Deferred.new(-> { Plumb::Subtyping.accepted_type(type) }).tap { |d| d.accepts_itself! }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
25
39
|
def type
|
|
26
40
|
@lock.synchronize do
|
|
27
41
|
@cached_type ||= @definition.call
|
|
@@ -35,5 +49,12 @@ module Plumb
|
|
|
35
49
|
@cached_type
|
|
36
50
|
end
|
|
37
51
|
end
|
|
52
|
+
|
|
53
|
+
protected
|
|
54
|
+
|
|
55
|
+
# What an accepted type accepts is itself.
|
|
56
|
+
def accepts_itself!
|
|
57
|
+
@accepted_type = self
|
|
58
|
+
end
|
|
38
59
|
end
|
|
39
60
|
end
|
data/lib/plumb/disjunction.rb
CHANGED
|
@@ -23,14 +23,30 @@ module Plumb
|
|
|
23
23
|
|
|
24
24
|
attr_reader :children
|
|
25
25
|
|
|
26
|
+
# Leaf branches under this node, counting through nested disjunctions:
|
|
27
|
+
# `a | b | c` is `(a | b) | c`, with 3.
|
|
28
|
+
attr_reader :branch_count
|
|
29
|
+
|
|
26
30
|
# Identical for both nodes, which differ only in how types flow.
|
|
27
31
|
def initialize(left, right)
|
|
28
32
|
@left = Composable.wrap(left)
|
|
29
33
|
@right = Composable.wrap(right)
|
|
30
34
|
@children = [@left, @right].freeze
|
|
35
|
+
@branch_count = [@left, @right].sum { |c| c.is_a?(Disjunction) ? c.branch_count : 1 }
|
|
36
|
+
if @branch_count >= ClassDispatch::MIN_BRANCHES
|
|
37
|
+
@dispatch = ClassDispatch.new(self)
|
|
38
|
+
# Per instance, so a short union keeps the plain #call.
|
|
39
|
+
extend Dispatched
|
|
40
|
+
end
|
|
31
41
|
freeze
|
|
32
42
|
end
|
|
33
43
|
|
|
44
|
+
# #dup keeps ivars but not singleton modules (TypeRegistry dups types to rename them).
|
|
45
|
+
def initialize_copy(source)
|
|
46
|
+
super
|
|
47
|
+
extend Dispatched if @dispatch
|
|
48
|
+
end
|
|
49
|
+
|
|
34
50
|
# (A | B).input_type == A.input_type | B.input_type — shared by both nodes.
|
|
35
51
|
#
|
|
36
52
|
# A Union cannot shortcut this to `self` the way it can #output_type, because a
|
|
@@ -52,6 +68,23 @@ module Plumb
|
|
|
52
68
|
l.equal?(@left) && r.equal?(@right) ? self : Disjunction.build(l, r)
|
|
53
69
|
end
|
|
54
70
|
|
|
71
|
+
# What either branch accepts. Not #input_type: that leaves a container branch as
|
|
72
|
+
# is, so `Array[Date] | Nil` composed with its encode rewrite checks an
|
|
73
|
+
# `Array[Date]` against the rewrite's String output, and rejects.
|
|
74
|
+
def accepted_type
|
|
75
|
+
l = branch_accepted(@left)
|
|
76
|
+
r = branch_accepted(@right)
|
|
77
|
+
l.equal?(@left) && r.equal?(@right) ? self : Disjunction.build(l, r)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# A branch whose input is unknown (a bare pattern matcher) opts out of the
|
|
81
|
+
# composition check, as it does on its own (see Subtyping.check_composable!).
|
|
82
|
+
private def branch_accepted(branch)
|
|
83
|
+
return Types::Any if Plumb::Subtyping.resolved_input(branch).is_a?(AnyClass)
|
|
84
|
+
|
|
85
|
+
Plumb::Subtyping.accepted_type(branch)
|
|
86
|
+
end
|
|
87
|
+
|
|
55
88
|
# Rebuild around new branches, RECLASSIFYING by what they are.
|
|
56
89
|
# @see Conjunction#with_children for why this must not preserve the class.
|
|
57
90
|
def with_children(children) = Disjunction.build(children[0], children[1])
|
|
@@ -108,5 +141,88 @@ module Plumb
|
|
|
108
141
|
right.is_a?(::Array) ? merged.concat(right) : merged.push(right)
|
|
109
142
|
merged
|
|
110
143
|
end
|
|
144
|
+
|
|
145
|
+
# #call for a union of ClassDispatch::MIN_BRANCHES or more. It first tries only the
|
|
146
|
+
# branches that could accept the value's class: same result, as the skipped ones
|
|
147
|
+
# would have failed. If those fail too, the plain #call runs to collect every
|
|
148
|
+
# branch's errors, as without dispatch.
|
|
149
|
+
module Dispatched
|
|
150
|
+
def call(result)
|
|
151
|
+
candidates = @dispatch.candidates(result.value.class)
|
|
152
|
+
return super unless candidates
|
|
153
|
+
|
|
154
|
+
original = result.value
|
|
155
|
+
i = 0
|
|
156
|
+
while i < candidates.size
|
|
157
|
+
r = candidates[i].call(i.zero? ? result : result.reset(original))
|
|
158
|
+
return r if r.valid?
|
|
159
|
+
|
|
160
|
+
i += 1
|
|
161
|
+
end
|
|
162
|
+
super(result.reset(original))
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Per input class, the leaf branches that could accept a value of that class, in
|
|
167
|
+
# order. A branch's classes are its Subtyping.stable_domain: known only when what it
|
|
168
|
+
# accepts and what it produces share base types, so a converting branch (a
|
|
169
|
+
# Function, a struct, a codec's rewrite) is always a candidate.
|
|
170
|
+
#
|
|
171
|
+
# Only Class domains are used, not Modules: an object can gain a module through
|
|
172
|
+
# #extend, which its #class doesn't show.
|
|
173
|
+
#
|
|
174
|
+
# Built lazily, on first use: resolving branch domains when the union is built would
|
|
175
|
+
# materialize `defer`red forward references.
|
|
176
|
+
class ClassDispatch
|
|
177
|
+
MIN_BRANCHES = 3
|
|
178
|
+
# Beyond this many distinct classes, candidates are recomputed instead of cached.
|
|
179
|
+
MAX_CLASSES = 64
|
|
180
|
+
# Cached for a class whose candidates are all the branches: nothing to skip.
|
|
181
|
+
ALL = :all
|
|
182
|
+
|
|
183
|
+
def initialize(node)
|
|
184
|
+
@node = node
|
|
185
|
+
@lock = Mutex.new
|
|
186
|
+
# Replaced, never mutated, so reads need no lock.
|
|
187
|
+
@table = {}.freeze
|
|
188
|
+
@branches = nil
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# @param klass [Class] the input value's class
|
|
192
|
+
# @return [Array<Composable>, nil] candidate branches in order, or nil when none
|
|
193
|
+
# can be skipped
|
|
194
|
+
def candidates(klass)
|
|
195
|
+
found = @table[klass] || store(klass, compute(klass))
|
|
196
|
+
found.equal?(ALL) ? nil : found
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
private
|
|
200
|
+
|
|
201
|
+
def compute(klass)
|
|
202
|
+
list = branches.filter_map { |branch, domain| branch if domain.nil? || domain.any? { |d| klass <= d } }
|
|
203
|
+
list.size == branches.size ? ALL : list.freeze
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def store(klass, list)
|
|
207
|
+
@lock.synchronize { @table = @table.merge(klass => list).freeze if @table.size < MAX_CLASSES }
|
|
208
|
+
list
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# [[branch, classes or nil], ...]
|
|
212
|
+
def branches
|
|
213
|
+
@branches || @lock.synchronize do
|
|
214
|
+
@branches ||= leaves(@node).map { |branch| [branch, domain(branch)] }.freeze
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def leaves(node)
|
|
219
|
+
node.children.flat_map { |c| c.is_a?(Disjunction) ? leaves(c) : [c] }
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def domain(branch)
|
|
223
|
+
classes = Plumb::Subtyping.stable_domain(branch)
|
|
224
|
+
classes if classes&.all? { |c| c.is_a?(::Class) }
|
|
225
|
+
end
|
|
226
|
+
end
|
|
111
227
|
end
|
|
112
228
|
end
|
data/lib/plumb/hash_class.rb
CHANGED
|
@@ -25,6 +25,8 @@ module Plumb
|
|
|
25
25
|
# leftover input keys. `_schema` stays the source of truth for ==/subtyping.
|
|
26
26
|
@literal_fields = @_schema.select { |k, _| k.literal? }
|
|
27
27
|
@matcher_fields = @_schema.reject { |k, _| k.literal? }
|
|
28
|
+
# [from, to] names of aliased keys (see Key#aliased?), renamed by #unalias.
|
|
29
|
+
@aliases = @literal_fields.each_key.filter_map { |k| [k.from_key, k.to_key] if k.aliased? }.freeze
|
|
28
30
|
# The `_` catch-all's value type, if present (there is at most one).
|
|
29
31
|
@catch_all_type = @_schema.find { |k, _| k.catch_all? }&.last
|
|
30
32
|
freeze
|
|
@@ -161,7 +163,7 @@ module Plumb
|
|
|
161
163
|
return result.invalid!(errors: 'must be a Hash') unless result.value.is_a?(::Hash)
|
|
162
164
|
return result unless _schema.any?
|
|
163
165
|
|
|
164
|
-
input = result.value
|
|
166
|
+
input = @aliases.empty? ? result.value : unalias(result.value)
|
|
165
167
|
# Reuse the incoming cursor as the per-field scratch (see #call): `input`
|
|
166
168
|
# is captured above and `result` is only flipped at the end, so fields
|
|
167
169
|
# reset it in place with no scratch allocation.
|
|
@@ -208,7 +210,7 @@ module Plumb
|
|
|
208
210
|
return result.invalid!(errors: NOT_A_HASH) unless result.value.is_a?(::Hash)
|
|
209
211
|
return result unless _schema.any?
|
|
210
212
|
|
|
211
|
-
input = result.value
|
|
213
|
+
input = @aliases.empty? ? result.value : unalias(result.value)
|
|
212
214
|
errors = nil # Do not allocate errors unless needed
|
|
213
215
|
output = {}
|
|
214
216
|
|
|
@@ -270,7 +272,8 @@ module Plumb
|
|
|
270
272
|
# a key's optionality are different types, so compare that too.
|
|
271
273
|
_schema.all? do |key, value|
|
|
272
274
|
other_key, other_value = other._schema.find { |k, _| k.eql?(key) }
|
|
273
|
-
other_key && key.optional? == other_key.optional? &&
|
|
275
|
+
other_key && key.optional? == other_key.optional? && key.from_key == other_key.from_key &&
|
|
276
|
+
value == other_value
|
|
274
277
|
end
|
|
275
278
|
end
|
|
276
279
|
|
|
@@ -300,7 +303,7 @@ module Plumb
|
|
|
300
303
|
# type-check (the front-end/back-end coercion pattern).
|
|
301
304
|
def accepted_type
|
|
302
305
|
relaxed = _schema.each_with_object({}) do |(key, field), h|
|
|
303
|
-
h[key] = Plumb::Subtyping.accepted_type(field)
|
|
306
|
+
h[key.accepted] = Plumb::Subtyping.accepted_type(field)
|
|
304
307
|
end
|
|
305
308
|
self.class.new(schema: relaxed)
|
|
306
309
|
end
|
|
@@ -407,12 +410,20 @@ module Plumb
|
|
|
407
410
|
# catch-all) are already optional, so they pass through unchanged.
|
|
408
411
|
def relaxed_to_optional
|
|
409
412
|
relaxed = _schema.each_with_object({}) do |(key, type), h|
|
|
410
|
-
new_key = key.literal? ?
|
|
413
|
+
new_key = key.literal? ? key.with_optional(true) : key
|
|
411
414
|
h[new_key] = type
|
|
412
415
|
end
|
|
413
416
|
self.class.new(schema: relaxed)
|
|
414
417
|
end
|
|
415
418
|
|
|
419
|
+
# A copy of `input` with aliased keys under their own names. The alias wins
|
|
420
|
+
# when both are present; either way the key's own name is still accepted.
|
|
421
|
+
def unalias(input)
|
|
422
|
+
input = input.dup
|
|
423
|
+
@aliases.each { |from, to| input[to] = input.delete(from) if input.key?(from) }
|
|
424
|
+
input
|
|
425
|
+
end
|
|
426
|
+
|
|
416
427
|
def _inspect
|
|
417
428
|
%(Hash[#{_schema.map { |(k, v)| [k.inspect, v.inspect].join(': ') }.join(', ')}])
|
|
418
429
|
end
|
data/lib/plumb/key.rb
CHANGED
|
@@ -22,9 +22,11 @@ module Plumb
|
|
|
22
22
|
key.is_a?(Key) ? key : new(key, symbolize:)
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
attr_reader :to_key, :to_sym, :node_name, :matcher
|
|
25
|
+
attr_reader :to_key, :to_sym, :node_name, :matcher, :from_key
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
# @param from [Symbol, String, nil] a literal key's INPUT name, when it differs
|
|
28
|
+
# from the name it is emitted under (see #aliased?).
|
|
29
|
+
def initialize(key, optional: false, symbolize: false, from: nil)
|
|
28
30
|
@node_name = :key
|
|
29
31
|
if key.is_a?(::Symbol) || key.is_a?(::String)
|
|
30
32
|
key_type = symbolize ? Symbol : key.class
|
|
@@ -35,6 +37,8 @@ module Plumb
|
|
|
35
37
|
@optional = !match[:qmark].nil? ? true : optional
|
|
36
38
|
@matcher = @to_key
|
|
37
39
|
@literal = true
|
|
40
|
+
@from_key = from.nil? ? @to_key : from
|
|
41
|
+
@aliased = @from_key != @to_key
|
|
38
42
|
else
|
|
39
43
|
# A type/matcher key. It matches other keys structurally, so it has no
|
|
40
44
|
# concrete #to_key and never imposes a required key.
|
|
@@ -43,6 +47,8 @@ module Plumb
|
|
|
43
47
|
@to_sym = nil
|
|
44
48
|
@optional = true
|
|
45
49
|
@literal = false
|
|
50
|
+
@from_key = nil
|
|
51
|
+
@aliased = false
|
|
46
52
|
end
|
|
47
53
|
freeze
|
|
48
54
|
end
|
|
@@ -50,6 +56,19 @@ module Plumb
|
|
|
50
56
|
# A concrete Symbol/String key (exact lookup) vs a type/matcher key.
|
|
51
57
|
def literal? = @literal
|
|
52
58
|
|
|
59
|
+
# Read from the input under a different name than it is emitted under — how a
|
|
60
|
+
# Codec maps a wire key (`'name'`) to a schema key (`:name`) and back. An aliased
|
|
61
|
+
# key still accepts its own name as a fallback.
|
|
62
|
+
def aliased? = @aliased
|
|
63
|
+
|
|
64
|
+
# This key as a consumer sees it: named by what it reads.
|
|
65
|
+
def accepted = aliased? ? Key.new(@from_key, optional: @optional) : self
|
|
66
|
+
|
|
67
|
+
# This key with a different optionality, keeping its alias.
|
|
68
|
+
def with_optional(optional)
|
|
69
|
+
optional == @optional ? self : Key.new(@to_key, optional:, from: @from_key)
|
|
70
|
+
end
|
|
71
|
+
|
|
53
72
|
# The `_` catch-all: a matcher key over the Any top, so it matches every key.
|
|
54
73
|
def catch_all? = !@literal && @matcher.is_a?(AnyClass)
|
|
55
74
|
|
|
@@ -82,7 +101,7 @@ module Plumb
|
|
|
82
101
|
|
|
83
102
|
def inspect
|
|
84
103
|
if @literal
|
|
85
|
-
"#{@to_key}#{'?' if @optional}"
|
|
104
|
+
"#{"#{@from_key.inspect}->" if aliased?}#{@to_key}#{'?' if @optional}"
|
|
86
105
|
elsif catch_all?
|
|
87
106
|
'_'
|
|
88
107
|
else
|
data/lib/plumb/tagged_hash.rb
CHANGED
|
@@ -63,18 +63,21 @@ module Plumb
|
|
|
63
63
|
def accepted_type
|
|
64
64
|
relaxed = @children.map do |child|
|
|
65
65
|
schema = child._schema.each_with_object({}) do |(k, field), h|
|
|
66
|
-
h[k] = k.eql?(@key) ? field : Plumb::Subtyping.accepted_type(field)
|
|
66
|
+
h[k.accepted] = k.eql?(@key) ? field : Plumb::Subtyping.accepted_type(field)
|
|
67
67
|
end
|
|
68
68
|
child.class.new(schema:)
|
|
69
69
|
end
|
|
70
|
-
self.class.new(@hash_type, @key, relaxed)
|
|
70
|
+
self.class.new(Plumb::Subtyping.accepted_type(@hash_type), @key.accepted, relaxed)
|
|
71
71
|
end
|
|
72
72
|
|
|
73
73
|
def call(result)
|
|
74
74
|
result = @hash_type.call(result)
|
|
75
75
|
return result unless result.valid?
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
input = result.value
|
|
78
|
+
# An aliased key (a codec's wire name) falls back to its own name.
|
|
79
|
+
tag_key = @key.aliased? && input.key?(@key.from_key) ? @key.from_key : @key.to_sym
|
|
80
|
+
child = @index[input[tag_key]]
|
|
78
81
|
return result.invalid!(errors: "expected :#{@key.to_sym} to be one of #{@index.keys.join(', ')}") unless child
|
|
79
82
|
|
|
80
83
|
child.call(result)
|
data/lib/plumb/version.rb
CHANGED