plumb 0.2.0.beta.1 → 0.2.0.beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +53 -1
- data/lib/plumb/array_class.rb +72 -9
- data/lib/plumb/codec.rb +231 -140
- data/lib/plumb/composable.rb +2 -2
- data/lib/plumb/hash_class.rb +5 -4
- data/lib/plumb/hash_map.rb +1 -5
- data/lib/plumb/stream_class.rb +1 -5
- data/lib/plumb/types.rb +21 -4
- 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: 119f529616c7dfe5fe87e12e26e9cf91f3cb4c28fd19cacf89dc15c271e534de
|
|
4
|
+
data.tar.gz: 514598daebbf8a7ee477b68c8dd05cb74e9c5102da4f9f7f8a682f2de5939a66
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8997e52706716448c59bff278df20b9768aa0cc1e7d870c3e6dce8d01d15bf248c1ceefd7cff3b91a78462d0a56b8645f1a7085ca6a809aa9701cb4b26bdf862
|
|
7
|
+
data.tar.gz: e4dcaa6edaf31597fe1985efea84656a4efb1e9ba75a1f72ad9316a36257af79b4f36bafdd3d051f632ee0a7b005cbac3e07d9e051d12d5b7ff18aae8842e077
|
data/README.md
CHANGED
|
@@ -519,6 +519,27 @@ str.parse() # 'nope'
|
|
|
519
519
|
str.parse('yup') # 'yup'
|
|
520
520
|
```
|
|
521
521
|
|
|
522
|
+
A block generates the value on every invocation, instead of returning a fixed one:
|
|
523
|
+
|
|
524
|
+
```ruby
|
|
525
|
+
id = Types::UUID::V4.default { SecureRandom.uuid }
|
|
526
|
+
id.parse() # a fresh UUID each time
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
The step _declares_ the type it defaults — so a `Types::Date.default { Date.today }`
|
|
530
|
+
is still a `Date` for subtyping, JSON Schema and [Codecs](#encoders-and-codecs) — and
|
|
531
|
+
what the block returns is checked against it, failing where it is defaulted rather
|
|
532
|
+
than somewhere downstream.
|
|
533
|
+
|
|
534
|
+
What is checked is the type's **output**, and the type itself is not re-run on the
|
|
535
|
+
generated value: a converting type expects the block to produce the converted value.
|
|
536
|
+
|
|
537
|
+
```ruby
|
|
538
|
+
int = Types::String.transform(::Integer, &:to_i)
|
|
539
|
+
int.default { 10 }.parse() # 10
|
|
540
|
+
int.default { '10' }.parse() # raises — a String is not what this type produces
|
|
541
|
+
```
|
|
542
|
+
|
|
522
543
|
Note that this is syntax sugar for:
|
|
523
544
|
|
|
524
545
|
```ruby
|
|
@@ -835,6 +856,9 @@ type.resolve('2024-02-02') # => Result::Valid with Date object
|
|
|
835
856
|
type.resolve('2024-') # => Result::Invalid with error message
|
|
836
857
|
```
|
|
837
858
|
|
|
859
|
+
The guard keeps the type it wraps: the example above is still a `Date` for subtyping,
|
|
860
|
+
JSON Schema and [Codecs](#encoders-and-codecs).
|
|
861
|
+
|
|
838
862
|
### `Types::Interface`
|
|
839
863
|
|
|
840
864
|
Use this for objects that must respond to one or more methods.
|
|
@@ -2067,6 +2091,34 @@ JSONPerson.to_json_schema
|
|
|
2067
2091
|
# "dates" is described as { "type" => "object", "properties" => { "from" => { "type" => "string" }, ... } }
|
|
2068
2092
|
```
|
|
2069
2093
|
|
|
2094
|
+
#### Codec instances: a registry of pre-built pairs
|
|
2095
|
+
|
|
2096
|
+
Composing a codec rewrites the whole type tree, so it belongs at boot — not on the path of every message. A codec _instance_ is a registry of `[decoder, encoder]` pairs, each built once by `register` and then looked up by key:
|
|
2097
|
+
|
|
2098
|
+
```ruby
|
|
2099
|
+
CODECS = JSONCodec.new do |c|
|
|
2100
|
+
c.register('person.created', Person)
|
|
2101
|
+
c.register('company.created', Company)
|
|
2102
|
+
c.register(Types::Date) # the key defaults to the type itself
|
|
2103
|
+
end
|
|
2104
|
+
|
|
2105
|
+
CODECS.decode('person.created', payload) # => a Person hash
|
|
2106
|
+
CODECS.encode('person.created', person) # => JSON structures
|
|
2107
|
+
CODECS.decode(Types::Date, '2024-01-01') # => Date
|
|
2108
|
+
```
|
|
2109
|
+
|
|
2110
|
+
Keys are yours to choose — a message name, a content type, the type itself. `decode` and `encode` only `#parse`, so the rewrite is paid for once.
|
|
2111
|
+
|
|
2112
|
+
An instance built with a block is frozen when the block returns. Without one it stays open, and `register` chains:
|
|
2113
|
+
|
|
2114
|
+
```ruby
|
|
2115
|
+
registry = JSONCodec.new
|
|
2116
|
+
registry.register('day', Types::Date).register('person', Person)
|
|
2117
|
+
registry.freeze
|
|
2118
|
+
```
|
|
2119
|
+
|
|
2120
|
+
`key?` asks what is registered; an unknown key raises `Plumb::Codec::NoEntryError` (a `KeyError`). Payloads are still validated by their type — a bad one raises `Plumb::ParseError` as usual.
|
|
2121
|
+
|
|
2070
2122
|
#### `Codec::Forms`: string-based formats
|
|
2071
2123
|
|
|
2072
2124
|
The second built-in codec targets HTML forms, query strings and other formats where **every value arrives as a string**. Unlike `Codec::JSON` there are almost no native scalars: strings pass through, untyped containers recurse (Rack-style nested params), and everything else maps through an encoder with a strictly-patterned string input type — integers (`/\A-?\d+\z/`), floats, decimals, booleans (`"true"/"1"`, `"false"/"0"`, case-insensitive), ISO 8601 dates and times, scheme-prefixed URIs, and the empty string for `nil` (so `Types::Date | Types::Nil` decodes `''` to `nil`).
|
|
@@ -2616,7 +2668,7 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
|
|
|
2616
2668
|
|
|
2617
2669
|
## Contributing
|
|
2618
2670
|
|
|
2619
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/ismasan/plumb.
|
|
2671
|
+
Bug reports and pull requests are welcome on GitHub at [github.com/ismasan/plumb](https://github.com/ismasan/plumb).
|
|
2620
2672
|
|
|
2621
2673
|
## License
|
|
2622
2674
|
|
data/lib/plumb/array_class.rb
CHANGED
|
@@ -45,22 +45,17 @@ module Plumb
|
|
|
45
45
|
def output_type = Plumb::Subtyping.map_children(self) { |c| Plumb::Subtyping.resolved_output(c) }
|
|
46
46
|
|
|
47
47
|
def concurrent
|
|
48
|
-
|
|
48
|
+
concurrent_class.new(element_type:)
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def stream
|
|
52
52
|
StreamClass.new(element_type:)
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
+
# A lenient version of this Array: it accepts any Array and emits one with only
|
|
56
|
+
# the valid elements, dropping the rest. @see FilteredArray
|
|
55
57
|
def filtered
|
|
56
|
-
|
|
57
|
-
identity: [:filtered_array, element_type]) do |result|
|
|
58
|
-
arr = result.value.each.with_object([]) do |e, memo|
|
|
59
|
-
r = element_type.resolve(e)
|
|
60
|
-
memo << r.value if r.valid?
|
|
61
|
-
end
|
|
62
|
-
result.valid!(arr)
|
|
63
|
-
end
|
|
58
|
+
filtered_class.new(element_type:)
|
|
64
59
|
end
|
|
65
60
|
|
|
66
61
|
def call(result)
|
|
@@ -78,6 +73,12 @@ module Plumb
|
|
|
78
73
|
|
|
79
74
|
attr_reader :element_type
|
|
80
75
|
|
|
76
|
+
# Named, not hardcoded, so the two combine in EITHER order: each subclass points
|
|
77
|
+
# at the variant that keeps what it already is, instead of the second call
|
|
78
|
+
# dropping the first.
|
|
79
|
+
def concurrent_class = ConcurrentArrayClass
|
|
80
|
+
def filtered_class = FilteredArray
|
|
81
|
+
|
|
81
82
|
def _inspect
|
|
82
83
|
%(Array[#{element_type}])
|
|
83
84
|
end
|
|
@@ -136,6 +137,68 @@ module Plumb
|
|
|
136
137
|
|
|
137
138
|
[values, errors]
|
|
138
139
|
end
|
|
140
|
+
|
|
141
|
+
def filtered_class = ConcurrentFilteredArray
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Same element type as an ArrayClass, but drops invalid elements instead of
|
|
145
|
+
# rejecting the whole Array. Being an ArrayClass is what keeps it rewritable: a
|
|
146
|
+
# visitor — or a Codec — that maps the element type rebuilds a FilteredArray, not
|
|
147
|
+
# a plain one. @see HashMap::FilteredHashMap, the same arrangement for maps.
|
|
148
|
+
class FilteredArray < self
|
|
149
|
+
# It drops elements, so it changes the value whatever its element type does.
|
|
150
|
+
def value_preserving? = false
|
|
151
|
+
|
|
152
|
+
# Lenient: it never rejects an Array, so as a #>> consumer it accepts any
|
|
153
|
+
# enumerable — without this `Types::Array >> Array[String].filtered` is an
|
|
154
|
+
# illegal narrowing.
|
|
155
|
+
def accepted_type = Types::Each
|
|
156
|
+
|
|
157
|
+
# `Array[T].filtered.stream` IS `Array[T].stream.filtered` — a filtered Stream
|
|
158
|
+
# drops the same elements, lazily.
|
|
159
|
+
def stream = super.filtered
|
|
160
|
+
|
|
161
|
+
def call(result)
|
|
162
|
+
return result.invalid!(errors: 'is not an Array') unless ::Array === result.value
|
|
163
|
+
|
|
164
|
+
result.valid!(valid_elements(result.value))
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
def concurrent_class = ConcurrentFilteredArray
|
|
170
|
+
|
|
171
|
+
# The only thing the concurrent variant replaces. No errors to collect, unlike
|
|
172
|
+
# #map_array_elements — an invalid element is simply not there.
|
|
173
|
+
def valid_elements(array)
|
|
174
|
+
array.each.with_object([]) do |e, memo|
|
|
175
|
+
r = element_type.resolve(e)
|
|
176
|
+
memo << r.value if r.valid?
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def _inspect = "Array[#{element_type}].filtered"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Both at once, reached from either side (`.filtered.concurrent`,
|
|
184
|
+
# `.concurrent.filtered`), and itself under both.
|
|
185
|
+
class ConcurrentFilteredArray < FilteredArray
|
|
186
|
+
private
|
|
187
|
+
|
|
188
|
+
def filtered_class = ConcurrentFilteredArray
|
|
189
|
+
|
|
190
|
+
# A rejected future re-raises, as ConcurrentArrayClass does: an element step
|
|
191
|
+
# that RAISED is a bug to surface, not an element to drop.
|
|
192
|
+
def valid_elements(array)
|
|
193
|
+
futures = array.map { |e| Concurrent::Future.execute { element_type.resolve(e) } }
|
|
194
|
+
|
|
195
|
+
futures.each_with_object([]) do |future, memo|
|
|
196
|
+
r = future.value # blocks until settled; nil when rejected
|
|
197
|
+
raise future.reason if future.rejected?
|
|
198
|
+
|
|
199
|
+
memo << r.value if r.valid?
|
|
200
|
+
end
|
|
201
|
+
end
|
|
139
202
|
end
|
|
140
203
|
end
|
|
141
204
|
end
|
data/lib/plumb/codec.rb
CHANGED
|
@@ -32,9 +32,8 @@ module Plumb
|
|
|
32
32
|
# algebra, so parsing, subtyping and visitors work on it unchanged. It works
|
|
33
33
|
# on any type, not just Hash schemas: `JSONCodec >> Types::Date` returns the
|
|
34
34
|
# matched encoder's decode step.
|
|
35
|
+
#
|
|
35
36
|
class Codec
|
|
36
|
-
include Composable
|
|
37
|
-
|
|
38
37
|
class << self
|
|
39
38
|
# Register one or more Encoder subclasses, in matching-priority order.
|
|
40
39
|
# The registry is inheritable: subclassing a codec extends it, and a
|
|
@@ -46,7 +45,7 @@ module Plumb
|
|
|
46
45
|
raise ArgumentError, "expected an Encoder subclass, got #{enc.inspect}"
|
|
47
46
|
end
|
|
48
47
|
|
|
49
|
-
|
|
48
|
+
encoders << enc
|
|
50
49
|
end
|
|
51
50
|
self
|
|
52
51
|
end
|
|
@@ -56,154 +55,179 @@ module Plumb
|
|
|
56
55
|
# nothing. Real encoders always match first, so a noop never shadows a
|
|
57
56
|
# registered encoder for the same type.
|
|
58
57
|
def noop(*types)
|
|
59
|
-
types.each { |t|
|
|
58
|
+
types.each { |t| noop_types << Composable.wrap(t) }
|
|
60
59
|
self
|
|
61
60
|
end
|
|
62
61
|
|
|
63
|
-
#
|
|
64
|
-
# win ties in
|
|
65
|
-
|
|
62
|
+
# A subclass starts from a COPY of its parent's registries and appends its own,
|
|
63
|
+
# so `encoders` reads inherited-first, own-last (later registrations win ties in
|
|
64
|
+
# matching). Copied at subclass time rather than resolved at read time because
|
|
65
|
+
# registration happens in a class body: a parent is complete before a subclass
|
|
66
|
+
# of it exists.
|
|
67
|
+
def inherited(subclass)
|
|
68
|
+
super
|
|
69
|
+
subclass.encoders = encoders.dup
|
|
70
|
+
subclass.noop_types = noop_types.dup
|
|
71
|
+
end
|
|
66
72
|
|
|
67
|
-
|
|
68
|
-
def noop_types =
|
|
73
|
+
def encoders = @encoders ||= []
|
|
74
|
+
def noop_types = @noop_types ||= []
|
|
69
75
|
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def |(other) = instance | other
|
|
75
|
-
def &(other) = instance & other
|
|
76
|
-
def for(type) = instance.for(type)
|
|
77
|
-
def to_plumb_type(op:, left:) = instance.to_plumb_type(op:, left:)
|
|
78
|
-
def to_composable = instance
|
|
79
|
-
def call(result) = instance.call(result)
|
|
76
|
+
# Only #inherited seeds a registry; everything else registers through
|
|
77
|
+
# #encoder / #noop.
|
|
78
|
+
attr_writer :encoders, :noop_types
|
|
79
|
+
protected :encoders=, :noop_types=
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
# Decode direction: rewrite `other` so it accepts the encoders' input form
|
|
82
|
+
# and produces the output values `other` describes. The rewritten type
|
|
83
|
+
# REPLACES the composition — no codec node remains.
|
|
84
|
+
def >>(other)
|
|
85
|
+
Rewriter.new(self, :decode).call(Composable.wrap(other))
|
|
86
|
+
end
|
|
82
87
|
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
+
# Both directions for a type, as a [decoding, encoding] pair:
|
|
89
|
+
#
|
|
90
|
+
# decoder, encoder = Plumb::Codec::JSON.for(Person)
|
|
91
|
+
# decoder.parse(input_data) # => a Person hash
|
|
92
|
+
# encoder.parse(person) # => output structures
|
|
93
|
+
#
|
|
94
|
+
# @param type [Composable, Object]
|
|
95
|
+
# @return [Array(Composable, Composable)]
|
|
96
|
+
def for(type)
|
|
97
|
+
type = Composable.wrap(type)
|
|
98
|
+
[self >> type, type >> self]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Encode direction, reached when the codec is the RIGHT operand
|
|
102
|
+
# (`Person >> JSONCodec` — see Composable.resolve_operand): build an encode
|
|
103
|
+
# rewrite of what `left` produces. Composable#>> then composes
|
|
104
|
+
# `And(left, rewrite)`: the left validates its input once, the
|
|
105
|
+
# rewrite encodes. Building from `left`'s OUTPUT (not `left` itself)
|
|
106
|
+
# avoids re-running its coercions on already-parsed values.
|
|
107
|
+
def to_plumb_type(op:, left:)
|
|
108
|
+
unless op == :>>
|
|
109
|
+
raise Plumb::TypeError, "#{inspect} only composes with #>> (got #{op}); a Codec is not a value type"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
left = Composable.wrap(left)
|
|
113
|
+
# A plain-include struct wraps as an opaque Step (output Any), so its
|
|
114
|
+
# rewrite target is the struct node itself, not its resolved output.
|
|
115
|
+
target = Plumb::Attributes.struct_class(left) ? left : Plumb::Subtyping.resolved_output(left)
|
|
116
|
+
Rewriter.new(self, :encode).call(target)
|
|
88
117
|
end
|
|
89
118
|
|
|
90
|
-
def
|
|
91
|
-
|
|
92
|
-
end
|
|
119
|
+
def |(_other) = raise Plumb::TypeError, "#{inspect} only composes with #>>; a Codec is not a value type"
|
|
120
|
+
alias & |
|
|
93
121
|
|
|
94
|
-
|
|
122
|
+
# A Codec is not a value type, and has no node to stand in for one. Both of
|
|
123
|
+
# these say so at the point of the mistake — without #to_composable,
|
|
124
|
+
# Composable.wrap would see a `#call` and build an opaque step out of the class.
|
|
125
|
+
def to_composable = raise(Plumb::TypeError, "#{inspect} is not a type; compose it with a type via #>>")
|
|
95
126
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
@encoders = self.class.encoders + extra_encoders
|
|
100
|
-
@noop_types = self.class.noop_types
|
|
101
|
-
@noop_union = @noop_types.reduce(:|)
|
|
102
|
-
freeze
|
|
103
|
-
end
|
|
127
|
+
def call(_result)
|
|
128
|
+
raise Plumb::TypeError, "#{inspect} is not a runtime type; compose it with a type via #>>"
|
|
129
|
+
end
|
|
104
130
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
131
|
+
# The best matching encoder for `type`, or nil. Matching is against each
|
|
132
|
+
# encoder's output type — schemas are written in output
|
|
133
|
+
# terms in both directions. Most-specific wins; equivalent types
|
|
134
|
+
# tie-break to the last registered; incomparable multi-matches raise.
|
|
135
|
+
def encoder_for(type, path = BLANK_ARRAY)
|
|
136
|
+
matches = encoders.select { |e| Plumb::Subtyping.subtype?(type, e.output_type) }
|
|
137
|
+
return nil if matches.empty?
|
|
138
|
+
return matches.first if matches.size == 1
|
|
111
139
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
# @param type [Composable, Object]
|
|
119
|
-
# @return [Array(Composable, Composable)]
|
|
120
|
-
def for(type)
|
|
121
|
-
type = Composable.wrap(type)
|
|
122
|
-
[self >> type, type >> self]
|
|
123
|
-
end
|
|
140
|
+
minimal = matches.reject do |e|
|
|
141
|
+
# e is dominated when another match's output type is strictly narrower.
|
|
142
|
+
matches.any? do |o|
|
|
143
|
+
!o.equal?(e) && Plumb::Subtyping.strict_subtype?(o.output_type, e.output_type)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
124
146
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
def to_plumb_type(op:, left:)
|
|
132
|
-
unless op == :>>
|
|
133
|
-
raise Plumb::TypeError, "#{inspect} only composes with #>> (got #{op}); a Codec is not a value type"
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
left = Composable.wrap(left)
|
|
137
|
-
# A plain-include struct wraps as an opaque Step (output Any), so its
|
|
138
|
-
# rewrite target is the struct node itself, not its resolved output.
|
|
139
|
-
target = Plumb::Attributes.struct_class(left) ? left : Plumb::Subtyping.resolved_output(left)
|
|
140
|
-
Rewriter.new(self, :encode).call(target)
|
|
141
|
-
end
|
|
147
|
+
first = minimal.first
|
|
148
|
+
unless minimal.all? { |e| Plumb::Subtyping.equivalent?(e.output_type, first.output_type) }
|
|
149
|
+
raise Plumb::TypeError,
|
|
150
|
+
"#{inspect}: #{at_path(path)} (#{type.inspect}) matches multiple incomparable encoders: " \
|
|
151
|
+
"#{minimal.map(&:inspect).join(', ')}. Register a more specific encoder or restructure."
|
|
152
|
+
end
|
|
142
153
|
|
|
143
|
-
|
|
144
|
-
|
|
154
|
+
minimal.last
|
|
155
|
+
end
|
|
145
156
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
157
|
+
# Is `type` (one side of it, per direction) covered by the registered noop
|
|
158
|
+
# types? Decode checks what the type ACCEPTS (it will be fed raw input
|
|
159
|
+
# data); encode checks what it PRODUCES (its output lands in the encoded
|
|
160
|
+
# document).
|
|
161
|
+
def noop?(type, direction)
|
|
162
|
+
return false unless noop_union
|
|
149
163
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
minimal = matches.reject do |e|
|
|
160
|
-
# e is dominated when another match's output type is strictly narrower.
|
|
161
|
-
matches.any? do |o|
|
|
162
|
-
!o.equal?(e) && Plumb::Subtyping.strict_subtype?(o.output_type, e.output_type)
|
|
163
|
-
end
|
|
164
|
+
side = direction == :decode ? Plumb::Subtyping.accepted_type(type) : Plumb::Subtyping.resolved_output(type)
|
|
165
|
+
# A pure filter with an opaque side is judged by the type itself: a
|
|
166
|
+
# bare-matcher Constraint (a branch of a factored refinement union, eg.
|
|
167
|
+
# the `String[/\Atrue\z/i] | String['1']` boolean input type) reports Any
|
|
168
|
+
# as its accepted type, but as a value-preserving refinement it IS its
|
|
169
|
+
# own honest description.
|
|
170
|
+
side = type if side.is_a?(AnyClass) && Plumb::Subtyping.value_preserving?(type)
|
|
171
|
+
Plumb::Subtyping.subtype?(side, noop_union)
|
|
164
172
|
end
|
|
165
173
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
174
|
+
# Is this concrete VALUE covered by the noop types? Used for Static nodes,
|
|
175
|
+
# whose fixed value can be validated directly — subtyping over the node
|
|
176
|
+
# can't relate an atomic Static to a container noop (`Static[[]]` vs
|
|
177
|
+
# `Types::Array`), but the value itself can just be checked.
|
|
178
|
+
def noop_value?(value)
|
|
179
|
+
return false unless noop_union
|
|
180
|
+
|
|
181
|
+
noop_union === value
|
|
171
182
|
end
|
|
172
183
|
|
|
173
|
-
|
|
184
|
+
def at_path(path) = path.empty? ? 'the root type' : "field `#{path.join('.')}`"
|
|
185
|
+
|
|
186
|
+
# Named, so an anonymous `Class.new(Codec)` still says what it carries.
|
|
187
|
+
def inspect = "#{name || superclass.name}[#{encoders.map(&:inspect).join(', ')}]"
|
|
188
|
+
|
|
189
|
+
private
|
|
190
|
+
|
|
191
|
+
# Derived from #noop_types, so resolved once — after boot, when the first
|
|
192
|
+
# composition asks.
|
|
193
|
+
def noop_union
|
|
194
|
+
return @noop_union if defined?(@noop_union)
|
|
195
|
+
|
|
196
|
+
@noop_union = noop_types.reduce(:|)
|
|
197
|
+
end
|
|
174
198
|
end
|
|
175
199
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
return
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
# bare-matcher Constraint (a branch of a factored refinement union, eg.
|
|
186
|
-
# the `String[/\Atrue\z/i] | String['1']` boolean input type) reports Any
|
|
187
|
-
# as its accepted type, but as a value-preserving refinement it IS its
|
|
188
|
-
# own honest description.
|
|
189
|
-
side = type if side.is_a?(AnyClass) && Plumb::Subtyping.value_preserving?(type)
|
|
190
|
-
Plumb::Subtyping.subtype?(side, @noop_union)
|
|
200
|
+
Entry = Data.define(:decoder, :encoder)
|
|
201
|
+
NoEntryError = Class.new(KeyError)
|
|
202
|
+
|
|
203
|
+
def initialize(&)
|
|
204
|
+
@entries = {}
|
|
205
|
+
return unless block_given?
|
|
206
|
+
|
|
207
|
+
yield self
|
|
208
|
+
freeze
|
|
191
209
|
end
|
|
192
210
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
def noop_value?(value)
|
|
198
|
-
return false unless @noop_union
|
|
211
|
+
def freeze
|
|
212
|
+
@entries.freeze
|
|
213
|
+
super
|
|
214
|
+
end
|
|
199
215
|
|
|
200
|
-
|
|
216
|
+
# Named, not splatted: both sides are types that #parse, so a swapped pair would
|
|
217
|
+
# decode where it should encode without anything raising.
|
|
218
|
+
def register(key, type = key)
|
|
219
|
+
decoder, encoder = self.class.for(type)
|
|
220
|
+
@entries[key] = Entry.new(decoder:, encoder:)
|
|
221
|
+
self
|
|
201
222
|
end
|
|
202
223
|
|
|
203
|
-
def
|
|
224
|
+
def key?(key) = @entries.key?(key)
|
|
204
225
|
|
|
205
|
-
|
|
206
|
-
|
|
226
|
+
def decode(key, payload) = entry(key).decoder.parse(payload)
|
|
227
|
+
def encode(key, payload) = entry(key).encoder.parse(payload)
|
|
228
|
+
|
|
229
|
+
def inspect
|
|
230
|
+
%(#<#{self.class}:#{object_id} [#{@entries.size} entries]>)
|
|
207
231
|
end
|
|
208
232
|
|
|
209
233
|
# The deep rewrite walker. Top-down, per node:
|
|
@@ -293,11 +317,16 @@ module Plumb
|
|
|
293
317
|
return NodeMapper.map(type) { |t| visit(t, path) }
|
|
294
318
|
end
|
|
295
319
|
|
|
320
|
+
# Before encoder matching, for the Static reason: a source IS a subtype of what
|
|
321
|
+
# it declares, so an encoder would replace the code that produces the value.
|
|
322
|
+
return visit_source(type, path) if source_step?(type)
|
|
323
|
+
|
|
296
324
|
enc = encoder_for(type, path)
|
|
297
325
|
return replace(type, enc, path) if enc
|
|
298
326
|
|
|
299
327
|
case type
|
|
300
328
|
when HashClass then visit_hash(type, path)
|
|
329
|
+
when FilteredHash then visit_filtered_hash(type, path)
|
|
301
330
|
when ArrayClass, StreamClass then visit_array(type, path)
|
|
302
331
|
when TupleClass then visit_tuple(type, path)
|
|
303
332
|
when HashMap then visit_hash_map(type, path)
|
|
@@ -311,13 +340,37 @@ module Plumb
|
|
|
311
340
|
end
|
|
312
341
|
end
|
|
313
342
|
|
|
314
|
-
# A leaf: nothing to recurse into structurally. It survives when
|
|
315
|
-
# format already carries it (the noop check), or — decoding — when the
|
|
343
|
+
# A leaf: nothing to recurse into structurally. It survives when it is OPAQUE,
|
|
344
|
+
# when the format already carries it (the noop check), or — decoding — when the
|
|
316
345
|
# codec can bridge what it CONSUMES.
|
|
317
346
|
def visit_leaf(type, path)
|
|
347
|
+
return type if opaque_step?(type)
|
|
348
|
+
|
|
318
349
|
bridge_input(type, path) || noop_or_fail(type, path)
|
|
319
350
|
end
|
|
320
351
|
|
|
352
|
+
# An OPAQUE step (`Any -> Any`) is a black box — a `#generate` generator, a bare
|
|
353
|
+
# proc in a schema. It names no type to rewrite, so it passes through, and what
|
|
354
|
+
# it emits lands unencoded, exactly as its `Any` output says.
|
|
355
|
+
def opaque_step?(type) = type.is_a?(Plumb::TypedStep) && type.opaque?
|
|
356
|
+
|
|
357
|
+
# A SOURCE step declares `Any -> T`: it produces a T out of whatever it is
|
|
358
|
+
# handed. The `#default { }` generator is one.
|
|
359
|
+
def source_step?(type)
|
|
360
|
+
type.is_a?(Plumb::TypedStep) && type.input_type.is_a?(AnyClass) && !type.output_type.is_a?(AnyClass)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# DECODE: leave it alone. Accepting `Any` it already accepts the encoded form,
|
|
364
|
+
# and it produces the decoded value itself (`Date.default { Date.today }` yields
|
|
365
|
+
# a Date, not a wire string).
|
|
366
|
+
#
|
|
367
|
+
# ENCODE: the rewrite is composed AFTER the type it encodes (see
|
|
368
|
+
# Codec#to_plumb_type), so the source has already run and the T it declared is
|
|
369
|
+
# what sits here. Rewriting the node would run the generator a second time.
|
|
370
|
+
def visit_source(type, path)
|
|
371
|
+
@direction == :decode ? type : visit(type.output_type, path)
|
|
372
|
+
end
|
|
373
|
+
|
|
321
374
|
# DECODE: a converting leaf (a Function, a Plumb::Implementation — any node
|
|
322
375
|
# whose accepted type is a distinct node) consumes values the encoded
|
|
323
376
|
# document does not carry: `Hash[time: Time] -> Thing` wants a Time, a JSON
|
|
@@ -443,12 +496,10 @@ module Plumb
|
|
|
443
496
|
# constant) still rewrite their input once.
|
|
444
497
|
input = enc.input_type_for(type)
|
|
445
498
|
rewritten_input = @input_memo.fetch(type) do
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
@input_stack.pop
|
|
451
|
-
end
|
|
499
|
+
@input_stack.push(enc)
|
|
500
|
+
@input_memo[type] = visit(input, path + ["<#{enc.inspect} input>"])
|
|
501
|
+
ensure
|
|
502
|
+
@input_stack.pop
|
|
452
503
|
end
|
|
453
504
|
|
|
454
505
|
# Splice the input into the step unless it is the encoder's declared
|
|
@@ -480,6 +531,21 @@ module Plumb
|
|
|
480
531
|
end
|
|
481
532
|
end
|
|
482
533
|
|
|
534
|
+
# DECODE rewrites the schema a filter FILTERS and rebuilds the filter around it,
|
|
535
|
+
# so decoding happens inside it, per field: #bridge_input's spliced step would
|
|
536
|
+
# decode the whole hash first, making one unreadable field fatal — what
|
|
537
|
+
# .filtered exists to avoid. (The filtered Array and HashMap need none of this;
|
|
538
|
+
# being containers they rewrite through #visit_array / #visit_hash_map.)
|
|
539
|
+
#
|
|
540
|
+
# ENCODE: `resolved_output` reduces the filter to its relaxed schema before the
|
|
541
|
+
# rewrite, so this is only reached defensively.
|
|
542
|
+
def visit_filtered_hash(type, path)
|
|
543
|
+
return visit(type.output_type, path) unless @direction == :decode
|
|
544
|
+
|
|
545
|
+
schema = visit(type.input_type, path)
|
|
546
|
+
schema.equal?(type.input_type) ? type : schema.filtered
|
|
547
|
+
end
|
|
548
|
+
|
|
483
549
|
def visit_array(type, path)
|
|
484
550
|
return noop_or_fail(type, path) if type.children.first.is_a?(AnyClass) # untyped — a leaf
|
|
485
551
|
|
|
@@ -507,11 +573,16 @@ module Plumb
|
|
|
507
573
|
NodeMapper.map_children(type) { |variant| visit(variant, path) }
|
|
508
574
|
end
|
|
509
575
|
|
|
576
|
+
# Rewriting can collapse two branches onto the SAME node — encoding a
|
|
577
|
+
# `#default { }`, both resolve to the type. Keep one: the second is unreachable,
|
|
578
|
+
# and a failing encode would otherwise report its errors twice.
|
|
510
579
|
def visit_or(type, path)
|
|
511
580
|
left, right = type.children
|
|
512
581
|
l = visit(left, path)
|
|
513
582
|
r = visit(right, path)
|
|
514
|
-
l.equal?(left) && r.equal?(right)
|
|
583
|
+
return type if l.equal?(left) && r.equal?(right)
|
|
584
|
+
|
|
585
|
+
l == r ? l : Disjunction.build(l, r)
|
|
515
586
|
end
|
|
516
587
|
|
|
517
588
|
# A MEET: a data-bearing type refined by pure filters (a #where's
|
|
@@ -560,27 +631,41 @@ module Plumb
|
|
|
560
631
|
# Rewriting it too decodes twice: #bridge_input splices `b`'s own decode step in
|
|
561
632
|
# front, the already-decoded value hits a step expecting the encoded form, and the
|
|
562
633
|
# pipeline rejects everything. A step only counts as having consumed the wire if
|
|
563
|
-
# its rewrite actually CHANGED it
|
|
634
|
+
# its rewrite actually CHANGED it — or if it is a black box, which says the same
|
|
635
|
+
# thing: what it hands on is unknown. `Types::Integer.generate { 1 }` is
|
|
636
|
+
# `(opaque >> Integer)`, and rewriting that Integer into the codec's
|
|
637
|
+
# `String -> Integer` step would feed it the generated Integer and reject
|
|
638
|
+
# everything.
|
|
564
639
|
def visit_composition(type, path)
|
|
565
640
|
left, right = type.children
|
|
566
641
|
l = pure_refinement?(left) ? left : visit(left, path)
|
|
567
|
-
|
|
568
|
-
# `left`'s rewrite left it unchanged.
|
|
569
|
-
wire_consumed = @direction == :decode && !l.equal?(left)
|
|
570
|
-
r = pure_refinement?(right) || wire_consumed ? right : visit(right, path)
|
|
642
|
+
r = pure_refinement?(right) || wire_consumed?(left, l) ? right : visit(right, path)
|
|
571
643
|
|
|
572
644
|
l.equal?(left) && r.equal?(right) ? type : Conjunction.build(l, r)
|
|
573
645
|
end
|
|
574
646
|
|
|
647
|
+
# Encoding, nothing consumes a wire: every step is rewritten in place.
|
|
648
|
+
def wire_consumed?(left, rewritten)
|
|
649
|
+
return false unless @direction == :decode
|
|
650
|
+
|
|
651
|
+
!rewritten.equal?(left) || opaque_step?(rewritten) || source_step?(rewritten)
|
|
652
|
+
end
|
|
653
|
+
|
|
575
654
|
# A pure refinement carries no encodable type — it filters the adjacent
|
|
576
655
|
# type's values. A baseless Constraint refining a sibling qualifies, but a
|
|
577
656
|
# base-type Constraint — `Types::Date` IS `Constraint(::Date)` — is data an
|
|
578
657
|
# encoder must see (eg. in `Types::Date.where(year: ...)` ==
|
|
579
658
|
# `And(Constraint(::Date), AVM)`), and so is a baseless Module gate, which
|
|
580
659
|
# names a type rather than filtering one.
|
|
660
|
+
#
|
|
661
|
+
# `Any` is the TRIVIAL filter, so it qualifies too — but only ALONGSIDE a sibling
|
|
662
|
+
# that carries a type; on its own it is still a leaf, and still an error. It gets
|
|
663
|
+
# here from an opaque step's output slot, which is what `resolved_output` leaves
|
|
664
|
+
# behind (`Integer.generate { 1 }` resolves to `(Any >> Integer)`): the value is
|
|
665
|
+
# unknown until the Integer beside it vouches for it, and that is what to encode.
|
|
581
666
|
def pure_refinement?(child)
|
|
582
667
|
case child
|
|
583
|
-
when AttributeValueMatch, ValueClass, Not then true
|
|
668
|
+
when AttributeValueMatch, ValueClass, Not, AnyClass then true
|
|
584
669
|
when Constraint then child.base.nil? && !child.matcher.is_a?(::Module)
|
|
585
670
|
else false
|
|
586
671
|
end
|
|
@@ -608,7 +693,6 @@ module Plumb
|
|
|
608
693
|
'Register an encoder for it, or declare it with .noop.'
|
|
609
694
|
end
|
|
610
695
|
end
|
|
611
|
-
|
|
612
696
|
end
|
|
613
697
|
|
|
614
698
|
# ------------------------------------------------------------------
|
|
@@ -634,9 +718,10 @@ module Plumb
|
|
|
634
718
|
def decode(str) = ::Date.parse(str)
|
|
635
719
|
end
|
|
636
720
|
|
|
637
|
-
# Time <=> ISO 8601 date-time string ("2024-08-30T20:15:
|
|
721
|
+
# Time <=> ISO 8601 date-time string ("2024-08-30T20:15:23.456789Z").
|
|
722
|
+
#
|
|
638
723
|
class TimeEncoder < Encoder[Types::String[TIME_EXPR].metadata(format: 'date-time') => Types::Time]
|
|
639
|
-
def encode(time) = time.iso8601
|
|
724
|
+
def encode(time) = time.iso8601(6)
|
|
640
725
|
def decode(str) = ::Time.parse(str)
|
|
641
726
|
end
|
|
642
727
|
|
|
@@ -791,5 +876,11 @@ module Plumb
|
|
|
791
876
|
encoder DateEncoder, TimeEncoder, SymbolEncoder, DecimalEncoder
|
|
792
877
|
encoder URIEncoder, HTTPURIEncoder, FileURIEncoder
|
|
793
878
|
end
|
|
879
|
+
|
|
880
|
+
private
|
|
881
|
+
|
|
882
|
+
def entry(key)
|
|
883
|
+
@entries.fetch(key) { raise NoEntryError, "no encoder/decoder registered for #{key}" }
|
|
884
|
+
end
|
|
794
885
|
end
|
|
795
886
|
end
|
data/lib/plumb/composable.rb
CHANGED
|
@@ -232,8 +232,8 @@ module Plumb
|
|
|
232
232
|
callable
|
|
233
233
|
elsif callable.respond_to?(:to_composable)
|
|
234
234
|
# The context-free resolution hook for objects that become a type on
|
|
235
|
-
# demand: an Encoder class resolves to its default (declared)
|
|
236
|
-
#
|
|
235
|
+
# demand: an Encoder class resolves to its default (declared) direction; a
|
|
236
|
+
# Codec raises, having no type to become. Used in schema literals,
|
|
237
237
|
# `Array[Enc]`, `#/`, etc. The composition operators (#>>, #|, #&)
|
|
238
238
|
# consult #to_plumb_type BEFORE wrapping, so a composed operand can
|
|
239
239
|
# resolve against context and never reaches this branch.
|
data/lib/plumb/hash_class.rb
CHANGED
|
@@ -453,11 +453,12 @@ module Plumb
|
|
|
453
453
|
# A filtered Hash is lenient: it accepts ANY hash and drops invalid/missing/
|
|
454
454
|
# extra fields (it never rejects a Hash), so as a #>> consumer it accepts any
|
|
455
455
|
# hash-like value. (Its #input_type still declares the schema it relaxes.)
|
|
456
|
-
|
|
457
|
-
# when this file is).
|
|
458
|
-
def self.each_pair_interface = @each_pair_interface ||= Types::Interface[:each_pair]
|
|
456
|
+
def accepted_type = Types::EachPair
|
|
459
457
|
|
|
460
|
-
|
|
458
|
+
# Re-derived, NOT rebuilt around the old #fn: the filter closes over the schema it
|
|
459
|
+
# was built from, so Function#with_children returns a node that inspects as the
|
|
460
|
+
# new schema and still filters by the old one.
|
|
461
|
+
def with_children(children) = children.first.filtered
|
|
461
462
|
|
|
462
463
|
# Neither boundary check runs — the per-field validation in HashClass#filtered
|
|
463
464
|
# subsumes both. Overriding #call is also what excludes this node from fusion,
|
data/lib/plumb/hash_map.rb
CHANGED
|
@@ -93,11 +93,7 @@ module Plumb
|
|
|
93
93
|
# entries (it never rejects a Hash), so as a #>> consumer it accepts any
|
|
94
94
|
# hash-like value — not itself. Without this, `Hash >> Hash[K, V].filtered`
|
|
95
95
|
# would be flagged as an illegal narrowing.
|
|
96
|
-
|
|
97
|
-
# yet when this file is).
|
|
98
|
-
def self.each_pair_interface = @each_pair_interface ||= Types::Interface[:each_pair]
|
|
99
|
-
|
|
100
|
-
def accepted_type = FilteredHashMap.each_pair_interface
|
|
96
|
+
def accepted_type = Types::EachPair
|
|
101
97
|
|
|
102
98
|
def call(result)
|
|
103
99
|
result.invalid(errors: 'must be a Hash') unless result.value.is_a?(::Hash)
|
data/lib/plumb/stream_class.rb
CHANGED
|
@@ -41,11 +41,7 @@ module Plumb
|
|
|
41
41
|
# #>> composition check. This lets a producer of a raw Enumerator/Array (eg.
|
|
42
42
|
# a CSV enumerator) feed a Stream. Covariant Stream[X] <= Stream[Y]
|
|
43
43
|
# subtyping is unaffected (that goes through #subtype_of? / #children).
|
|
44
|
-
|
|
45
|
-
# when this file is) — #call hits it on every data invocation.
|
|
46
|
-
def self.each_interface = @each_interface ||= Types::Interface[:each]
|
|
47
|
-
|
|
48
|
-
def input_type = StreamClass.each_interface
|
|
44
|
+
def input_type = Types::Each
|
|
49
45
|
|
|
50
46
|
# The [Step] interface
|
|
51
47
|
# @param result [Result]
|
data/lib/plumb/types.rb
CHANGED
|
@@ -96,11 +96,16 @@ module Plumb
|
|
|
96
96
|
# Works with a block too:
|
|
97
97
|
# date = Type::Any[Date].default { Date.today }
|
|
98
98
|
#
|
|
99
|
+
# A generated default declares, and is checked against, `type`'s OUTPUT — not `type`
|
|
100
|
+
# itself, which would re-run a conversion on a value the block already produced
|
|
101
|
+
# (`Types::String.transform(::Integer).default { 10 }` generates the Integer).
|
|
102
|
+
# Declaring it also tells the rest of the library what fills this position: without
|
|
103
|
+
# it the branch resolves to `Any`, and a Codec has nothing to encode.
|
|
99
104
|
policy :default, helper: true do |type, value = Undefined, &block|
|
|
100
105
|
val_type = if value == Undefined
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
106
|
+
fn = ->(result) { result.valid(block.call) }
|
|
107
|
+
Function.new(Types::Any, Plumb::Subtyping.resolved_output(type), fn,
|
|
108
|
+
inspect: 'default proc', identity: [:default, block])
|
|
104
109
|
else
|
|
105
110
|
Types::Static[value]
|
|
106
111
|
end
|
|
@@ -112,12 +117,20 @@ module Plumb
|
|
|
112
117
|
# Expect a specific exception class, and return an invalid result if it is raised.
|
|
113
118
|
# Usage:
|
|
114
119
|
# type = Types::String.build(Date, :parse).policy(:rescue, Date::Error)
|
|
120
|
+
#
|
|
121
|
+
# The guard returns what `type` returned, so it declares `type`'s OUTPUT — opaque,
|
|
122
|
+
# it would erase the type it wraps (the closure is invisible) and resolve to `Any`.
|
|
123
|
+
# Unchecked: `type` already validated what it produced. The INPUT stays `Any`, or
|
|
124
|
+
# `type`'s own input check runs a second time, outside the rescue.
|
|
115
125
|
policy :rescue do |type, exception_class|
|
|
116
|
-
|
|
126
|
+
fn = lambda do |result|
|
|
117
127
|
type.call(result)
|
|
118
128
|
rescue exception_class => e
|
|
119
129
|
result.invalid(errors: e.message)
|
|
120
130
|
end
|
|
131
|
+
|
|
132
|
+
GuaranteedFunction.new(Types::Any, Plumb::Subtyping.resolved_output(type), fn,
|
|
133
|
+
inspect: 'Rescue', identity: [:rescue, type, exception_class])
|
|
121
134
|
end
|
|
122
135
|
|
|
123
136
|
# Split a string into an array. Default separator is /\s*,\s*/
|
|
@@ -168,6 +181,10 @@ module Plumb
|
|
|
168
181
|
Email = String[URI::MailTo::EMAIL_REGEXP].as_node(:email)
|
|
169
182
|
Date = Any[::Date]
|
|
170
183
|
Time = Any[::Time]
|
|
184
|
+
# What a lenient container accepts (a Stream, a `.filtered` Array/Hash/HashMap).
|
|
185
|
+
# Here, not beside those classes, because Types is loaded after all of them.
|
|
186
|
+
Each = Interface[:each]
|
|
187
|
+
EachPair = Interface[:each_pair]
|
|
171
188
|
|
|
172
189
|
# A type that recursively converts string keys to symbols in nested hashes.
|
|
173
190
|
# This is commonly used for normalizing payload data in commands and events.
|
data/lib/plumb/version.rb
CHANGED