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 +4 -4
- data/README.md +29 -18
- data/lib/plumb/and.rb +12 -3
- 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/hash_map.rb +22 -0
- data/lib/plumb/key.rb +22 -3
- data/lib/plumb/subtyping.rb +5 -2
- data/lib/plumb/tagged_hash.rb +6 -3
- data/lib/plumb/types.rb +2 -2
- 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
|
@@ -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
|
|
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
|
|
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
|
|
856
|
-
type.resolve('2024-') # => Result
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
2256
|
-
# @return [Plumb::Result
|
|
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.
|
|
33
|
-
#
|
|
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)
|
|
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
|
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/hash_map.rb
CHANGED
|
@@ -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
|
-
|
|
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/subtyping.rb
CHANGED
|
@@ -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)
|
|
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.
|
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/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
|
|
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