plumb 0.0.18 → 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 +887 -64
- data/bench/compare_dry_schema.rb +79 -0
- data/bench/compare_dry_types.rb +37 -0
- data/bench/compare_parametric_schema.rb +2 -80
- data/bench/dry_schema_hash.rb +103 -0
- data/bench/dry_types_hash.rb +125 -0
- data/bench/json_schema_profile.rb +107 -0
- data/bench/plumb_hash.rb +17 -11
- data/bench/results_allocations.rb +137 -0
- data/bench/sample_data.rb +78 -0
- data/examples/command_objects.rb +1 -1
- data/examples/concurrent_downloads.rb +16 -9
- data/examples/event_registry.rb +6 -1
- data/examples/weekdays.rb +1 -1
- data/lib/plumb/and.rb +63 -6
- data/lib/plumb/any_class.rb +12 -2
- data/lib/plumb/array_class.rb +133 -25
- data/lib/plumb/attribute_value_match.rb +41 -1
- data/lib/plumb/attributes.rb +59 -19
- data/lib/plumb/codec.rb +886 -0
- data/lib/plumb/composable.rb +451 -39
- data/lib/plumb/conjunction.rb +50 -0
- data/lib/plumb/constraint.rb +234 -0
- data/lib/plumb/covariant_fusion.rb +46 -0
- data/lib/plumb/decorator.rb +12 -22
- data/lib/plumb/deferred.rb +13 -5
- data/lib/plumb/disjunction.rb +112 -0
- data/lib/plumb/encoder.rb +207 -0
- data/lib/plumb/function.rb +347 -0
- data/lib/plumb/hash_class.rb +339 -32
- data/lib/plumb/hash_map.rb +58 -14
- data/lib/plumb/implementation.rb +247 -0
- data/lib/plumb/interface_class.rb +21 -2
- data/lib/plumb/intersection.rb +47 -0
- data/lib/plumb/json_schema_visitor.rb +255 -36
- data/lib/plumb/key.rb +63 -13
- data/lib/plumb/mermaid_visitor.rb +129 -0
- data/lib/plumb/metadata.rb +10 -1
- data/lib/plumb/metadata_visitor.rb +36 -34
- data/lib/plumb/never_class.rb +38 -0
- data/lib/plumb/node_mapper.rb +97 -0
- data/lib/plumb/not.rb +34 -2
- data/lib/plumb/optimizer.rb +444 -0
- data/lib/plumb/or.rb +26 -29
- data/lib/plumb/pipeline.rb +99 -11
- data/lib/plumb/policy.rb +17 -4
- data/lib/plumb/range_class.rb +46 -0
- data/lib/plumb/relation.rb +57 -0
- data/lib/plumb/result.rb +55 -23
- data/lib/plumb/semantic_matcher.rb +393 -0
- data/lib/plumb/static_class.rb +20 -1
- data/lib/plumb/stream_class.rb +28 -6
- data/lib/plumb/subtyping.rb +461 -0
- data/lib/plumb/tagged_hash.rb +45 -4
- data/lib/plumb/tuple_class.rb +21 -4
- data/lib/plumb/type_cache.rb +41 -0
- data/lib/plumb/type_registry.rb +71 -0
- data/lib/plumb/typed_step.rb +67 -0
- data/lib/plumb/types.rb +44 -43
- data/lib/plumb/union.rb +30 -0
- data/lib/plumb/value_class.rb +20 -1
- data/lib/plumb/version.rb +1 -1
- data/lib/plumb/visitor_handlers.rb +20 -4
- data/lib/plumb.rb +90 -3
- metadata +30 -8
- data/lib/plumb/build.rb +0 -22
- data/lib/plumb/match_class.rb +0 -42
- data/lib/plumb/schema.rb +0 -195
- data/lib/plumb/step.rb +0 -27
- data/lib/plumb/transform.rb +0 -26
data/README.md
CHANGED
|
@@ -139,7 +139,7 @@ More about [Types::Hash](#typeshash) and [Types::Array](#typesarray). There's al
|
|
|
139
139
|
|
|
140
140
|
### Type composition
|
|
141
141
|
|
|
142
|
-
At the core, Plumb types are little [Railway-oriented pipelines](https://ismaelcelis.com/posts/composable-pipelines-in-ruby/) that can be composed together with _AND_, _OR_ and _NOT_ semantics. Everything else builds on top of these
|
|
142
|
+
At the core, Plumb types are little [Railway-oriented pipelines](https://ismaelcelis.com/posts/composable-pipelines-in-ruby/) that can be composed together with _AND_ (`#>>`), _OR_ (`#|`) and _NOT_ (`#not`) semantics, plus set-style _intersection_ (`#&`). Everything else builds on top of these ideas.
|
|
143
143
|
|
|
144
144
|
#### Composing types with `#>>` ("And")
|
|
145
145
|
|
|
@@ -155,6 +155,43 @@ Similar to Ruby's built-in [function composition](https://thoughtbot.com/blog/pr
|
|
|
155
155
|
|
|
156
156
|
In other words, `A >> B` means "if A succeeds, pass its result to B. Otherwise return A's failed result."
|
|
157
157
|
|
|
158
|
+
`#>>` also **type-checks the composition** at build time: if the left side could never produce a value the right side accepts, the chain is a dead end and it raises `Plumb::TypeError` before any data flows through. See [Composition type-checks](#composition-type-checks).
|
|
159
|
+
|
|
160
|
+
#### A plain callable between two types
|
|
161
|
+
|
|
162
|
+
A proc declares no types, so on its own it's an opaque step. Written *between* two types, those types move into its boundaries and the chain becomes a single typed function:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
Doubled = Types::Integer >> ->(result) { result.valid(result.value * 2) } >> Types::Integer
|
|
166
|
+
|
|
167
|
+
Doubled.inspect # => "(Types::Integer -> Types::Integer)"
|
|
168
|
+
Doubled.input_type # => Types::Integer
|
|
169
|
+
Doubled.output_type # => Types::Integer
|
|
170
|
+
Doubled.parse(3) # => 6
|
|
171
|
+
Doubled.resolve('3').errors # => "Must be a Integer"
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The checks are the ones you wrote — the input validated before the callable runs, the output after — but they run as one node's boundaries instead of a three-step chain, and the result reports what it accepts and produces, so it keeps composing (and type-checking) downstream. Either half works on its own: `Types::Integer >> a_proc` is `(Types::Integer -> Plumb::Types::Any)`, typed on the side you declared.
|
|
175
|
+
|
|
176
|
+
Nothing is dropped to do this: a type only moves into a boundary the step left undeclared, so it runs exactly where the no-op ran. That includes a type that *builds* a value, so a struct pipeline collapses the same way:
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
Person = Types::Data[name: Types::String]
|
|
180
|
+
Renamer = Person >> ->(r) { r.valid(r.value.with(name: r.value.name.upcase)) } >> Person
|
|
181
|
+
|
|
182
|
+
Renamer.inspect # => "(Person -> Person)"
|
|
183
|
+
Renamer.parse(name: 'ada').name # => "ADA"
|
|
184
|
+
Renamer.resolve(name: 42).errors # => {name: "Must be a String"}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
What keeps its own node is a step that declares what it accepts, or one carrying its own callable — two of those meeting is transform fusion's business rather than absorption's:
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
# The transform declares String as its input, so the leading gate stays a step.
|
|
191
|
+
(Types::String >> Types::String.transform(::Integer, &:to_i)).inspect
|
|
192
|
+
# => "(Types::String >> (Types::String -> Integer))"
|
|
193
|
+
```
|
|
194
|
+
|
|
158
195
|
#### Disjunction with `#|` ("Or")
|
|
159
196
|
|
|
160
197
|
`A | B` means "if A returns a valid result, return that. Otherwise try B with the original input."
|
|
@@ -214,6 +251,58 @@ FlexibleUSD.parse(Money.new(1000, 'GBP')) # Money(USD 15.00)
|
|
|
214
251
|
|
|
215
252
|
You can see more use cases in [the examples directory](https://github.com/ismasan/plumb/tree/main/examples)
|
|
216
253
|
|
|
254
|
+
#### Intersection with `#&` and the `Never` type
|
|
255
|
+
|
|
256
|
+
`A & B` is the **intersection** (the greatest lower bound) of two types: it describes values that satisfy **both**. Unlike `#>>`, it is symmetric (order-independent) and never raises — where the two types can't overlap it produces `Types::Never` (see below).
|
|
257
|
+
|
|
258
|
+
Where it can, `#&` narrows to the exact overlap:
|
|
259
|
+
|
|
260
|
+
```ruby
|
|
261
|
+
Types::Integer[2..] & Types::Integer[0..100] # => Integer[2..100] (ranges intersected)
|
|
262
|
+
Types::Integer[1, 2, 3] & Types::Integer[2, 3, 4] # => Integer[Set[2, 3]]
|
|
263
|
+
Types::Integer & Types::Numeric # => Integer (keeps the narrower)
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
It distributes over unions and intersects covariant containers element-wise:
|
|
267
|
+
|
|
268
|
+
```ruby
|
|
269
|
+
Types::Array[Types::Integer | Types::Float] & Types::Array[Types::Float] # => Array[Float]
|
|
270
|
+
Types::Integer | (Types::String & Types::Integer) # => Integer
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
When the intersection is provably empty, the result is `Types::Never`:
|
|
274
|
+
|
|
275
|
+
```ruby
|
|
276
|
+
Types::String & Types::Integer # => Types::Never (no value is both)
|
|
277
|
+
Types::Integer[2..10] & Types::Integer[11..100] # => Types::Never (disjoint ranges)
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Chaining refinements with `#[]` (or `#where`) is the same intersection, so a provably-empty chain reduces to `Types::Never` too:
|
|
281
|
+
|
|
282
|
+
```ruby
|
|
283
|
+
Types::Integer[0..5][10..] # => Types::Never (== Integer[0..5] & Integer[10..])
|
|
284
|
+
Types::String.where(size: 0..5).where(size: 10..) # => Types::Never (unsatisfiable clause)
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
When it can neither narrow nor prove emptiness, `#&` falls back to a runtime intersection that validates the value through both sides.
|
|
288
|
+
|
|
289
|
+
`Types::Hash#&` ([Hash intersections](#hash-intersections)) and `Types::Interface#&` ([Intersecting interfaces](#intersecting-interfaces)) are the record- and interface-specific cases of the same operator.
|
|
290
|
+
|
|
291
|
+
##### `Types::Never`
|
|
292
|
+
|
|
293
|
+
`Types::Never` is the **bottom type** — the dual of the `Types::Any` top. No value inhabits it, so it always fails validation, and it collapses out of compositions:
|
|
294
|
+
|
|
295
|
+
```ruby
|
|
296
|
+
Types::Any & Types::Integer # => Integer (Any is the identity of &)
|
|
297
|
+
Types::Integer & Types::Never # => Types::Never (Never absorbs &)
|
|
298
|
+
Types::Integer | Types::Never # => Integer (Never is dropped from |)
|
|
299
|
+
|
|
300
|
+
Types::Never.resolve(42).valid? # => false (nothing is a Never)
|
|
301
|
+
Types::Never.to_json_schema # => { "not" => {} }
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
You rarely write `Types::Never` by hand — it's what an impossible intersection reduces to, which lets the composition algebra prove and discard dead branches (as in `Integer | (String & Integer)` above). It's also useful as a Hash catch-all to forbid undeclared keys — see [`_: Types::Never`](#undeclared-keys-and-the-_-catch-all).
|
|
305
|
+
|
|
217
306
|
### Built-in types
|
|
218
307
|
|
|
219
308
|
* `Types::Value`
|
|
@@ -232,6 +321,7 @@ You can see more use cases in [the examples directory](https://github.com/ismasa
|
|
|
232
321
|
* `Types::Numeric`
|
|
233
322
|
* `Types::String`
|
|
234
323
|
* `Types::Hash`
|
|
324
|
+
* `Types::Range`
|
|
235
325
|
* `Types::SymbolizedHash`
|
|
236
326
|
* `Types::UUID::V4`
|
|
237
327
|
* `Types::Email`
|
|
@@ -243,15 +333,8 @@ You can see more use cases in [the examples directory](https://github.com/ismasa
|
|
|
243
333
|
* `Types::Lax::Integer`
|
|
244
334
|
* `Types::Lax::String`
|
|
245
335
|
* `Types::Lax::Symbol`
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
* `Types::Forms::True`
|
|
249
|
-
* `Types::Forms::False`
|
|
250
|
-
* `Types::Forms::Date`
|
|
251
|
-
* `Types::Forms::Time`
|
|
252
|
-
* `Types::Forms::URI::Generic`
|
|
253
|
-
* `Types::Forms::URI::HTTP`
|
|
254
|
-
* `Types::Forms::URI::File`
|
|
336
|
+
|
|
337
|
+
For parsing stringy formats (HTML forms, query strings) into these types — what the `Types::Forms` namespace used to do, one way — see `Plumb::Codec::Forms` under [Encoders and Codecs](#encoders-and-codecs).
|
|
255
338
|
|
|
256
339
|
TODO: datetime, others.
|
|
257
340
|
|
|
@@ -351,6 +434,14 @@ The helper accepts multiple attribute/value pairs
|
|
|
351
434
|
JoeBloggs = Types::Any[User].where(first_name: 'Joe', last_name: 'Bloggs')
|
|
352
435
|
```
|
|
353
436
|
|
|
437
|
+
Attribute constraints take part in [composition type-checks](#composition-type-checks): a constrained type is a subtype of its base, and a constraint on the same attribute is a subtype when its value is contained in the other's (compared like ranges/literals).
|
|
438
|
+
|
|
439
|
+
```ruby
|
|
440
|
+
Types::Array.where(size: 10) >> Types::Array # ok: constrained Array is still an Array
|
|
441
|
+
Types::Array.where(size: 10) >> Types::Array.where(size: 8..100) # ok: 10 is within 8..100
|
|
442
|
+
Types::Array.where(size: 10..15) >> Types::Array.where(size: 11..14) # raises: 10..15 isn't within 11..14
|
|
443
|
+
```
|
|
444
|
+
|
|
354
445
|
#### `#transform`
|
|
355
446
|
|
|
356
447
|
Transform value. Requires specifying the resulting type of the value after transformation.
|
|
@@ -363,9 +454,27 @@ StringToInt = Types::String.transform(Integer, &:to_i)
|
|
|
363
454
|
StringToInteger.parse('10') # => 10
|
|
364
455
|
```
|
|
365
456
|
|
|
457
|
+
As a shorthand, `#transform` also accepts a single Ruby conversion symbol — `:to_s`, `:to_sym`, `:to_i`, `:to_f`, `:to_r`, `:to_c`, `:to_a`, `:to_h`, `:to_proc` — and infers the output type from it:
|
|
458
|
+
|
|
459
|
+
```ruby
|
|
460
|
+
Types::String.transform(:to_i) # transform to Integer, via #to_i
|
|
461
|
+
Types::Integer.transform(:to_s) # transform to String
|
|
462
|
+
# equivalent to
|
|
463
|
+
Types::String.transform(Integer, &:to_i)
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
When the input's base Ruby type is known, it validates that the type actually responds to the method, so mistakes fail at build time:
|
|
467
|
+
|
|
468
|
+
```ruby
|
|
469
|
+
Types::Integer.transform(:to_sym) # raises Plumb::TypeError (Integer has no #to_sym)
|
|
470
|
+
Types::Any.transform(:to_i) # ok — unknown input type, no check
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
`#transform` builds a [`Plumb::Function`](#plumbfunctioninput--output) — the underlying typed-conversion node — with a block that takes and returns a plain value. To build one standalone from a callable, or to work at the `Result` level, use `Plumb::Function[]` directly.
|
|
474
|
+
|
|
366
475
|
#### `#invoke`
|
|
367
476
|
|
|
368
|
-
`#invoke` builds a
|
|
477
|
+
`#invoke` builds a step that will invoke one or more methods on the value.
|
|
369
478
|
|
|
370
479
|
```ruby
|
|
371
480
|
StringToInt = Types::String.invoke(:to_i)
|
|
@@ -389,7 +498,7 @@ UpcaseToSym = Types::String.invoke(%i[downcase to_sym])
|
|
|
389
498
|
UpcaseToSym.parse('FOO_BAR') # :foo_bar
|
|
390
499
|
```
|
|
391
500
|
|
|
392
|
-
Note, as opposed to `#transform`, this helper does not
|
|
501
|
+
Note, as opposed to `#transform`, this helper does not declare a resulting output type (`#output_type`), which can be valuable for introspection or documentation (ex. JSON Schema).
|
|
393
502
|
|
|
394
503
|
Also, there's no definition-time checks that the method names are actually supported by the input values.
|
|
395
504
|
|
|
@@ -410,6 +519,27 @@ str.parse() # 'nope'
|
|
|
410
519
|
str.parse('yup') # 'yup'
|
|
411
520
|
```
|
|
412
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
|
+
|
|
413
543
|
Note that this is syntax sugar for:
|
|
414
544
|
|
|
415
545
|
```ruby
|
|
@@ -504,7 +634,6 @@ ten.parse(10) # => 10
|
|
|
504
634
|
ten.parse(100) # => 10
|
|
505
635
|
ten.parse('hello') # => 10
|
|
506
636
|
ten.parse() # => 10
|
|
507
|
-
ten.metadata[:type] # => Integer
|
|
508
637
|
```
|
|
509
638
|
|
|
510
639
|
Useful for data structures where some fields shouldn't change. Example:
|
|
@@ -517,12 +646,6 @@ CreateUserEvent = Types::Hash[
|
|
|
517
646
|
]
|
|
518
647
|
```
|
|
519
648
|
|
|
520
|
-
Note that the value must be of the same type as the starting step's target type.
|
|
521
|
-
|
|
522
|
-
```ruby
|
|
523
|
-
Types::Integer.static('nope') # raises ArgumentError
|
|
524
|
-
```
|
|
525
|
-
|
|
526
649
|
This usage is similar as using `Types::Static['hello']`directly.
|
|
527
650
|
|
|
528
651
|
This helper is shorthand for the following composition:
|
|
@@ -531,11 +654,11 @@ This helper is shorthand for the following composition:
|
|
|
531
654
|
Types::Static[value] >> step
|
|
532
655
|
```
|
|
533
656
|
|
|
534
|
-
|
|
657
|
+
Because the static value flows through the original step's type, an inconsistent value is caught at build time by the [composition check](#composition-type-checks):
|
|
535
658
|
|
|
536
659
|
```ruby
|
|
537
|
-
|
|
538
|
-
|
|
660
|
+
Types::Integer[100..].static(10) # raises Plumb::TypeError (10 is not within 100..)
|
|
661
|
+
type = Types::Integer[100..].static(150) # ok
|
|
539
662
|
```
|
|
540
663
|
|
|
541
664
|
So, normally you'd only use this attached to primitive types without further processing (but your use case may vary).
|
|
@@ -573,21 +696,109 @@ type.metadata[:description] # 'A long text'
|
|
|
573
696
|
`#metadata` combines keys from type compositions.
|
|
574
697
|
|
|
575
698
|
```ruby
|
|
576
|
-
type = Types::String.metadata(
|
|
699
|
+
type = Types::String[/@/].metadata(note: 'An email address') >> Types::String.metadata(description: 'A long text')
|
|
577
700
|
type.metadata[:description] # 'A long text'
|
|
578
701
|
type.metadata[:note] # 'An email address'
|
|
579
702
|
```
|
|
580
703
|
|
|
581
|
-
`#metadata`
|
|
704
|
+
`#metadata` only carries user-provided annotations. The Ruby type(s) a composition accepts and produces are described by `#input_type` and `#output_type` instead (see below).
|
|
705
|
+
|
|
706
|
+
TODO: document custom visitors.
|
|
707
|
+
|
|
708
|
+
#### `#input_type` and `#output_type`
|
|
709
|
+
|
|
710
|
+
Every type exposes the type it expects as input and the type it produces as output.
|
|
582
711
|
|
|
583
712
|
```ruby
|
|
584
|
-
Types::String.
|
|
585
|
-
Types::String
|
|
586
|
-
#
|
|
587
|
-
(Types::String | Types::Integer).metadata[:type] # [String, Integer]
|
|
713
|
+
StringToInt = Types::String.transform(Integer, &:to_i)
|
|
714
|
+
StringToInt.input_type # Types::String
|
|
715
|
+
StringToInt.output_type # Integer
|
|
588
716
|
```
|
|
589
717
|
|
|
590
|
-
|
|
718
|
+
They resolve through a composition, reporting what the chain as a whole consumes and produces — not its individual steps. The steps themselves remain available as `#children`:
|
|
719
|
+
|
|
720
|
+
```ruby
|
|
721
|
+
chain = Types::String.transform(Integer, &:to_i) >> Types::Integer.transform(Integer) { |i| i * 2 }
|
|
722
|
+
chain.input_type # Types::String — the chain can only be called with a String
|
|
723
|
+
chain.output_type # Integer — it can only produce an Integer
|
|
724
|
+
chain.children # [(Types::String -> Integer), (Types::Integer -> Integer)]
|
|
725
|
+
```
|
|
726
|
+
|
|
727
|
+
For a plain type, both are the type itself. Unions distribute over both sides:
|
|
728
|
+
|
|
729
|
+
```ruby
|
|
730
|
+
(Types::String | Types::Integer).input_type # Types::String | Types::Integer
|
|
731
|
+
(Types::String | Types::Integer).output_type # Types::String | Types::Integer
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
These power type introspection — for example, the JSON Schema visitor builds its schema from `#input_type`, since a schema describes the values a caller must send.
|
|
735
|
+
|
|
736
|
+
#### Composition type-checks
|
|
737
|
+
|
|
738
|
+
`#>>` is typed by **subsumption**, like function composition in a statically-typed language: everything the left step *produces* must be acceptable to the right step — i.e. the left's output type must be a **subtype** of the right's input type. If not, `#>>` raises `Plumb::TypeError` at build time, so broken data pipelines fail loudly when you define them, not silently at runtime.
|
|
739
|
+
|
|
740
|
+
```ruby
|
|
741
|
+
Types::String >> Types::Integer # raises: String is not a subtype of Integer
|
|
742
|
+
Types::Numeric >> Types::Integer # raises: Numeric is broader than Integer
|
|
743
|
+
Types::Integer[0..40] >> Types::Integer[2..10] # raises: the left can emit values (0,1,11..40) the right rejects
|
|
744
|
+
|
|
745
|
+
Types::Integer >> Types::Numeric # ok: every Integer is a Numeric
|
|
746
|
+
Types::Integer[2..10] >> Types::Integer[0..40] # ok: 2..10 is within 0..40
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
To **narrow** a value — where only some of what the left produces should pass — use `#[]` (or `#transform(...)[...]`). A refinement is a runtime-checked cast, built directly, and is *not* subject to the composition check:
|
|
750
|
+
|
|
751
|
+
```ruby
|
|
752
|
+
Types::Integer[0..40][2..10] # narrow to 2..10 (runtime-checked)
|
|
753
|
+
Types::String.transform(Integer, &:to_i)[1..10] # convert, then bound the result
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
For an arbitrary composition the checker can't prove — not just a matcher constraint — reach for `#/`, the unchecked counterpart of `#>>`. It builds the same refinement and is still validated at runtime, but skips the build-time check; you're asserting the chain is sound. It reads like `Pathname#/` ("join the next segment"):
|
|
757
|
+
|
|
758
|
+
```ruby
|
|
759
|
+
Types::Integer / Types::Integer[2..10] # narrow without the build-time check
|
|
760
|
+
Types::String / Types::String[/@/] # a String you assert is an email downstream
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
The check is permissive only where types are genuinely unknown: opaque steps (plain procs/lambdas, `#invoke`, `#generate`) and value-level transforms (`#transform`/`#build`) report `Any` on the relevant side and opt out. (`#static` ignores its input, so it never blocks a chain feeding *into* it, but it does declare the value it produces — so `Types::Static['foo'] >> Types::Integer` is flagged.)
|
|
764
|
+
|
|
765
|
+
For `Types::Hash` schemas, subsumption is record subtyping — the producer must provide every key the consumer requires (as a required key, with a subtype value); it may add extra keys:
|
|
766
|
+
|
|
767
|
+
```ruby
|
|
768
|
+
# the consumer requires :age, but the producer never provides it:
|
|
769
|
+
Types::Hash[name: Types::String] >> Types::Hash[name: Types::String, age: Types::Integer]
|
|
770
|
+
# => Plumb::TypeError
|
|
771
|
+
|
|
772
|
+
# a shared key whose value type isn't a subtype:
|
|
773
|
+
Types::Hash[name: Types::String] >> Types::Hash[name: Types::Integer]
|
|
774
|
+
# => Plumb::TypeError
|
|
775
|
+
|
|
776
|
+
# ok — producer is a subtype of consumer (wider, with subtype values):
|
|
777
|
+
Types::Hash[name: Types::Integer, age: Types::Integer] >> Types::Hash[name: Types::Numeric]
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
[`#where`](#where) attribute constraints subtype the same way — a constrained type is a subtype of its base, and a constraint is a subtype of a looser one on the same attribute:
|
|
781
|
+
|
|
782
|
+
```ruby
|
|
783
|
+
Types::Array.where(size: 10) >> Types::Array.where(size: 8..100) # ok: 10 is within 8..100
|
|
784
|
+
Types::Array.where(size: 10..15) >> Types::Array.where(size: 11..14) # raises: 10..15 isn't within 11..14
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
#### Subtype checks: `#<=` and `Plumb::Subtyping`
|
|
788
|
+
|
|
789
|
+
The relation behind the composition check is also available directly. `a <= b` asks "is every value described by `a` also described by `b`?" — i.e. is `a` a subtype/subset of `b`? — with `>=`, `<` and `>` derived from it. `Plumb::Subtyping.subtype?(a, b)` is the same check as a method. Built-in and custom types both participate, and raw Ruby classes/values are accepted on either side (they're normalized):
|
|
790
|
+
|
|
791
|
+
```ruby
|
|
792
|
+
Types::Integer <= Types::Numeric # true
|
|
793
|
+
Types::Numeric <= Types::Integer # false
|
|
794
|
+
Types::String[/@/] <= Types::String # true (more refined => a subset)
|
|
795
|
+
Types::Integer <= Numeric # true (compares against a raw Ruby class)
|
|
796
|
+
Types::Array[Integer] <= Types::Array[Numeric] # true (covariant in the element type)
|
|
797
|
+
|
|
798
|
+
big = Types::Hash[name: Types::String, age: Types::Integer]
|
|
799
|
+
small = Types::Hash[name: Types::String]
|
|
800
|
+
big <= small # true (width + depth subtyping)
|
|
801
|
+
```
|
|
591
802
|
|
|
592
803
|
### Other policies
|
|
593
804
|
|
|
@@ -630,7 +841,7 @@ Wraps a step's execution, rescues a specific exception and returns an invalid re
|
|
|
630
841
|
|
|
631
842
|
Useful for turning a 3rd party library's exception into an invalid result that plays well with Plumb's type compositions.
|
|
632
843
|
|
|
633
|
-
Example:
|
|
844
|
+
Example: parsing strings with `Date.parse` and turning `Date::Error` exceptions into Plumb errors.
|
|
634
845
|
|
|
635
846
|
```ruby
|
|
636
847
|
# Accept a string that can be parsed into a Date
|
|
@@ -645,6 +856,9 @@ type.resolve('2024-02-02') # => Result::Valid with Date object
|
|
|
645
856
|
type.resolve('2024-') # => Result::Invalid with error message
|
|
646
857
|
```
|
|
647
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
|
+
|
|
648
862
|
### `Types::Interface`
|
|
649
863
|
|
|
650
864
|
Use this for objects that must respond to one or more methods.
|
|
@@ -810,10 +1024,27 @@ StaffMember = User + Employee # Hash[:name, :age, :company]
|
|
|
810
1024
|
|
|
811
1025
|
#### Hash intersections
|
|
812
1026
|
|
|
813
|
-
Use `Types::Hash#&` to
|
|
1027
|
+
Use `Types::Hash#&` to intersect two hash definitions as maps. It keeps the keys present in **both**, and intersects each shared key's value type:
|
|
814
1028
|
|
|
815
1029
|
```ruby
|
|
816
|
-
|
|
1030
|
+
User & Employee # => Hash[name: String] (only the shared :name survives)
|
|
1031
|
+
|
|
1032
|
+
# shared keys have their value types intersected
|
|
1033
|
+
Types::Hash[age: Types::Integer[18..]] & Types::Hash[age: Types::Integer[..65]]
|
|
1034
|
+
# => Hash[age: Integer[18..65]]
|
|
1035
|
+
```
|
|
1036
|
+
|
|
1037
|
+
Two closed schemas that share no keys have nothing in common, so the intersection is `Types::Never` — the empty/bottom type, which no value satisfies:
|
|
1038
|
+
|
|
1039
|
+
```ruby
|
|
1040
|
+
Types::Hash[a: Types::Integer] & Types::Hash[b: Types::String] # => Types::Never
|
|
1041
|
+
```
|
|
1042
|
+
|
|
1043
|
+
A [`_` catch-all](#undeclared-keys-and-the-_-catch-all) widens what survives, since it admits the other side's extra keys:
|
|
1044
|
+
|
|
1045
|
+
```ruby
|
|
1046
|
+
Types::Hash[a: Types::String, _: Types::Any] & Types::Hash[a: Types::String, b: Types::Integer]
|
|
1047
|
+
# => Hash[a: String, b: Integer] (:b admitted via the left's catch-all)
|
|
817
1048
|
```
|
|
818
1049
|
|
|
819
1050
|
#### `Types::Hash#tagged_by`
|
|
@@ -833,18 +1064,41 @@ Events = Types::Hash.tagged_by(
|
|
|
833
1064
|
Events.parse(type: 'name_updated', name: 'Joe') # Uses NameUpdatedEvent definition
|
|
834
1065
|
```
|
|
835
1066
|
|
|
836
|
-
#### `
|
|
1067
|
+
#### Undeclared keys and the `_` catch-all
|
|
1068
|
+
|
|
1069
|
+
By default, keys present in the input but **not** declared in the schema are dropped:
|
|
1070
|
+
|
|
1071
|
+
```ruby
|
|
1072
|
+
Types::Hash[age: Types::Integer].parse(age: 30, name: 'Joe') # => { age: 30 } (:name dropped)
|
|
1073
|
+
```
|
|
1074
|
+
|
|
1075
|
+
To control what happens to those undeclared keys, add a special `_` key. It is a **catch-all**: its value type is applied to every key not otherwise declared. The value type you give it decides the behaviour:
|
|
837
1076
|
|
|
838
|
-
|
|
1077
|
+
| Catch-all | Meaning | Undeclared key `name`… |
|
|
1078
|
+
| --- | --- | --- |
|
|
1079
|
+
| _(none)_ | drop (default) | is removed |
|
|
1080
|
+
| `_: Types::Any` | include, unchanged | is kept as-is |
|
|
1081
|
+
| `_: SomeType` | include, validated/coerced | must be a `SomeType` (coerced if the type coerces) |
|
|
1082
|
+
| `_: Types::Never` | exclude (strict) | is a validation **error** |
|
|
839
1083
|
|
|
840
1084
|
```ruby
|
|
841
|
-
|
|
1085
|
+
# _: Any — keep every undeclared key, unchanged
|
|
1086
|
+
hash = Types::Hash[age: Types::Lax::Integer, _: Types::Any]
|
|
1087
|
+
hash.parse(age: '30', name: 'Joe', last_name: 'Bloggs')
|
|
1088
|
+
# => { age: 30, name: 'Joe', last_name: 'Bloggs' }
|
|
842
1089
|
|
|
843
|
-
#
|
|
844
|
-
|
|
1090
|
+
# _: SomeType — every undeclared value must be (or coerce to) that type
|
|
1091
|
+
Types::Hash[id: Types::String, _: Types::Integer].parse(id: 'x', a: 1, b: 2)
|
|
1092
|
+
# => { id: 'x', a: 1, b: 2 }
|
|
1093
|
+
Types::Hash[id: Types::String, _: Types::Integer].resolve(id: 'x', a: 'nope').valid? # => false
|
|
1094
|
+
|
|
1095
|
+
# _: Never — reject any undeclared key (a closed/strict hash)
|
|
1096
|
+
strict = Types::Hash[a: Types::String, _: Types::Never]
|
|
1097
|
+
strict.resolve(a: 'x').valid? # => true
|
|
1098
|
+
strict.resolve(a: 'x', b: 1).valid? # => false (b is not allowed)
|
|
845
1099
|
```
|
|
846
1100
|
|
|
847
|
-
|
|
1101
|
+
`_: Any` is useful when you only care about validating some fields, or to assemble different front and back hashes — for example a client-facing one that validates JSON or form data, and a backend one that runs further coercions on some keys while passing the rest through:
|
|
848
1102
|
|
|
849
1103
|
```ruby
|
|
850
1104
|
# Front-end definition does structural validation
|
|
@@ -854,15 +1108,29 @@ Front = Types::Hash[price: Integer, name: String, category: String]
|
|
|
854
1108
|
IntToMoney = Types::Integer.build(Money)
|
|
855
1109
|
|
|
856
1110
|
# Backend definition turns :price into a Money object, leaves other keys as-is
|
|
857
|
-
Back = Types::Hash[price: IntToMoney]
|
|
1111
|
+
Back = Types::Hash[price: IntToMoney, _: Types::Any]
|
|
858
1112
|
|
|
859
1113
|
# Compose the pipeline
|
|
860
1114
|
InputHandler = Front >> Back
|
|
861
1115
|
|
|
862
1116
|
InputHandler.parse(price: 100_000, name: 'iPhone 15', category: 'smartphones')
|
|
863
|
-
# => { price: #<Money fractional:100000 currency:GBP>, name: 'iPhone 15', category: '
|
|
1117
|
+
# => { price: #<Money fractional:100000 currency:GBP>, name: 'iPhone 15', category: 'smartphones' }
|
|
864
1118
|
```
|
|
865
1119
|
|
|
1120
|
+
The catch-all also shows up in generated JSON Schema as `additionalProperties`: `_: Any` → `{}` (anything), `_: Integer` → `{ "type": "integer" }`, and `_: Never` → `{ "not": {} }` (nothing allowed).
|
|
1121
|
+
|
|
1122
|
+
#### Typed keys
|
|
1123
|
+
|
|
1124
|
+
Keys are not limited to symbols. A key can be any type or matcher, and it matches an input key via `key === other`. So you can key by String, or by a pattern, and mix them with a catch-all:
|
|
1125
|
+
|
|
1126
|
+
```ruby
|
|
1127
|
+
Types::Hash['name' => Types::String] # a String key
|
|
1128
|
+
Types::Hash[Types::String[/^id_/] => Types::Integer, # keys matching /^id_/ hold Integers
|
|
1129
|
+
_: Types::Any] # everything else passes through
|
|
1130
|
+
```
|
|
1131
|
+
|
|
1132
|
+
A typed key is **lenient**: input keys that don't match any declared or typed key follow the catch-all rule above (dropped by default). This is different from a homogeneous map (`Types::Hash[Types::Symbol, Types::Integer]`, a `HashMap` — note the comma, not `=>`), which is **strict** (a non-conforming key is an error) and coerces keys through the key type. Use a `HashMap` for "every key/value has this type"; use typed keys for "keys shaped like this map to that".
|
|
1133
|
+
|
|
866
1134
|
#### `Types::Hash#filtered`
|
|
867
1135
|
|
|
868
1136
|
The `#filtered` modifier returns a valid Hash with the subset of values that were valid, instead of failing the entire result if one or more values are invalid.
|
|
@@ -873,17 +1141,97 @@ User.parse(name: 'Joe', age: 40) # => { name: 'Joe', age: 40 }
|
|
|
873
1141
|
User.parse(name: 'Joe', age: 'nope') # => { name: 'Joe' }
|
|
874
1142
|
```
|
|
875
1143
|
|
|
1144
|
+
### `Types::Range`
|
|
1145
|
+
|
|
1146
|
+
`Types::Range` validates that a value is a Ruby `Range`. On its own it accepts any range:
|
|
1147
|
+
|
|
1148
|
+
```ruby
|
|
1149
|
+
Types::Range.resolve(1..10) # valid
|
|
1150
|
+
Types::Range.resolve('a'..'z') # valid
|
|
1151
|
+
Types::Range.resolve(5) # invalid ("must be a Range")
|
|
1152
|
+
```
|
|
1153
|
+
|
|
1154
|
+
Specialize it with `#[]` to constrain the range's endpoints. The member type is matched against both the range's `#begin` and `#end` (a `nil` bound — an open-ended range — is skipped):
|
|
1155
|
+
|
|
1156
|
+
```ruby
|
|
1157
|
+
IntRange = Types::Range[Integer]
|
|
1158
|
+
IntRange.resolve(1..10) # valid
|
|
1159
|
+
IntRange.resolve('a'..'z') # invalid (endpoints aren't Integers)
|
|
1160
|
+
IntRange.resolve(1..) # valid (only the present bound is checked)
|
|
1161
|
+
```
|
|
1162
|
+
|
|
1163
|
+
The member type is any `#===` interface, so a `Range` itself works as the member matcher to bound where the endpoints may fall:
|
|
1164
|
+
|
|
1165
|
+
```ruby
|
|
1166
|
+
# A range whose endpoints both lie within 1..100
|
|
1167
|
+
Percent = Types::Range[1..100]
|
|
1168
|
+
Percent.resolve(10..20) # valid
|
|
1169
|
+
Percent.resolve(10..200) # invalid (200 is outside 1..100)
|
|
1170
|
+
```
|
|
1171
|
+
|
|
1172
|
+
#### Open-ended ranges with `#where`
|
|
1173
|
+
|
|
1174
|
+
Use `#where` with the `begin`/`end` attributes to constrain the range's own endpoints. Passing `end: nil` matches only endless ranges, and `begin: nil` only beginless ranges:
|
|
1175
|
+
|
|
1176
|
+
```ruby
|
|
1177
|
+
# Endless ranges only, eg. (1..)
|
|
1178
|
+
Endless = Types::Range[Integer].where(end: nil)
|
|
1179
|
+
Endless.resolve(1..) # valid
|
|
1180
|
+
Endless.resolve(1..10) # invalid ("must have attribute end === nil")
|
|
1181
|
+
|
|
1182
|
+
# Beginless ranges only, eg. (..10)
|
|
1183
|
+
Beginless = Types::Range[Integer].where(begin: nil)
|
|
1184
|
+
Beginless.resolve(..10) # valid
|
|
1185
|
+
Beginless.resolve(1..10) # invalid
|
|
1186
|
+
```
|
|
1187
|
+
|
|
1188
|
+
`#where` values are also full `#===` matchers, so an endpoint can be constrained by a type or another range:
|
|
1189
|
+
|
|
1190
|
+
```ruby
|
|
1191
|
+
# A range that starts at zero or above
|
|
1192
|
+
NonNegativeStart = Types::Range.where(begin: Types::Integer[0..])
|
|
1193
|
+
NonNegativeStart.resolve(5..10) # valid
|
|
1194
|
+
NonNegativeStart.resolve(-5..10) # invalid
|
|
1195
|
+
```
|
|
1196
|
+
|
|
1197
|
+
#### Composition
|
|
1198
|
+
|
|
1199
|
+
`Types::Range` is covariant in its member type and preserves its input value (it validates endpoints without coercing them), so it composes like the other containers. A union absorbs a narrower member into a wider one:
|
|
1200
|
+
|
|
1201
|
+
```ruby
|
|
1202
|
+
Types::Range[1..10] <= Types::Range[Integer] # true (covariant)
|
|
1203
|
+
|
|
1204
|
+
# The narrower branch is absorbed
|
|
1205
|
+
Types::Range[Integer] | Types::Range[1..10] # => Range[Integer]
|
|
1206
|
+
```
|
|
1207
|
+
|
|
1208
|
+
#### JSON Schema
|
|
1209
|
+
|
|
1210
|
+
A `Types::Range` whose member pins numeric bounds maps to JSON Schema's native keywords, preserving an exclusive end as `exclusiveMaximum`:
|
|
1211
|
+
|
|
1212
|
+
```ruby
|
|
1213
|
+
Plumb::JSONSchemaVisitor.call(Types::Range[0...100], root: false)
|
|
1214
|
+
# => { "type" => "integer", "minimum" => 0, "exclusiveMaximum" => 100 }
|
|
1215
|
+
```
|
|
1216
|
+
|
|
876
1217
|
### `Types::SymbolizedHash`
|
|
877
1218
|
|
|
878
1219
|
This type turns a hash's keys into symbols by calling `#to_sym` on them, and returning a new Hash.
|
|
879
1220
|
|
|
1221
|
+
`SymbolizedHash` is a `Symbol => Any` map. You can use it as a _transform_.
|
|
1222
|
+
|
|
880
1223
|
```ruby
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
type.parse('name' => 'Joe', 'age' => 20) # {name: 'Joe', age: 20}
|
|
1224
|
+
UserHash = Types::Hash[name: String]
|
|
1225
|
+
Types::SymbolizedHash.transform(UserHash).parse('name' => 'Joe') # { name: 'Joe' }
|
|
884
1226
|
```
|
|
885
1227
|
|
|
1228
|
+
You can also use the shortcut `#symbolized`
|
|
886
1229
|
|
|
1230
|
+
```ruby
|
|
1231
|
+
# Symbolize keys, then coerce into a typed Hash.
|
|
1232
|
+
type = Types::Hash[name: String, age: Integer].symbolized
|
|
1233
|
+
type.parse('name' => 'Joe', 'age' => 20) # {name: 'Joe', age: 20}
|
|
1234
|
+
```
|
|
887
1235
|
|
|
888
1236
|
### maps
|
|
889
1237
|
|
|
@@ -947,6 +1295,47 @@ emails = Types::Array[Types::String[/@/]]
|
|
|
947
1295
|
|
|
948
1296
|
Prefer the latter (`Types::Array[Types::String[/@/]]`), as that first validates that each element is a `String` before matching against the regular expression.
|
|
949
1297
|
|
|
1298
|
+
#### Chained array maps fuse into a single pass
|
|
1299
|
+
|
|
1300
|
+
`Types::Array` is covariant in its element type, so mapping `f` over an array and then mapping `g` is the same as mapping `f >> g` once. Composing two arrays applies that, and the collection is traversed once instead of twice:
|
|
1301
|
+
|
|
1302
|
+
```ruby
|
|
1303
|
+
Trim = Types::String.transform(::String, &:strip)
|
|
1304
|
+
Downcase = Types::String.transform(::String, &:downcase)
|
|
1305
|
+
Symbolize = Types::String.transform(::Symbol, &:to_sym)
|
|
1306
|
+
|
|
1307
|
+
# Written as three separate maps over the collection...
|
|
1308
|
+
Tags = Types::Array[Trim] >> Types::Array[Downcase] >> Types::Array[Symbolize]
|
|
1309
|
+
|
|
1310
|
+
# ...built as one.
|
|
1311
|
+
Tags.class # => Plumb::ArrayClass
|
|
1312
|
+
Tags.inspect # => "Array[(Types::String -> Symbol)]"
|
|
1313
|
+
Tags == Types::Array[Trim >> Downcase >> Symbolize] # => true
|
|
1314
|
+
|
|
1315
|
+
Tags.parse([' RUBY ', ' Plumb', 'CSV ']) # => [:ruby, :plumb, :csv]
|
|
1316
|
+
```
|
|
1317
|
+
|
|
1318
|
+
This is worth knowing when the element steps are defined apart from one another and only meet at a boundary — you get the single-pass version without hand-fusing it. On a 200-element array the three-stage chain above goes from 154.6µs to 102.2µs per value, and the two intermediate arrays are never built.
|
|
1319
|
+
|
|
1320
|
+
Validation is unaffected. The JSON Schema still describes the input side, and errors are still keyed by element index:
|
|
1321
|
+
|
|
1322
|
+
```ruby
|
|
1323
|
+
Tags.to_json_schema # => {"type" => "array", "items" => {"type" => "string"}}
|
|
1324
|
+
Tags.resolve(['ok', 42, ' fine ']).errors # => {1 => "Must be a String"}
|
|
1325
|
+
```
|
|
1326
|
+
|
|
1327
|
+
`Types::Tuple`, the `Types::Hash[K, V]` map form and `Types::Stream` fuse the same way. Fusion needs the element boundary to be provable — what the left element produces must be accepted by the right — so anything the checker can't prove is left as two passes:
|
|
1328
|
+
|
|
1329
|
+
```ruby
|
|
1330
|
+
# Narrowing isn't provable, so this stays two passes (and `#>>` would reject it outright).
|
|
1331
|
+
Types::Array[Trim] / Types::Array[Types::String[/^a/]]
|
|
1332
|
+
|
|
1333
|
+
# Different containers, so no functor law to apply.
|
|
1334
|
+
Types::Array[Trim] / Types::Stream[Symbolize]
|
|
1335
|
+
```
|
|
1336
|
+
|
|
1337
|
+
That guard is what keeps errors identical: two passes report stage by stage, so if the right map could reject what the left produced, one pass could surface errors two passes never reach. Records (`Types::Hash[name: ...]`) don't fuse either, since a record can drop, add and make keys optional.
|
|
1338
|
+
|
|
950
1339
|
#### Concurrent arrays
|
|
951
1340
|
|
|
952
1341
|
Use `Types::Array#concurrent` to process array elements concurrently (using Concurrent Ruby for now).
|
|
@@ -1323,7 +1712,7 @@ class DBConfig < Types::Data
|
|
|
1323
1712
|
end
|
|
1324
1713
|
|
|
1325
1714
|
class Config < Types::Data
|
|
1326
|
-
attribute :host,
|
|
1715
|
+
attribute :host, Plumb::Codec::HTTPURIEncoder, writer: true
|
|
1327
1716
|
attribute :port, Types::Integer.default(80), writer: true
|
|
1328
1717
|
|
|
1329
1718
|
# Nested structs can have writers too
|
|
@@ -1388,6 +1777,30 @@ result = CreateUser.resolve(name: 'Joe', age: 40)
|
|
|
1388
1777
|
# result.value => User
|
|
1389
1778
|
```
|
|
1390
1779
|
|
|
1780
|
+
##### `#step` (non-strict) and `#step!` (strict)
|
|
1781
|
+
|
|
1782
|
+
A pipeline is a sequence of validators/coercions that progressively narrows its data, so **`#step` is non-strict**: it chains with [`#/`](#composition-type-checks), skipping the composition check (a later step may legitimately narrow what an earlier one produced). Use **`#step!`** for the strict [`#>>` check](#composition-type-checks) — a build-time `Plumb::TypeError` if a step could never accept the previous step's output.
|
|
1783
|
+
|
|
1784
|
+
```ruby
|
|
1785
|
+
pl.step Types::Hash # non-strict: a later step may narrow this
|
|
1786
|
+
pl.step! Types::Hash[name: Types::String] # strict: raises if it can't accept the prior output
|
|
1787
|
+
```
|
|
1788
|
+
|
|
1789
|
+
To add a step that **transforms** the value into a new type, pass the output type followed by a block. This builds a [`#transform`](#transform) — a trusted, declared conversion — and the rest of the pipeline chains from the new type:
|
|
1790
|
+
|
|
1791
|
+
```ruby
|
|
1792
|
+
User = Data.define(:name)
|
|
1793
|
+
|
|
1794
|
+
pipeline = Types::Any.pipeline do |pl|
|
|
1795
|
+
pl.step Types::Hash[name: Types::String]
|
|
1796
|
+
# output type + a block that produces it (the block takes and returns a Result)
|
|
1797
|
+
pl.step(User) { |result| result.valid(User.new(result.value[:name])) }
|
|
1798
|
+
end
|
|
1799
|
+
|
|
1800
|
+
pipeline.output_type == Plumb::Composable.wrap(User) # true
|
|
1801
|
+
pipeline.parse(name: 'Joe') # => #<data User name="Joe">
|
|
1802
|
+
```
|
|
1803
|
+
|
|
1391
1804
|
Pipelines are Plumb steps, so they can be composed further.
|
|
1392
1805
|
|
|
1393
1806
|
```ruby
|
|
@@ -1513,10 +1926,6 @@ Pipe2 = DebuggablePipeline.new do |pl|
|
|
|
1513
1926
|
end
|
|
1514
1927
|
```
|
|
1515
1928
|
|
|
1516
|
-
### Plumb::Schema
|
|
1517
|
-
|
|
1518
|
-
TODO
|
|
1519
|
-
|
|
1520
1929
|
### Recursive types
|
|
1521
1930
|
|
|
1522
1931
|
You can use a proc to defer evaluation of recursive definitions.
|
|
@@ -1550,6 +1959,197 @@ LinkedList = Types::Hash[
|
|
|
1550
1959
|
|
|
1551
1960
|
|
|
1552
1961
|
|
|
1962
|
+
### Encoders and Codecs
|
|
1963
|
+
|
|
1964
|
+
A one-way coercion can parse an external representation (a date string) into a parsed value (a `Date`), but not back. **Encoders** generalize that into pluggable, two-way serialization, and **Codecs** group encoders and apply them to whole schemas — Ruby data structures to JSON-ready structures and back, for example.
|
|
1965
|
+
|
|
1966
|
+
#### Defining encoders
|
|
1967
|
+
|
|
1968
|
+
An encoder is a class declaring an input and an output type, with `#decode` (input ⇒ output) and `#encode` (output ⇒ input) methods:
|
|
1969
|
+
|
|
1970
|
+
```ruby
|
|
1971
|
+
DateRange = Types::Range[Types::Date]
|
|
1972
|
+
JSONDateRange = Types::Hash[from: Types::Date, to: Types::Date]
|
|
1973
|
+
|
|
1974
|
+
class JSONDateRangeEncoder < Plumb::Encoder[JSONDateRange => DateRange]
|
|
1975
|
+
def encode(range) = { from: range.begin, to: range.end }
|
|
1976
|
+
def decode(hash) = hash[:from]..hash[:to]
|
|
1977
|
+
end
|
|
1978
|
+
```
|
|
1979
|
+
|
|
1980
|
+
By default an encoder behaves exactly like a transform in its declared direction (`JSONDateRange -> DateRange`, running `#decode`). But it is reversible: composed next to a type that matches its *output* side, it transparently runs the inverse.
|
|
1981
|
+
|
|
1982
|
+
```ruby
|
|
1983
|
+
# Decode: the declared direction.
|
|
1984
|
+
FromJSON = JSONDateRange >> JSONDateRangeEncoder >> DateRange
|
|
1985
|
+
FromJSON.parse({ from: Date.new(2024, 1, 1), to: Date.new(2024, 2, 1) }) # => Date..Date range
|
|
1986
|
+
|
|
1987
|
+
# Encode: inferred from the DateRange on the left.
|
|
1988
|
+
ToJSON = DateRange >> JSONDateRangeEncoder >> JSONDateRange
|
|
1989
|
+
ToJSON.parse(Date.new(2024, 1, 1)..Date.new(2024, 2, 1)) # => { from: ..., to: ... }
|
|
1990
|
+
```
|
|
1991
|
+
|
|
1992
|
+
Each direction is a normal Plumb step: it validates its input type, runs your method, and validates the produced value against its output type (a wrong return value is an invalid `Result`, and an exception raised inside `#encode`/`#decode` becomes an invalid `Result` too). Composition is type-checked as usual — `Types::Symbol >> JSONDateRangeEncoder` raises `Plumb::TypeError`.
|
|
1993
|
+
|
|
1994
|
+
Where the context gives no signal — schema literals (`Types::Hash[dates: SomeEncoder]`), `#/`, `.parse`, or an `Any`/opaque neighbour — the declared direction is used. `.decoding` (the declared direction) and `.encoding` (the inverse, input/output swapped) are the explicit forms:
|
|
1995
|
+
|
|
1996
|
+
```ruby
|
|
1997
|
+
JSONDateRangeEncoder.decoding # JSONDateRange -> DateRange, runs #decode
|
|
1998
|
+
JSONDateRangeEncoder.encoding # DateRange -> JSONDateRange, runs #encode
|
|
1999
|
+
|
|
2000
|
+
JSONDateRangeEncoder.decode(from: Date.new(2024, 1, 1), to: Date.new(2024, 2, 1)) # => a Range
|
|
2001
|
+
JSONDateRangeEncoder.encode(Date.new(2024, 1, 1)..Date.new(2024, 2, 1)) # => a Hash
|
|
2002
|
+
```
|
|
2003
|
+
|
|
2004
|
+
Encoders also express lenient unions — `Types::Date | SomeDateEncoder` accepts a `Date` or decodes a string into one.
|
|
2005
|
+
|
|
2006
|
+
#### Codecs
|
|
2007
|
+
|
|
2008
|
+
A codec groups encoders and applies them to whole types at composition time. Codecs know nothing about any particular format — only their encoders. Types that are already valid in the target format are declared with `.noop`:
|
|
2009
|
+
|
|
2010
|
+
```ruby
|
|
2011
|
+
# Plumb::Codec::JSON ships noops for String, Numeric, booleans, Nil and bare
|
|
2012
|
+
# Hash/Array, plus built-in string encoders for Dates, Times, URIs,
|
|
2013
|
+
# Symbols and Decimals.
|
|
2014
|
+
class JSONCodec < Plumb::Codec::JSON
|
|
2015
|
+
encoder JSONDateRangeEncoder
|
|
2016
|
+
end
|
|
2017
|
+
```
|
|
2018
|
+
|
|
2019
|
+
(A subclass encoder registered for an equivalent type — eg. your own Date encoder — takes precedence over an inherited built-in.)
|
|
2020
|
+
|
|
2021
|
+
Composing a codec with a type rewrites the type deeply, in either direction:
|
|
2022
|
+
|
|
2023
|
+
```ruby
|
|
2024
|
+
Person = Types::Hash[name: Types::String, dates: DateRange]
|
|
2025
|
+
|
|
2026
|
+
JSONPerson = JSONCodec >> Person # decode: JSON structures -> Person
|
|
2027
|
+
JSONPerson.parse({ name: 'Joe', dates: { from: '2024-01-01', to: '2024-02-01' } })
|
|
2028
|
+
# => { name: 'Joe', dates: Date(2024-01-01)..Date(2024-02-01) }
|
|
2029
|
+
|
|
2030
|
+
EncodedPerson = Person >> JSONCodec # encode: Person -> JSON structures
|
|
2031
|
+
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' } }
|
|
2033
|
+
```
|
|
2034
|
+
|
|
2035
|
+
`Codec.for(type)` returns both directions as a `[decoding, encoding]` pair:
|
|
2036
|
+
|
|
2037
|
+
```ruby
|
|
2038
|
+
decoder, encoder = JSONCodec.for(Person)
|
|
2039
|
+
decoder.parse(json_data) # => a Person hash
|
|
2040
|
+
encoder.parse(person_hash) # => JSON structures
|
|
2041
|
+
```
|
|
2042
|
+
|
|
2043
|
+
Note how the `Date` values *inside* `JSONDateRange` were resolved too: an encoder's input type is itself rewritten through the same codec, so nested non-native values are handled by other encoders in the group (here, the built-in `Date` encoder). The rewrite recurses into nested hashes, arrays, tuples, hash maps, union branches, metadata/policy wrappers and `.defer`red recursive types. Matching is by subtyping against each encoder's output type, most-specific encoder first.
|
|
2044
|
+
|
|
2045
|
+
Codecs work with any type, not just schemas:
|
|
2046
|
+
|
|
2047
|
+
```ruby
|
|
2048
|
+
(JSONCodec >> Types::Date).parse('2024-01-01') # => Date
|
|
2049
|
+
(Types::Date >> JSONCodec).parse(Date.new(2024, 1, 1)) # => '2024-01-01'
|
|
2050
|
+
JSONCodec >> Types::String # => Types::String, unchanged (noop)
|
|
2051
|
+
```
|
|
2052
|
+
|
|
2053
|
+
Struct classes (`Types::Data` subclasses, or any class that `include`s `Plumb::Attributes`) work too, at any depth — decoding builds instances, encoding takes them apart:
|
|
2054
|
+
|
|
2055
|
+
```ruby
|
|
2056
|
+
class Company < Types::Data
|
|
2057
|
+
attribute :name, Types::String
|
|
2058
|
+
attribute :founded, Types::Date
|
|
2059
|
+
end
|
|
2060
|
+
|
|
2061
|
+
decoder, encoder = JSONCodec.for(Company)
|
|
2062
|
+
company = decoder.parse({ name: 'ACME', founded: '2024-01-01' }) # => #<Company founded: Date>
|
|
2063
|
+
encoder.parse(company) # => { name: 'ACME', founded: '2024-01-01' }
|
|
2064
|
+
```
|
|
2065
|
+
|
|
2066
|
+
A field whose type is a **converting step** — a `#transform`/`#build` Function, a [`Plumb::Implementation`](#include-plumbimplementationinput--output-to-declare-a-class-types), a struct class — is decoded by rewriting what it *accepts* and putting that in front of it, so the step is fed the decoded value. A `Types::Data` class is one such node (`Hash[…] -> Person`); so is a hand-written equivalent, and both are handled the same way:
|
|
2067
|
+
|
|
2068
|
+
```ruby
|
|
2069
|
+
class ParseRecord
|
|
2070
|
+
extend Plumb::Implementation[Types::Hash[on: Types::Date] => Record]
|
|
2071
|
+
|
|
2072
|
+
def self._call(result) = result.valid(Record.new(result.value[:on]))
|
|
2073
|
+
end
|
|
2074
|
+
|
|
2075
|
+
(JSONCodec >> ParseRecord).parse({ on: '2024-01-01' }) # => #<Record on: Date>
|
|
2076
|
+
```
|
|
2077
|
+
|
|
2078
|
+
Its accepted `Date` is decoded from a string first; the step itself is preserved and still validates what it is handed. A step whose accepted type is already native is left untouched; one whose accepted type the codec can't decode raises, naming the step.
|
|
2079
|
+
|
|
2080
|
+
A field that matches no encoder and no noop is a composition-time error naming the field path:
|
|
2081
|
+
|
|
2082
|
+
```ruby
|
|
2083
|
+
JSONCodec >> Types::Hash[profile: Types::Hash[joined: Types::Any[Time]]]
|
|
2084
|
+
# raises Plumb::TypeError: ... field `profile.joined` (Any[Time]) matches no encoder ...
|
|
2085
|
+
```
|
|
2086
|
+
|
|
2087
|
+
The result of a codec composition is ordinary Plumb algebra — the codec leaves no runtime node behind — so JSON Schema generation works, describing the input side of a decoded schema:
|
|
2088
|
+
|
|
2089
|
+
```ruby
|
|
2090
|
+
JSONPerson.to_json_schema
|
|
2091
|
+
# "dates" is described as { "type" => "object", "properties" => { "from" => { "type" => "string" }, ... } }
|
|
2092
|
+
```
|
|
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
|
+
|
|
2122
|
+
#### `Codec::Forms`: string-based formats
|
|
2123
|
+
|
|
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`).
|
|
2125
|
+
|
|
2126
|
+
```ruby
|
|
2127
|
+
Config = Types::Hash[
|
|
2128
|
+
host: Types::URI::HTTP,
|
|
2129
|
+
port: Types::Integer,
|
|
2130
|
+
active: Types::Boolean,
|
|
2131
|
+
starts_on: Types::Date | Types::Nil
|
|
2132
|
+
]
|
|
2133
|
+
|
|
2134
|
+
decoder, encoder = Plumb::Codec::Forms.for(Config)
|
|
2135
|
+
decoder.parse({ host: 'http://example.com', port: '80', active: '1', starts_on: '' })
|
|
2136
|
+
# => { host: URI(...), port: 80, active: true, starts_on: nil }
|
|
2137
|
+
encoder.parse({ host: URI.parse('http://example.com'), port: 80, active: true, starts_on: nil })
|
|
2138
|
+
# => { host: 'http://example.com', port: '80', active: 'true', starts_on: '' }
|
|
2139
|
+
```
|
|
2140
|
+
|
|
2141
|
+
`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.
|
|
2142
|
+
|
|
2143
|
+
Format-neutral encoders live at the `Plumb::Codec` level and are registered by both built-in codecs: ISO 8601 `Codec::DateEncoder`/`Codec::TimeEncoder`, RFC 3986 `Codec::URIEncoder`/`HTTPURIEncoder`/`FileURIEncoder`, `Codec::SymbolEncoder` (Symbols travel as strings) and `Codec::DecimalEncoder` (BigDecimals travel as canonical decimal strings — a string, not a number, to keep their precision; this also applies under `Codec::JSON`, where a raw BigDecimal would not be JSON-native). They are also usable per-field (`attribute :host, Plumb::Codec::HTTPURIEncoder`), and the old lenient behaviour is expressible as a union: `Types::Date | Plumb::Codec::DateEncoder`.
|
|
2144
|
+
|
|
2145
|
+
Things to know:
|
|
2146
|
+
|
|
2147
|
+
* Direction inference needs a typed neighbour. Opaque contexts fall back to the declared direction — use `.decoding`/`.encoding` to be explicit.
|
|
2148
|
+
* The JSON Schema of an *encode* pipeline describes what it accepts (its output-typed values), per the library convention that schemas describe accepted inputs. Visit the decode direction for the input-format schema.
|
|
2149
|
+
* `.defer`red fields rewrite lazily, so an unmatched type inside one surfaces at first resolution rather than at composition.
|
|
2150
|
+
* Registering `noop Types::Hash` / `Types::Array` only covers *untyped* containers — structured schemas (and struct classes) are always recursed into, so a generic noop can't accidentally skip encoding of nested fields.
|
|
2151
|
+
* Decoding a struct runs the rewritten schema and then the struct's own validation — correct, but a struct attribute with a non-idempotent transform would apply it twice. Struct attributes should be validators/coercions, as they already must be for `#with`.
|
|
2152
|
+
|
|
1553
2153
|
### Custom types
|
|
1554
2154
|
|
|
1555
2155
|
Every Plumb type exposes the following one-method interface:
|
|
@@ -1564,25 +2164,62 @@ The `Result::Valid` class has helper methods `#valid(value) => Result::Valid` an
|
|
|
1564
2164
|
|
|
1565
2165
|
#### Compose procs or lambdas directly
|
|
1566
2166
|
|
|
1567
|
-
Piping any `#call` object onto Plumb types
|
|
2167
|
+
Piping any `#call` object onto Plumb types wraps your object in a composable step, with all methods necessary for further composition.
|
|
1568
2168
|
|
|
1569
2169
|
```ruby
|
|
1570
2170
|
Greeting = Types::String >> ->(result) { result.valid("Hello #{result.value}") }
|
|
1571
2171
|
```
|
|
1572
2172
|
|
|
1573
|
-
####
|
|
2173
|
+
#### `Plumb::Function[input => output]`
|
|
1574
2174
|
|
|
1575
|
-
|
|
2175
|
+
To build a standalone, typed function from a callable — one not already piped onto a type — use `Plumb::Function[]`. Declaring both ends gives you a typed function: the input is validated before your callable runs, and the value it produces is validated against the output type.
|
|
1576
2176
|
|
|
1577
2177
|
```ruby
|
|
1578
|
-
Greeting = Plumb::
|
|
2178
|
+
Greeting = Plumb::Function[String => String] do |result|
|
|
1579
2179
|
result.valid("Hello #{result.value}")
|
|
1580
2180
|
end
|
|
2181
|
+
|
|
2182
|
+
Greeting.parse('Joe') # => 'Hello Joe'
|
|
2183
|
+
Greeting.parse(10) # raises Plumb::ParseError ("Must be a String")
|
|
1581
2184
|
```
|
|
1582
2185
|
|
|
1583
|
-
|
|
2186
|
+
The block takes and returns a [`Result`](#custom-types) — unlike [`#transform`](#transform), whose block takes and returns a plain value. A callable can be passed instead of a block:
|
|
1584
2187
|
|
|
1585
|
-
|
|
2188
|
+
```ruby
|
|
2189
|
+
Greeting = Plumb::Function[MyGreeter.new, String => String]
|
|
2190
|
+
```
|
|
2191
|
+
|
|
2192
|
+
Because both ends are declared, the resulting step takes part in [composition type checks](#composition-type-checks) and JSON Schema generation, just like `#transform`:
|
|
2193
|
+
|
|
2194
|
+
```ruby
|
|
2195
|
+
StringLength = Plumb::Function[String => Integer] { |result| result.valid(result.value.size) }
|
|
2196
|
+
StringLength.input_type # => String
|
|
2197
|
+
StringLength.output_type # => Integer
|
|
2198
|
+
|
|
2199
|
+
Types::Integer >> StringLength # raises Plumb::TypeError at build time
|
|
2200
|
+
```
|
|
2201
|
+
|
|
2202
|
+
You can also pass a custom `#call(Result) => Result` interface as the first argument, to turn a callable into a typed function.
|
|
2203
|
+
|
|
2204
|
+
```ruby
|
|
2205
|
+
TypedGreeting = Plumb::Function[Greeting.new('Mr.'), String => String]
|
|
2206
|
+
TypedGreeting.parse('Joe') # "Mr. Joe"
|
|
2207
|
+
TypedGreeting.parse(10) # raises Plumb::ParseError
|
|
2208
|
+
```
|
|
2209
|
+
|
|
2210
|
+
|
|
2211
|
+
|
|
2212
|
+
Omit the types when the callable is genuinely untyped. Both ends default to `Types::Any`, and the step opts out of composition checks.
|
|
2213
|
+
|
|
2214
|
+
```ruby
|
|
2215
|
+
Greeting = Plumb::Function[] do |result|
|
|
2216
|
+
result.valid("Hello #{result.value}")
|
|
2217
|
+
end
|
|
2218
|
+
```
|
|
2219
|
+
|
|
2220
|
+
Note that this last example doesn't validate that the input is indeed a String, whereas `Plumb::Function[String => String]` does.
|
|
2221
|
+
|
|
2222
|
+
Either way, `Greeting` is a full Plumb step, which comes with all the Plumb methods and policies.
|
|
1586
2223
|
|
|
1587
2224
|
```ruby
|
|
1588
2225
|
# Greeting responds to #>>, #|, #default, #transform, etc etc
|
|
@@ -1599,11 +2236,11 @@ class Greeting
|
|
|
1599
2236
|
@gr = gr
|
|
1600
2237
|
end
|
|
1601
2238
|
|
|
1602
|
-
# The Plumb
|
|
2239
|
+
# The Plumb step interface
|
|
1603
2240
|
# @param result [Plumb::Result::Valid]
|
|
1604
2241
|
# @return [Plumb::Result::Valid, Plumb::Result::Invalid]
|
|
1605
2242
|
def call(result)
|
|
1606
|
-
result.valid("#{gr} #{result.value}")
|
|
2243
|
+
result.valid("#{@gr} #{result.value}")
|
|
1607
2244
|
end
|
|
1608
2245
|
end
|
|
1609
2246
|
|
|
@@ -1614,7 +2251,7 @@ This is useful when you want to parameterize your custom steps, for example by i
|
|
|
1614
2251
|
|
|
1615
2252
|
#### Include `Plumb::Composable` to make instance of a class full "steps"
|
|
1616
2253
|
|
|
1617
|
-
The class above will be wrapped
|
|
2254
|
+
The class above will be wrapped in a composable step when piped into other steps, but it doesn't support Plumb methods on its own.
|
|
1618
2255
|
|
|
1619
2256
|
Including `Plumb::Composable` makes it support all Plumb methods directly.
|
|
1620
2257
|
|
|
@@ -1628,9 +2265,9 @@ class Greeting
|
|
|
1628
2265
|
@gr = gr
|
|
1629
2266
|
end
|
|
1630
2267
|
|
|
1631
|
-
# The
|
|
2268
|
+
# The step interface
|
|
1632
2269
|
def call(result)
|
|
1633
|
-
result.valid("#{gr} #{result.value}")
|
|
2270
|
+
result.valid("#{@gr} #{result.value}")
|
|
1634
2271
|
end
|
|
1635
2272
|
|
|
1636
2273
|
# This is optional, but it allows you to control your object's #inspect
|
|
@@ -1650,15 +2287,149 @@ LoudGreeting = Greeting.new('Hola').default('no greeting').invoke(:upcase)
|
|
|
1650
2287
|
class User
|
|
1651
2288
|
extend Composable
|
|
1652
2289
|
|
|
1653
|
-
def self.
|
|
2290
|
+
def self.call(result)
|
|
1654
2291
|
# do something here. Perhaps returning a Result with an instance of this class
|
|
1655
|
-
|
|
2292
|
+
result.valid(new)
|
|
1656
2293
|
end
|
|
1657
2294
|
end
|
|
1658
2295
|
```
|
|
1659
2296
|
|
|
1660
2297
|
This is how [Plumb::Types::Data](#typesdata) is implemented.
|
|
1661
2298
|
|
|
2299
|
+
#### Include `Plumb::Implementation[input => output]` to declare a class' types
|
|
2300
|
+
|
|
2301
|
+
`Plumb::Composable` makes your instances composable, but Plumb knows nothing about what they accept or produce — they're opaque, so they opt out of [composition type-checks](#composition-type-checks) and subtype checks.
|
|
2302
|
+
|
|
2303
|
+
`Plumb::Implementation[Input => Output]` is `Composable` plus a declared type pair. It makes your instances behave like a [`Plumb::Function`](#plumbfunctioninput--output): your class owns its `#initialize` and its state, and implements a private `#_call(Result) => Result`.
|
|
2304
|
+
|
|
2305
|
+
The mixin owns the public `#call`, which runs the declared checks around your `#_call`:
|
|
2306
|
+
|
|
2307
|
+
```
|
|
2308
|
+
result.map(input_type).map(_call).map(output_type)
|
|
2309
|
+
```
|
|
2310
|
+
|
|
2311
|
+
ie. the input is validated (and coerced, if the input type converts) before `#_call` sees it, and what it returns is validated against the output type.
|
|
2312
|
+
|
|
2313
|
+
```ruby
|
|
2314
|
+
class UserFinder
|
|
2315
|
+
include Plumb::Implementation[Types::UUID::V4 => User]
|
|
2316
|
+
|
|
2317
|
+
def initialize(user_scope)
|
|
2318
|
+
@user_scope = user_scope
|
|
2319
|
+
end
|
|
2320
|
+
|
|
2321
|
+
private def _call(result)
|
|
2322
|
+
user = User.where(level: @user_scope).find_by(id: result.value)
|
|
2323
|
+
return result.invalid(errors: 'no user!') unless user
|
|
2324
|
+
|
|
2325
|
+
result.valid(user)
|
|
2326
|
+
end
|
|
2327
|
+
end
|
|
2328
|
+
```
|
|
2329
|
+
|
|
2330
|
+
Instances are now fully typed steps:
|
|
2331
|
+
|
|
2332
|
+
```ruby
|
|
2333
|
+
finder = UserFinder.new('admin')
|
|
2334
|
+
|
|
2335
|
+
finder.parse(some_uuid) # => a User. Raises Plumb::ParseError unless the input is a UUID
|
|
2336
|
+
finder >> some_other_step # composition, type-checked at build time
|
|
2337
|
+
Types::UUID::V4 >> finder # ...on both sides
|
|
2338
|
+
Types::Integer >> finder # => Plumb::TypeError: Integer is not a subtype of UUID::V4
|
|
2339
|
+
|
|
2340
|
+
finder <= User # => true. Like a Function, it is identified by what it PRODUCES
|
|
2341
|
+
finder.to_json_schema # describes the INPUT side, like any other conversion
|
|
2342
|
+
|
|
2343
|
+
Types::Hash[user: finder] # use it anywhere a type is expected
|
|
2344
|
+
```
|
|
2345
|
+
|
|
2346
|
+
Both sides are wrapped with `Plumb::Composable.wrap`, so raw Ruby classes and hash literals work too: `Plumb::Implementation[{id: Types::String} => User]`.
|
|
2347
|
+
|
|
2348
|
+
Instances report `#node_name` `:function`, so every visitor, JSON Schema handler and policy that understands a conversion node understands yours. Define your own `#node_name` after the include if you have visitors of your own. Everything else is the [regular extension surface](#participating-in-subtype--composition-checks): override `#subtype_of?`, `#value_preserving?` etc. as needed.
|
|
2349
|
+
|
|
2350
|
+
`include Plumb::Implementation` with no pair declares `Any => Any` — the opaque case, equivalent to `Plumb::Function.opaque`.
|
|
2351
|
+
|
|
2352
|
+
Subclassing needs no ceremony: `#_call` is an ordinary method, so an override is found by normal lookup and the inherited `#call` keeps checking around it. `super` reaches the parent's `#_call` directly, with no repeated checks.
|
|
2353
|
+
|
|
2354
|
+
```ruby
|
|
2355
|
+
class AdminFinder < UserFinder
|
|
2356
|
+
# input already validated as a UUID; the User you return is still checked
|
|
2357
|
+
private def _call(result) = result.valid(super.value.becomes(Admin))
|
|
2358
|
+
end
|
|
2359
|
+
```
|
|
2360
|
+
|
|
2361
|
+
#### Extend `Plumb::Implementation[input => output]` to make the class itself a typed step
|
|
2362
|
+
|
|
2363
|
+
Just as with [`Plumb::Composable`](#extend-a-class-with-plumbcomposable-to-make-the-class-itself-a-composable-step), `extend` instead of `include` puts the whole interface on the class: no instantiation, the class implements `self._call(result)` and answers `.input_type` / `.output_type`.
|
|
2364
|
+
|
|
2365
|
+
```ruby
|
|
2366
|
+
class ParseUUID
|
|
2367
|
+
extend Plumb::Implementation[Types::String => Types::UUID::V4]
|
|
2368
|
+
|
|
2369
|
+
def self._call(result) = result.valid(result.value.downcase)
|
|
2370
|
+
end
|
|
2371
|
+
|
|
2372
|
+
ParseUUID.parse('E1D3...') # the class IS the step
|
|
2373
|
+
ParseUUID >> UserFinder.new('admin') # composes like any other type
|
|
2374
|
+
Types::Hash[id: ParseUUID]
|
|
2375
|
+
ParseUUID.to_json_schema
|
|
2376
|
+
```
|
|
2377
|
+
|
|
2378
|
+
The two forms are alternatives — pick one per class. The extended form deliberately does **not** take over the class' own `#name`, `#inspect`, `#==` or `#<=` (on a class, `<=` means Ruby module ancestry), so ask for the subtype relation explicitly instead:
|
|
2379
|
+
|
|
2380
|
+
```ruby
|
|
2381
|
+
Plumb::Subtyping.subtype?(ParseUUID, Types::String) # => true
|
|
2382
|
+
```
|
|
2383
|
+
|
|
2384
|
+
#### Participating in subtype & composition checks
|
|
2385
|
+
|
|
2386
|
+
The subtype (`#<=`) and [`#>>` composition](#composition-type-checks) checks are built on a single hook that every `Plumb::Composable` already implements with a sensible default — `#>>` is just `subtype?(produced, accepted)`, so there's nothing extra to implement for composition. A custom type participates **without changing any core library code**: it either relies on the default or overrides the hook. `Plumb::Subtyping` itself only knows the composition algebra (the top type `Types::Any`, the bottom type `Types::Never`, union `#|`, intersection `#&`, refinement/sequencing `#>>`, and conversion `#transform`); everything else is delegated to the type.
|
|
2387
|
+
|
|
2388
|
+
The default leans on two methods your type already has:
|
|
2389
|
+
|
|
2390
|
+
- `#children` — the sub-types this type is built from, as an array. A type whose single child is a **raw Ruby matcher** (a Class, Range, Regexp or literal — as `Plumb::Constraint` wraps) is treated as *atomic* and compared with Ruby semantics. A type whose children are themselves Plumb types (like `Array`, `Tuple`, `HashMap`) is treated as a **covariant container** — so exposing `#children` is all a custom container needs to compare covariantly.
|
|
2391
|
+
- `#==` — structural equality (provided by `Plumb::Composable`).
|
|
2392
|
+
|
|
2393
|
+
##### The hook
|
|
2394
|
+
|
|
2395
|
+
| Hook | Returns | Used by | Default |
|
|
2396
|
+
| --- | --- | --- | --- |
|
|
2397
|
+
| `#subtype_of?(other)` | `Boolean` | `#<=`, `Plumb::Subtyping.subtype?`, and so `#>>` | reflexive · atomic · same-class covariant `#children` |
|
|
2398
|
+
|
|
2399
|
+
`#subtype_of?` answers "is every value I describe also described by `other`?". It's the leaf step of `subtype?`, reached after the algebra (`Any`/`Never`/`|`/`&`/`>>`/`#transform`) has been peeled away. Override it for bespoke behaviour — **recurse through `Plumb::Subtyping.subtype?`, never through `#<=`** (which would loop back into the algebra). `HashClass` overrides it for record (width + depth + optionality) subtyping.
|
|
2400
|
+
|
|
2401
|
+
```ruby
|
|
2402
|
+
# An "even integer" refinement that knows it is a subtype of Integer (and
|
|
2403
|
+
# therefore Numeric), and defers everything else to the default.
|
|
2404
|
+
class EvenInteger
|
|
2405
|
+
include Plumb::Composable
|
|
2406
|
+
|
|
2407
|
+
def call(result)
|
|
2408
|
+
result.value.is_a?(::Integer) && result.value.even? ? result : result.invalid(errors: 'must be even')
|
|
2409
|
+
end
|
|
2410
|
+
|
|
2411
|
+
def subtype_of?(other)
|
|
2412
|
+
Plumb::Subtyping.subtype?(Types::Integer, other) || super
|
|
2413
|
+
end
|
|
2414
|
+
|
|
2415
|
+
private def _inspect = 'EvenInteger'
|
|
2416
|
+
end
|
|
2417
|
+
|
|
2418
|
+
even = EvenInteger.new
|
|
2419
|
+
even <= Types::Integer # => true
|
|
2420
|
+
even <= Types::Numeric # => true
|
|
2421
|
+
even <= Types::String # => false
|
|
2422
|
+
```
|
|
2423
|
+
|
|
2424
|
+
##### Type flow: `#input_type` / `#output_type`
|
|
2425
|
+
|
|
2426
|
+
The `#>>` check (and the [JSON Schema visitor](#json-schema)) ask what a type accepts and produces; both [default to `self`](#input_type-and-output_type). Override them when your type changes the value or is opaque about it:
|
|
2427
|
+
|
|
2428
|
+
- a value-converting step declares a different `#output_type` (what `#transform`/`#build` do via `Plumb::Function`);
|
|
2429
|
+
- an opaque step (a wrapped proc, a generator) returns `Types::Any` for both, opting out of the `#>>` compatibility check.
|
|
2430
|
+
|
|
2431
|
+
Custom types are **values/leaves** in the algebra — you compose them with the built-in combinators (`#>>`, `#|`, `#transform`, `Types::Any`) rather than re-implementing those.
|
|
2432
|
+
|
|
1662
2433
|
### Custom policies
|
|
1663
2434
|
|
|
1664
2435
|
`Plumb.policy` can be used to encapsulate common type compositions, or compositions that can be configurable by parameters.
|
|
@@ -1709,7 +2480,7 @@ end
|
|
|
1709
2480
|
|
|
1710
2481
|
# Usage: annotate fields in a schema
|
|
1711
2482
|
AccountName = Types::String.admin
|
|
1712
|
-
AccountName.metadata # => {
|
|
2483
|
+
AccountName.metadata # => { admin: true }
|
|
1713
2484
|
```
|
|
1714
2485
|
|
|
1715
2486
|
#### Type-specific policies
|
|
@@ -1767,6 +2538,13 @@ Plumb.policy :split, SplitPolicy
|
|
|
1767
2538
|
|
|
1768
2539
|
Plumb ships with a JSON schema visitor that compiles a type composition into a JSON Schema Hash. All Plumb types support a `#to_json_schema` method.
|
|
1769
2540
|
|
|
2541
|
+
The generated schema describes the **input** a type accepts (its `#input_type`), not what it produces. So a coercing type advertises the type a caller should send:
|
|
2542
|
+
|
|
2543
|
+
```ruby
|
|
2544
|
+
# Accepts a String, coerces it to an Integer
|
|
2545
|
+
Types::String.transform(Integer, &:to_i).to_json_schema # => { "type" => "string" }
|
|
2546
|
+
```
|
|
2547
|
+
|
|
1770
2548
|
```ruby
|
|
1771
2549
|
Payload = Types::Hash[name: String]
|
|
1772
2550
|
Payload.to_json_schema(root: true)
|
|
@@ -1826,12 +2604,57 @@ Types::DateTime.to_json_schema
|
|
|
1826
2604
|
# {"type"=>"string", "format"=>"date-time"}
|
|
1827
2605
|
```
|
|
1828
2606
|
|
|
2607
|
+
##### Node names for compositions
|
|
2608
|
+
|
|
2609
|
+
Two-sided compositions report one of four `#node_name`s, depending on whether the node is a *computation* (some side changes the value) or a *type* (no side does):
|
|
2610
|
+
|
|
2611
|
+
| Node name | Built by | Meaning |
|
|
2612
|
+
| --------------- | --------------------------------- | ----------------------------------------------------------- |
|
|
2613
|
+
| `:and` | `#>>` with a converting side | Sequential composition — consumes the left's input, produces the right's output |
|
|
2614
|
+
| `:intersection` | `#>>`, `#/`, `#where`, `#check`, `#&` | The meet — both sides constrain the *same* value |
|
|
2615
|
+
| `:or` | `#\|` with a converting branch | Left-biased choice — a branch may coerce, so the ends differ |
|
|
2616
|
+
| `:union` | `#\|` with value-preserving branches | The join — a plain set of alternatives |
|
|
2617
|
+
|
|
2618
|
+
For a visitor this matters because an `:intersection` describes one value (merge both sides' specs) while an `:and` may describe a conversion (build from the input side). Visitors that don't need the distinction can register just `on(:and)` / `on(:or)`: `:intersection` and `:union` fall back to those when no specific handler is defined.
|
|
2619
|
+
|
|
2620
|
+
### Mermaid diagrams
|
|
2621
|
+
|
|
2622
|
+
Because a composition is just a tree of `>>` (sequence) and `|` (choice) nodes, it can also be rendered as a [Mermaid](https://mermaid.js.org) `flowchart`. Every Plumb type supports `#to_mermaid`. `>>` becomes sequential arrows; `|` becomes a fork, where the preceding step fans out to each alternative (and a following step joins them back).
|
|
2623
|
+
|
|
2624
|
+
```ruby
|
|
2625
|
+
type = (A >> B) | (C >> (D | B))
|
|
2626
|
+
puts type.to_mermaid
|
|
2627
|
+
```
|
|
2628
|
+
|
|
2629
|
+
```mermaid
|
|
2630
|
+
flowchart LR
|
|
2631
|
+
start(( ))
|
|
2632
|
+
n1["A"]
|
|
2633
|
+
n2["B"]
|
|
2634
|
+
n3["C"]
|
|
2635
|
+
n4["D"]
|
|
2636
|
+
n5["B"]
|
|
2637
|
+
start --> n1
|
|
2638
|
+
start --> n3
|
|
2639
|
+
n1 --> n2
|
|
2640
|
+
n3 --> n4
|
|
2641
|
+
n3 --> n5
|
|
2642
|
+
```
|
|
2643
|
+
|
|
2644
|
+
Each box is labelled by the node's metadata `:title` (or `:label`) when present, otherwise by its `#inspect` — so constant-bound types show their constant name. Structural nodes (`>>`, `|`) shape the graph; every other type (steps, transforms, refinements, hashes, arrays, …) renders as a single opaque box. Recursive types (`#defer`) render as one box rather than recursing forever.
|
|
2645
|
+
|
|
2646
|
+
The direction is configurable, and the visitor can be used directly:
|
|
2647
|
+
|
|
2648
|
+
```ruby
|
|
2649
|
+
type.to_mermaid(direction: 'TB')
|
|
2650
|
+
Plumb::MermaidVisitor.call(type)
|
|
2651
|
+
```
|
|
2652
|
+
|
|
1829
2653
|
|
|
1830
2654
|
|
|
1831
2655
|
## TODO:
|
|
1832
2656
|
|
|
1833
2657
|
- [ ] benchmarks and performace. Compare with `Parametric`, `ActiveModel::Attributes`, `ActionController::StrongParameters`
|
|
1834
|
-
- [ ] flesh out `Plumb::Schema`
|
|
1835
2658
|
- [x] `Plumb::Struct`
|
|
1836
2659
|
- [x] flesh out and document `Plumb::Pipeline`
|
|
1837
2660
|
- [ ] document custom visitors
|
|
@@ -1845,7 +2668,7 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
|
|
|
1845
2668
|
|
|
1846
2669
|
## Contributing
|
|
1847
2670
|
|
|
1848
|
-
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).
|
|
1849
2672
|
|
|
1850
2673
|
## License
|
|
1851
2674
|
|