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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3c32b91fda7581018e0febe58953ab0256b9850d9848cfe9ba468ef81d6003e3
4
- data.tar.gz: 340f1681daffc709e724254a9ee031f1fd0bc12c2cdd80e53f47ce1384e5827c
3
+ metadata.gz: 6a6433ce0705844548d8c5488e0a454335eecb2dc385ded5e398dabbce66f8f6
4
+ data.tar.gz: aed47d0aef0f1a1a685ae9e4b164451484b9cd2aa4763f0cfd0293c5bb90ed99
5
5
  SHA512:
6
- metadata.gz: e6e3c5d2bbaeeae28946cbaef50bf65edc9d98815d0546e8d62a69a0097dc889448e8edc68863f44ff922f5a808fbdb1266cc8fde6569bcde52c713c80856df7
7
- data.tar.gz: ea269dc275559ccb3fb61c94311707881cc11151cbbb433daafb4040218748d33e5461b84be951d73bdc4b5c02ec5ef5622498ea0ac23efa77efcc110411670f
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: 'Joe', dates: { from: '2024-01-01', to: '2024-02-01' } })
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: 'Joe', dates: { from: '2024-01-01', to: '2024-02-01' } }
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: 'http://example.com', port: '80', active: '1', starts_on: '' })
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: 'http://example.com', port: '80', active: 'true', starts_on: '' }
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.
@@ -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 class itself builds the instance (Function's
432
- # output stage CALLS it). Encoding, the class validates/constructs the
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
- Function.new(schema, struct, Plumb::NOOP)
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
- NodeMapper.map_record(type) do |field, key|
547
- visit(field, path + [key.literal? ? key.to_s : key.inspect])
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(key_type, v)
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
- NodeMapper.map_children(type) { |variant| visit(variant, path) }
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
@@ -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
@@ -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
@@ -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? && value == other_value
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? ? Key.new(key.to_key, optional: true) : key
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
- def initialize(key, optional: false, symbolize: false)
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
@@ -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
- child = @index[result.value[@key.to_sym]]
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Plumb
4
- VERSION = '0.3.1'
4
+ VERSION = '0.4.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: plumb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ismael Celis