plumb 0.3.0 → 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: 26a0c552d1280339792c3acf480302056f203a6ac3a1a19a548d977b72bb581f
4
- data.tar.gz: 325d6d8e267e107c6a2a7c28dddf696ed8198595fe0bc13af76b53648531dd1a
3
+ metadata.gz: 6a6433ce0705844548d8c5488e0a454335eecb2dc385ded5e398dabbce66f8f6
4
+ data.tar.gz: aed47d0aef0f1a1a685ae9e4b164451484b9cd2aa4763f0cfd0293c5bb90ed99
5
5
  SHA512:
6
- metadata.gz: aff0c3be749f252774c753074d87fe1b6f169a0ef3f118013b54100a3d0482283745b664f90a39c44b9af664dbd5d0d9cda9f63b86867236e178642bb026933d
7
- data.tar.gz: 6e8383986cf0bd2ee8e429adb13dd0440bf634536c3db745910fab84a6aac67b7130a6e90e59edca539cc9f22b661937c2c9feb9fa9f854467c086731189b37b
6
+ metadata.gz: b9a3ef7bb37d3239cca40a7a925bfad585b4490f02e6292310072a7d7d87361f0a39450cec257dbe7e1567234dd4cbc2c05610efbe245a219a2835cc8ecc1830
7
+ data.tar.gz: f3ea81577b79c024044615d7079acec13d40fc7db7435163301632d70d37dc24bedd23d6720a2d5781476b6b5c8ef308b1e7ee0b0739ded2b1e88953e5b68fb2
data/README.md CHANGED
@@ -96,7 +96,7 @@ StringToInt.parse('100lol') # fails
96
96
 
97
97
  ### `#resolve(value) => Result`
98
98
 
99
- `#resolve` takes an input value and returns a `Result::Valid` or `Result::Invalid`
99
+ `#resolve` takes an input value and returns a `Plumb::Result`, which is either valid or invalid (check with `#valid?` / `#invalid?`).
100
100
 
101
101
  ```ruby
102
102
  result = Types::Integer.resolve(10)
@@ -846,14 +846,14 @@ Example: parsing strings with `Date.parse` and turning `Date::Error` exceptions
846
846
  ```ruby
847
847
  # Accept a string that can be parsed into a Date
848
848
  # via Date.parse
849
- # If Date.parse raises a Date::Error, return a Result::Invalid with
849
+ # If Date.parse raises a Date::Error, return an invalid Result with
850
850
  # the exception's message as error message.
851
851
  type = Types::String
852
852
  .build(::Date, :parse)
853
853
  .policy(:rescue, ::Date::Error)
854
854
 
855
- type.resolve('2024-02-02') # => Result::Valid with Date object
856
- type.resolve('2024-') # => Result::Invalid with error message
855
+ type.resolve('2024-02-02') # => valid Result with Date object
856
+ type.resolve('2024-') # => invalid Result with error message
857
857
  ```
858
858
 
859
859
  The guard keeps the type it wraps: the example above is still a `Date` for subtyping,
@@ -1216,9 +1216,9 @@ Plumb::JSONSchemaVisitor.call(Types::Range[0...100], root: false)
1216
1216
 
1217
1217
  ## `Types::SymbolizedHash`
1218
1218
 
1219
- This type turns a hash's keys into symbols by calling `#to_sym` on them, and returning a new Hash.
1219
+ This type recursively turns a hash's String keys into symbols, returning a new Hash. Symbol keys and any other keys pass through unchanged, so it accepts any Hash.
1220
1220
 
1221
- `SymbolizedHash` is a `Symbol => Any` map. You can use it as a _transform_.
1221
+ You can use it as a _transform_.
1222
1222
 
1223
1223
  ```ruby
1224
1224
  UserHash = Types::Hash[name: String]
@@ -1233,6 +1233,13 @@ type = Types::Hash[name: String, age: Integer].symbolized
1233
1233
  type.parse('name' => 'Joe', 'age' => 20) # {name: 'Joe', age: 20}
1234
1234
  ```
1235
1235
 
1236
+ Hash maps support `#symbolized` too. It raises `Plumb::TypeError` if the map's key type can't accept Symbols, since every symbolized key would be rejected.
1237
+
1238
+ ```ruby
1239
+ Types::Hash[Symbol, Integer].symbolized.parse('a' => 1) # {a: 1}
1240
+ Types::Hash[String, Integer].symbolized # raises Plumb::TypeError
1241
+ ```
1242
+
1236
1243
  ## maps
1237
1244
 
1238
1245
  You can also use Hash syntax to define a hash map with specific types for all keys and values:
@@ -1361,7 +1368,7 @@ TODO: pluggable concurrency engines (Async?)
1361
1368
 
1362
1369
  ### `#stream`
1363
1370
 
1364
- Turn an Array definition into an enumerator that yields each element wrapped in `Result::Valid` or `Result::Invalid`.
1371
+ Turn an Array definition into an enumerator that yields each element wrapped in a valid or invalid `Result`.
1365
1372
 
1366
1373
  See [`Types::Stream`](#typesstream) below for more.
1367
1374
 
@@ -1421,7 +1428,7 @@ Row = Types::Tuple[Types::String.present, Types:Lax::Integer]
1421
1428
  Stream = Types::Stream[Row]
1422
1429
 
1423
1430
  data = CSV.new(File.new('./big-file.csv')).each # An Enumerator
1424
- # stream is an Enumerator that yields rows wrapped in[Result::Valid] or [Result::Invalid]
1431
+ # stream is an Enumerator that yields rows wrapped in a valid or invalid Result
1425
1432
  stream = Stream.parse(data)
1426
1433
  stream.each.with_index(1) do |result, line|
1427
1434
  if result.valid?
@@ -1816,7 +1823,7 @@ CreateIfJoe = IsJoe >> CreateUser
1816
1823
  Use `#around` in a pipeline definition to add a middleware step that wraps all other steps registered.
1817
1824
 
1818
1825
  ```ruby
1819
- # The #around interface is #call(Step, Result::Valid) => Result::Valid | Result::Invalid
1826
+ # The #around interface is #call(Step, Result) => Result
1820
1827
  StepLogger = proc do |step, result|
1821
1828
  Logger.info "Processing step #{step}"
1822
1829
  step.call(result)
@@ -2024,14 +2031,16 @@ Composing a codec with a type rewrites the type deeply, in either direction:
2024
2031
  Person = Types::Hash[name: Types::String, dates: DateRange]
2025
2032
 
2026
2033
  JSONPerson = JSONCodec >> Person # decode: JSON structures -> Person
2027
- 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' } })
2028
2035
  # => { name: 'Joe', dates: Date(2024-01-01)..Date(2024-02-01) }
2029
2036
 
2030
2037
  EncodedPerson = Person >> JSONCodec # encode: Person -> JSON structures
2031
2038
  EncodedPerson.parse({ name: 'Joe', dates: Date.new(2024, 1, 1)..Date.new(2024, 2, 1) })
2032
- # => { name: 'Joe', dates: { from: '2024-01-01', to: '2024-02-01' } }
2039
+ # => { 'name' => 'Joe', 'dates' => { 'from' => '2024-01-01', 'to' => '2024-02-01' } }
2033
2040
  ```
2034
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
+
2035
2044
  `Codec.for(type)` returns both directions as a `[decoding, encoding]` pair:
2036
2045
 
2037
2046
  ```ruby
@@ -2147,10 +2156,10 @@ Config = Types::Hash[
2147
2156
  ]
2148
2157
 
2149
2158
  decoder, encoder = Plumb::Codec::Forms.for(Config)
2150
- 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' => '' })
2151
2160
  # => { host: URI(...), port: 80, active: true, starts_on: nil }
2152
2161
  encoder.parse({ host: URI.parse('http://example.com'), port: 80, active: true, starts_on: nil })
2153
- # => { host: 'http://example.com', port: '80', active: 'true', starts_on: '' }
2162
+ # => { 'host' => 'http://example.com', 'port' => '80', 'active' => 'true', 'starts_on' => '' }
2154
2163
  ```
2155
2164
 
2156
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.
@@ -2170,12 +2179,14 @@ Things to know:
2170
2179
  Every Plumb type exposes the following one-method interface:
2171
2180
 
2172
2181
  ```
2173
- #call(Result::Valid) => Result::Valid | Result::Invalid
2182
+ #call(Result) => Result
2174
2183
  ```
2175
2184
 
2185
+ The incoming result is always valid (invalid results halt a pipeline before reaching later steps). Return a valid result to pass a value on, or an invalid one to halt.
2186
+
2176
2187
  As long as an object implements this interface, it can be composed into Plumb workflows.
2177
2188
 
2178
- The `Result::Valid` class has helper methods `#valid(value) => Result::Valid` and `#invalid(errors:) => Result::Invalid` to facilitate returning valid or invalid values from your own steps.
2189
+ `Plumb::Result` has helper methods `#valid(value) => Result` and `#invalid(value, errors:) => Result` to facilitate returning valid or invalid results from your own steps. Both return a new `Result` and leave the receiver untouched.
2179
2190
 
2180
2191
  ### Compose procs or lambdas directly
2181
2192
 
@@ -2243,7 +2254,7 @@ LoudGreeting = Greeting.default('no greeting').invoke(:upcase)
2243
2254
 
2244
2255
  ### A custom `#call` class
2245
2256
 
2246
- Or write a custom class that responds to `#call(Result::Valid) => Result::Valid | Result::Invalid`
2257
+ Or write a custom class that responds to `#call(Result) => Result`
2247
2258
 
2248
2259
  ```ruby
2249
2260
  class Greeting
@@ -2252,8 +2263,8 @@ class Greeting
2252
2263
  end
2253
2264
 
2254
2265
  # The Plumb step interface
2255
- # @param result [Plumb::Result::Valid]
2256
- # @return [Plumb::Result::Valid, Plumb::Result::Invalid]
2266
+ # @param result [Plumb::Result] a valid result
2267
+ # @return [Plumb::Result]
2257
2268
  def call(result)
2258
2269
  result.valid("#{@gr} #{result.value}")
2259
2270
  end
data/lib/plumb/and.rb CHANGED
@@ -29,11 +29,20 @@ module Plumb
29
29
  #
30
30
  # Conjunction.build, not Intersection.new — `left.output_type` need not preserve
31
31
  # values (a record drops undeclared keys, a Static replaces it), and only the
32
- # classifier may decide. The `lo.equal?(left)` guard is the fixpoint: a left that
33
- # IS its own output type would otherwise recurse forever.
32
+ # classifier may decide.
33
+ #
34
+ # A left that IS its own output type (eg. a recursive type) can't be met without
35
+ # recursing forever, so fall back to the right alone: every output passed it
36
+ # unchanged, so it's a sound over-approximation. Unless the right is the Any top,
37
+ # which would opt the chain out of #>> checks; then stay opaque (self).
34
38
  @output_type = if Plumb::Subtyping.value_preserving?(right)
35
39
  lo = left.output_type
36
- lo.equal?(left) ? self : Conjunction.build(lo, right)
40
+ if lo.equal?(left)
41
+ ro = right.output_type
42
+ ro.is_a?(AnyClass) ? self : ro
43
+ else
44
+ Conjunction.build(lo, right)
45
+ end
37
46
  else
38
47
  right.output_type
39
48
  end
@@ -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
@@ -35,6 +35,15 @@ module Plumb
35
35
  super
36
36
  end
37
37
 
38
+ # A map whose key and value types both admit Any is the "any Hash" type, so it
39
+ # claims anything whose base types are all Hashes (eg. `transform(::Hash)`).
40
+ def supertype_of?(other)
41
+ return false unless children.all? { |c| Plumb::Subtyping.subtype?(Types::Any, c) }
42
+
43
+ bases = Plumb.resolve_base_types(other)
44
+ bases.any? && bases.all? { |k| k.is_a?(::Class) && k <= ::Hash }
45
+ end
46
+
38
47
  # A HashMap re-maps each key and value through its key/value types, so it
39
48
  # preserves the value only when both do (a coercing key or value would change
40
49
  # the hash).
@@ -74,6 +83,19 @@ module Plumb
74
83
  FilteredHashMap.new(@key_type, @value_type)
75
84
  end
76
85
 
86
+ # Symbolize keys (via Types::SymbolizedHash), then validate against this map.
87
+ # Only the key type is checked up front: a map whose keys can't be Symbols
88
+ # (eg. `Hash[String, X]`) would reject every symbolized key.
89
+ # @see HashClass#symbolized
90
+ # @raise [Plumb::TypeError] when the key type doesn't accept Symbols.
91
+ def symbolized
92
+ unless Plumb::Subtyping.subtype?(Types::Symbol, Plumb::Subtyping.accepted_type(@key_type))
93
+ raise Plumb::TypeError, "cannot symbolize #{inspect}: its key type doesn't accept Symbols"
94
+ end
95
+
96
+ Types::SymbolizedHash / self
97
+ end
98
+
77
99
  private def _inspect = "HashMap[#{@key_type.inspect}, #{@value_type.inspect}]"
78
100
 
79
101
  # Same key/value types as a HashMap (so it inherits #initialize, #children
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
@@ -72,8 +72,7 @@ module Plumb
72
72
  ub = unwrap_transparent(b)
73
73
  return subtype?(ua, ub) unless ua.equal?(a) && ub.equal?(b)
74
74
 
75
- return true if b.is_a?(AnyClass) # X <= Top
76
- return false if a.is_a?(AnyClass) # Top <= X only when X is Top (handled above)
75
+ return true if b.is_a?(AnyClass) # X <= Top
77
76
 
78
77
  # A value-converting type (Function, or any custom type that opts in) is
79
78
  # identified for subtyping by what it *produces*, not what it consumes, so we
@@ -108,6 +107,10 @@ module Plumb
108
107
  return b.children.all? { |bb| subtype?(a, bb) } if b.is_a?(Intersection) # a <= (b1 ∧ b2)
109
108
  return a.children.any? { |aa| subtype?(aa, b) } if a.is_a?(Intersection) # (a1 ∧ a2) <= b
110
109
 
110
+ # Top <= X only when X is Top-equivalent. Checked after the join/meet rules
111
+ # so a union with a Top branch (`X | Any`) still admits Top.
112
+ return false if a.is_a?(AnyClass)
113
+
111
114
  # `a` decides via its #subtype_of? leaf; if it can't (it doesn't know about
112
115
  # `b`), `b` may claim `a` via #supertype_of? — the mirror hook for
113
116
  # supertype-driven relations like Interface duck-typing.
@@ -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/types.rb CHANGED
@@ -196,8 +196,8 @@ module Plumb
196
196
  # @example Mixed types preserved
197
197
  # SymbolizedHash.parse({ 'count' => 1, 'active' => true }) # => { count: 1, active: true }
198
198
  SymbolizedHash = Hash[
199
- # String keys are converted to symbols, existing symbols are preserved
200
- (Symbol | String.transform(::Symbol, :to_sym)),
199
+ # String keys are converted to symbols; any other key passes through unchanged
200
+ (Symbol | String.transform(::Symbol, :to_sym) | Any),
201
201
  # Hash values are recursively symbolized, other types pass through unchanged
202
202
  Any.defer { SymbolizedHash } | Any
203
203
  ]
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.0'
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.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ismael Celis