plumb 0.0.17 → 0.2.0.beta.1
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 +834 -63
- 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 +64 -19
- data/lib/plumb/attribute_value_match.rb +41 -1
- data/lib/plumb/attributes.rb +62 -20
- data/lib/plumb/codec.rb +795 -0
- data/lib/plumb/composable.rb +463 -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 +350 -38
- data/lib/plumb/hash_map.rb +62 -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 +25 -23
- 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 +32 -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 +27 -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
|
|
|
@@ -504,7 +613,6 @@ ten.parse(10) # => 10
|
|
|
504
613
|
ten.parse(100) # => 10
|
|
505
614
|
ten.parse('hello') # => 10
|
|
506
615
|
ten.parse() # => 10
|
|
507
|
-
ten.metadata[:type] # => Integer
|
|
508
616
|
```
|
|
509
617
|
|
|
510
618
|
Useful for data structures where some fields shouldn't change. Example:
|
|
@@ -517,12 +625,6 @@ CreateUserEvent = Types::Hash[
|
|
|
517
625
|
]
|
|
518
626
|
```
|
|
519
627
|
|
|
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
628
|
This usage is similar as using `Types::Static['hello']`directly.
|
|
527
629
|
|
|
528
630
|
This helper is shorthand for the following composition:
|
|
@@ -531,11 +633,11 @@ This helper is shorthand for the following composition:
|
|
|
531
633
|
Types::Static[value] >> step
|
|
532
634
|
```
|
|
533
635
|
|
|
534
|
-
|
|
636
|
+
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
637
|
|
|
536
638
|
```ruby
|
|
537
|
-
|
|
538
|
-
|
|
639
|
+
Types::Integer[100..].static(10) # raises Plumb::TypeError (10 is not within 100..)
|
|
640
|
+
type = Types::Integer[100..].static(150) # ok
|
|
539
641
|
```
|
|
540
642
|
|
|
541
643
|
So, normally you'd only use this attached to primitive types without further processing (but your use case may vary).
|
|
@@ -573,21 +675,109 @@ type.metadata[:description] # 'A long text'
|
|
|
573
675
|
`#metadata` combines keys from type compositions.
|
|
574
676
|
|
|
575
677
|
```ruby
|
|
576
|
-
type = Types::String.metadata(
|
|
678
|
+
type = Types::String[/@/].metadata(note: 'An email address') >> Types::String.metadata(description: 'A long text')
|
|
577
679
|
type.metadata[:description] # 'A long text'
|
|
578
680
|
type.metadata[:note] # 'An email address'
|
|
579
681
|
```
|
|
580
682
|
|
|
581
|
-
`#metadata`
|
|
683
|
+
`#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).
|
|
684
|
+
|
|
685
|
+
TODO: document custom visitors.
|
|
686
|
+
|
|
687
|
+
#### `#input_type` and `#output_type`
|
|
688
|
+
|
|
689
|
+
Every type exposes the type it expects as input and the type it produces as output.
|
|
690
|
+
|
|
691
|
+
```ruby
|
|
692
|
+
StringToInt = Types::String.transform(Integer, &:to_i)
|
|
693
|
+
StringToInt.input_type # Types::String
|
|
694
|
+
StringToInt.output_type # Integer
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
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`:
|
|
582
698
|
|
|
583
699
|
```ruby
|
|
584
|
-
Types::String.
|
|
585
|
-
Types::String
|
|
586
|
-
#
|
|
587
|
-
(Types::String
|
|
700
|
+
chain = Types::String.transform(Integer, &:to_i) >> Types::Integer.transform(Integer) { |i| i * 2 }
|
|
701
|
+
chain.input_type # Types::String — the chain can only be called with a String
|
|
702
|
+
chain.output_type # Integer — it can only produce an Integer
|
|
703
|
+
chain.children # [(Types::String -> Integer), (Types::Integer -> Integer)]
|
|
588
704
|
```
|
|
589
705
|
|
|
590
|
-
|
|
706
|
+
For a plain type, both are the type itself. Unions distribute over both sides:
|
|
707
|
+
|
|
708
|
+
```ruby
|
|
709
|
+
(Types::String | Types::Integer).input_type # Types::String | Types::Integer
|
|
710
|
+
(Types::String | Types::Integer).output_type # Types::String | Types::Integer
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
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.
|
|
714
|
+
|
|
715
|
+
#### Composition type-checks
|
|
716
|
+
|
|
717
|
+
`#>>` 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.
|
|
718
|
+
|
|
719
|
+
```ruby
|
|
720
|
+
Types::String >> Types::Integer # raises: String is not a subtype of Integer
|
|
721
|
+
Types::Numeric >> Types::Integer # raises: Numeric is broader than Integer
|
|
722
|
+
Types::Integer[0..40] >> Types::Integer[2..10] # raises: the left can emit values (0,1,11..40) the right rejects
|
|
723
|
+
|
|
724
|
+
Types::Integer >> Types::Numeric # ok: every Integer is a Numeric
|
|
725
|
+
Types::Integer[2..10] >> Types::Integer[0..40] # ok: 2..10 is within 0..40
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
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:
|
|
729
|
+
|
|
730
|
+
```ruby
|
|
731
|
+
Types::Integer[0..40][2..10] # narrow to 2..10 (runtime-checked)
|
|
732
|
+
Types::String.transform(Integer, &:to_i)[1..10] # convert, then bound the result
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
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"):
|
|
736
|
+
|
|
737
|
+
```ruby
|
|
738
|
+
Types::Integer / Types::Integer[2..10] # narrow without the build-time check
|
|
739
|
+
Types::String / Types::String[/@/] # a String you assert is an email downstream
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
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.)
|
|
743
|
+
|
|
744
|
+
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:
|
|
745
|
+
|
|
746
|
+
```ruby
|
|
747
|
+
# the consumer requires :age, but the producer never provides it:
|
|
748
|
+
Types::Hash[name: Types::String] >> Types::Hash[name: Types::String, age: Types::Integer]
|
|
749
|
+
# => Plumb::TypeError
|
|
750
|
+
|
|
751
|
+
# a shared key whose value type isn't a subtype:
|
|
752
|
+
Types::Hash[name: Types::String] >> Types::Hash[name: Types::Integer]
|
|
753
|
+
# => Plumb::TypeError
|
|
754
|
+
|
|
755
|
+
# ok — producer is a subtype of consumer (wider, with subtype values):
|
|
756
|
+
Types::Hash[name: Types::Integer, age: Types::Integer] >> Types::Hash[name: Types::Numeric]
|
|
757
|
+
```
|
|
758
|
+
|
|
759
|
+
[`#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:
|
|
760
|
+
|
|
761
|
+
```ruby
|
|
762
|
+
Types::Array.where(size: 10) >> Types::Array.where(size: 8..100) # ok: 10 is within 8..100
|
|
763
|
+
Types::Array.where(size: 10..15) >> Types::Array.where(size: 11..14) # raises: 10..15 isn't within 11..14
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
#### Subtype checks: `#<=` and `Plumb::Subtyping`
|
|
767
|
+
|
|
768
|
+
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):
|
|
769
|
+
|
|
770
|
+
```ruby
|
|
771
|
+
Types::Integer <= Types::Numeric # true
|
|
772
|
+
Types::Numeric <= Types::Integer # false
|
|
773
|
+
Types::String[/@/] <= Types::String # true (more refined => a subset)
|
|
774
|
+
Types::Integer <= Numeric # true (compares against a raw Ruby class)
|
|
775
|
+
Types::Array[Integer] <= Types::Array[Numeric] # true (covariant in the element type)
|
|
776
|
+
|
|
777
|
+
big = Types::Hash[name: Types::String, age: Types::Integer]
|
|
778
|
+
small = Types::Hash[name: Types::String]
|
|
779
|
+
big <= small # true (width + depth subtyping)
|
|
780
|
+
```
|
|
591
781
|
|
|
592
782
|
### Other policies
|
|
593
783
|
|
|
@@ -630,7 +820,7 @@ Wraps a step's execution, rescues a specific exception and returns an invalid re
|
|
|
630
820
|
|
|
631
821
|
Useful for turning a 3rd party library's exception into an invalid result that plays well with Plumb's type compositions.
|
|
632
822
|
|
|
633
|
-
Example:
|
|
823
|
+
Example: parsing strings with `Date.parse` and turning `Date::Error` exceptions into Plumb errors.
|
|
634
824
|
|
|
635
825
|
```ruby
|
|
636
826
|
# Accept a string that can be parsed into a Date
|
|
@@ -810,10 +1000,27 @@ StaffMember = User + Employee # Hash[:name, :age, :company]
|
|
|
810
1000
|
|
|
811
1001
|
#### Hash intersections
|
|
812
1002
|
|
|
813
|
-
Use `Types::Hash#&` to
|
|
1003
|
+
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
1004
|
|
|
815
1005
|
```ruby
|
|
816
|
-
|
|
1006
|
+
User & Employee # => Hash[name: String] (only the shared :name survives)
|
|
1007
|
+
|
|
1008
|
+
# shared keys have their value types intersected
|
|
1009
|
+
Types::Hash[age: Types::Integer[18..]] & Types::Hash[age: Types::Integer[..65]]
|
|
1010
|
+
# => Hash[age: Integer[18..65]]
|
|
1011
|
+
```
|
|
1012
|
+
|
|
1013
|
+
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:
|
|
1014
|
+
|
|
1015
|
+
```ruby
|
|
1016
|
+
Types::Hash[a: Types::Integer] & Types::Hash[b: Types::String] # => Types::Never
|
|
1017
|
+
```
|
|
1018
|
+
|
|
1019
|
+
A [`_` catch-all](#undeclared-keys-and-the-_-catch-all) widens what survives, since it admits the other side's extra keys:
|
|
1020
|
+
|
|
1021
|
+
```ruby
|
|
1022
|
+
Types::Hash[a: Types::String, _: Types::Any] & Types::Hash[a: Types::String, b: Types::Integer]
|
|
1023
|
+
# => Hash[a: String, b: Integer] (:b admitted via the left's catch-all)
|
|
817
1024
|
```
|
|
818
1025
|
|
|
819
1026
|
#### `Types::Hash#tagged_by`
|
|
@@ -833,18 +1040,41 @@ Events = Types::Hash.tagged_by(
|
|
|
833
1040
|
Events.parse(type: 'name_updated', name: 'Joe') # Uses NameUpdatedEvent definition
|
|
834
1041
|
```
|
|
835
1042
|
|
|
836
|
-
#### `
|
|
1043
|
+
#### Undeclared keys and the `_` catch-all
|
|
837
1044
|
|
|
838
|
-
|
|
1045
|
+
By default, keys present in the input but **not** declared in the schema are dropped:
|
|
839
1046
|
|
|
840
1047
|
```ruby
|
|
841
|
-
|
|
1048
|
+
Types::Hash[age: Types::Integer].parse(age: 30, name: 'Joe') # => { age: 30 } (:name dropped)
|
|
1049
|
+
```
|
|
1050
|
+
|
|
1051
|
+
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:
|
|
1052
|
+
|
|
1053
|
+
| Catch-all | Meaning | Undeclared key `name`… |
|
|
1054
|
+
| --- | --- | --- |
|
|
1055
|
+
| _(none)_ | drop (default) | is removed |
|
|
1056
|
+
| `_: Types::Any` | include, unchanged | is kept as-is |
|
|
1057
|
+
| `_: SomeType` | include, validated/coerced | must be a `SomeType` (coerced if the type coerces) |
|
|
1058
|
+
| `_: Types::Never` | exclude (strict) | is a validation **error** |
|
|
1059
|
+
|
|
1060
|
+
```ruby
|
|
1061
|
+
# _: Any — keep every undeclared key, unchanged
|
|
1062
|
+
hash = Types::Hash[age: Types::Lax::Integer, _: Types::Any]
|
|
1063
|
+
hash.parse(age: '30', name: 'Joe', last_name: 'Bloggs')
|
|
1064
|
+
# => { age: 30, name: 'Joe', last_name: 'Bloggs' }
|
|
1065
|
+
|
|
1066
|
+
# _: SomeType — every undeclared value must be (or coerce to) that type
|
|
1067
|
+
Types::Hash[id: Types::String, _: Types::Integer].parse(id: 'x', a: 1, b: 2)
|
|
1068
|
+
# => { id: 'x', a: 1, b: 2 }
|
|
1069
|
+
Types::Hash[id: Types::String, _: Types::Integer].resolve(id: 'x', a: 'nope').valid? # => false
|
|
842
1070
|
|
|
843
|
-
#
|
|
844
|
-
|
|
1071
|
+
# _: Never — reject any undeclared key (a closed/strict hash)
|
|
1072
|
+
strict = Types::Hash[a: Types::String, _: Types::Never]
|
|
1073
|
+
strict.resolve(a: 'x').valid? # => true
|
|
1074
|
+
strict.resolve(a: 'x', b: 1).valid? # => false (b is not allowed)
|
|
845
1075
|
```
|
|
846
1076
|
|
|
847
|
-
|
|
1077
|
+
`_: 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
1078
|
|
|
849
1079
|
```ruby
|
|
850
1080
|
# Front-end definition does structural validation
|
|
@@ -854,15 +1084,29 @@ Front = Types::Hash[price: Integer, name: String, category: String]
|
|
|
854
1084
|
IntToMoney = Types::Integer.build(Money)
|
|
855
1085
|
|
|
856
1086
|
# Backend definition turns :price into a Money object, leaves other keys as-is
|
|
857
|
-
Back = Types::Hash[price: IntToMoney]
|
|
1087
|
+
Back = Types::Hash[price: IntToMoney, _: Types::Any]
|
|
858
1088
|
|
|
859
1089
|
# Compose the pipeline
|
|
860
1090
|
InputHandler = Front >> Back
|
|
861
1091
|
|
|
862
1092
|
InputHandler.parse(price: 100_000, name: 'iPhone 15', category: 'smartphones')
|
|
863
|
-
# => { price: #<Money fractional:100000 currency:GBP>, name: 'iPhone 15', category: '
|
|
1093
|
+
# => { price: #<Money fractional:100000 currency:GBP>, name: 'iPhone 15', category: 'smartphones' }
|
|
864
1094
|
```
|
|
865
1095
|
|
|
1096
|
+
The catch-all also shows up in generated JSON Schema as `additionalProperties`: `_: Any` → `{}` (anything), `_: Integer` → `{ "type": "integer" }`, and `_: Never` → `{ "not": {} }` (nothing allowed).
|
|
1097
|
+
|
|
1098
|
+
#### Typed keys
|
|
1099
|
+
|
|
1100
|
+
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:
|
|
1101
|
+
|
|
1102
|
+
```ruby
|
|
1103
|
+
Types::Hash['name' => Types::String] # a String key
|
|
1104
|
+
Types::Hash[Types::String[/^id_/] => Types::Integer, # keys matching /^id_/ hold Integers
|
|
1105
|
+
_: Types::Any] # everything else passes through
|
|
1106
|
+
```
|
|
1107
|
+
|
|
1108
|
+
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".
|
|
1109
|
+
|
|
866
1110
|
#### `Types::Hash#filtered`
|
|
867
1111
|
|
|
868
1112
|
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 +1117,97 @@ User.parse(name: 'Joe', age: 40) # => { name: 'Joe', age: 40 }
|
|
|
873
1117
|
User.parse(name: 'Joe', age: 'nope') # => { name: 'Joe' }
|
|
874
1118
|
```
|
|
875
1119
|
|
|
1120
|
+
### `Types::Range`
|
|
1121
|
+
|
|
1122
|
+
`Types::Range` validates that a value is a Ruby `Range`. On its own it accepts any range:
|
|
1123
|
+
|
|
1124
|
+
```ruby
|
|
1125
|
+
Types::Range.resolve(1..10) # valid
|
|
1126
|
+
Types::Range.resolve('a'..'z') # valid
|
|
1127
|
+
Types::Range.resolve(5) # invalid ("must be a Range")
|
|
1128
|
+
```
|
|
1129
|
+
|
|
1130
|
+
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):
|
|
1131
|
+
|
|
1132
|
+
```ruby
|
|
1133
|
+
IntRange = Types::Range[Integer]
|
|
1134
|
+
IntRange.resolve(1..10) # valid
|
|
1135
|
+
IntRange.resolve('a'..'z') # invalid (endpoints aren't Integers)
|
|
1136
|
+
IntRange.resolve(1..) # valid (only the present bound is checked)
|
|
1137
|
+
```
|
|
1138
|
+
|
|
1139
|
+
The member type is any `#===` interface, so a `Range` itself works as the member matcher to bound where the endpoints may fall:
|
|
1140
|
+
|
|
1141
|
+
```ruby
|
|
1142
|
+
# A range whose endpoints both lie within 1..100
|
|
1143
|
+
Percent = Types::Range[1..100]
|
|
1144
|
+
Percent.resolve(10..20) # valid
|
|
1145
|
+
Percent.resolve(10..200) # invalid (200 is outside 1..100)
|
|
1146
|
+
```
|
|
1147
|
+
|
|
1148
|
+
#### Open-ended ranges with `#where`
|
|
1149
|
+
|
|
1150
|
+
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:
|
|
1151
|
+
|
|
1152
|
+
```ruby
|
|
1153
|
+
# Endless ranges only, eg. (1..)
|
|
1154
|
+
Endless = Types::Range[Integer].where(end: nil)
|
|
1155
|
+
Endless.resolve(1..) # valid
|
|
1156
|
+
Endless.resolve(1..10) # invalid ("must have attribute end === nil")
|
|
1157
|
+
|
|
1158
|
+
# Beginless ranges only, eg. (..10)
|
|
1159
|
+
Beginless = Types::Range[Integer].where(begin: nil)
|
|
1160
|
+
Beginless.resolve(..10) # valid
|
|
1161
|
+
Beginless.resolve(1..10) # invalid
|
|
1162
|
+
```
|
|
1163
|
+
|
|
1164
|
+
`#where` values are also full `#===` matchers, so an endpoint can be constrained by a type or another range:
|
|
1165
|
+
|
|
1166
|
+
```ruby
|
|
1167
|
+
# A range that starts at zero or above
|
|
1168
|
+
NonNegativeStart = Types::Range.where(begin: Types::Integer[0..])
|
|
1169
|
+
NonNegativeStart.resolve(5..10) # valid
|
|
1170
|
+
NonNegativeStart.resolve(-5..10) # invalid
|
|
1171
|
+
```
|
|
1172
|
+
|
|
1173
|
+
#### Composition
|
|
1174
|
+
|
|
1175
|
+
`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:
|
|
1176
|
+
|
|
1177
|
+
```ruby
|
|
1178
|
+
Types::Range[1..10] <= Types::Range[Integer] # true (covariant)
|
|
1179
|
+
|
|
1180
|
+
# The narrower branch is absorbed
|
|
1181
|
+
Types::Range[Integer] | Types::Range[1..10] # => Range[Integer]
|
|
1182
|
+
```
|
|
1183
|
+
|
|
1184
|
+
#### JSON Schema
|
|
1185
|
+
|
|
1186
|
+
A `Types::Range` whose member pins numeric bounds maps to JSON Schema's native keywords, preserving an exclusive end as `exclusiveMaximum`:
|
|
1187
|
+
|
|
1188
|
+
```ruby
|
|
1189
|
+
Plumb::JSONSchemaVisitor.call(Types::Range[0...100], root: false)
|
|
1190
|
+
# => { "type" => "integer", "minimum" => 0, "exclusiveMaximum" => 100 }
|
|
1191
|
+
```
|
|
1192
|
+
|
|
876
1193
|
### `Types::SymbolizedHash`
|
|
877
1194
|
|
|
878
1195
|
This type turns a hash's keys into symbols by calling `#to_sym` on them, and returning a new Hash.
|
|
879
1196
|
|
|
1197
|
+
`SymbolizedHash` is a `Symbol => Any` map. You can use it as a _transform_.
|
|
1198
|
+
|
|
880
1199
|
```ruby
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
type.parse('name' => 'Joe', 'age' => 20) # {name: 'Joe', age: 20}
|
|
1200
|
+
UserHash = Types::Hash[name: String]
|
|
1201
|
+
Types::SymbolizedHash.transform(UserHash).parse('name' => 'Joe') # { name: 'Joe' }
|
|
884
1202
|
```
|
|
885
1203
|
|
|
1204
|
+
You can also use the shortcut `#symbolized`
|
|
886
1205
|
|
|
1206
|
+
```ruby
|
|
1207
|
+
# Symbolize keys, then coerce into a typed Hash.
|
|
1208
|
+
type = Types::Hash[name: String, age: Integer].symbolized
|
|
1209
|
+
type.parse('name' => 'Joe', 'age' => 20) # {name: 'Joe', age: 20}
|
|
1210
|
+
```
|
|
887
1211
|
|
|
888
1212
|
### maps
|
|
889
1213
|
|
|
@@ -947,6 +1271,47 @@ emails = Types::Array[Types::String[/@/]]
|
|
|
947
1271
|
|
|
948
1272
|
Prefer the latter (`Types::Array[Types::String[/@/]]`), as that first validates that each element is a `String` before matching against the regular expression.
|
|
949
1273
|
|
|
1274
|
+
#### Chained array maps fuse into a single pass
|
|
1275
|
+
|
|
1276
|
+
`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:
|
|
1277
|
+
|
|
1278
|
+
```ruby
|
|
1279
|
+
Trim = Types::String.transform(::String, &:strip)
|
|
1280
|
+
Downcase = Types::String.transform(::String, &:downcase)
|
|
1281
|
+
Symbolize = Types::String.transform(::Symbol, &:to_sym)
|
|
1282
|
+
|
|
1283
|
+
# Written as three separate maps over the collection...
|
|
1284
|
+
Tags = Types::Array[Trim] >> Types::Array[Downcase] >> Types::Array[Symbolize]
|
|
1285
|
+
|
|
1286
|
+
# ...built as one.
|
|
1287
|
+
Tags.class # => Plumb::ArrayClass
|
|
1288
|
+
Tags.inspect # => "Array[(Types::String -> Symbol)]"
|
|
1289
|
+
Tags == Types::Array[Trim >> Downcase >> Symbolize] # => true
|
|
1290
|
+
|
|
1291
|
+
Tags.parse([' RUBY ', ' Plumb', 'CSV ']) # => [:ruby, :plumb, :csv]
|
|
1292
|
+
```
|
|
1293
|
+
|
|
1294
|
+
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.
|
|
1295
|
+
|
|
1296
|
+
Validation is unaffected. The JSON Schema still describes the input side, and errors are still keyed by element index:
|
|
1297
|
+
|
|
1298
|
+
```ruby
|
|
1299
|
+
Tags.to_json_schema # => {"type" => "array", "items" => {"type" => "string"}}
|
|
1300
|
+
Tags.resolve(['ok', 42, ' fine ']).errors # => {1 => "Must be a String"}
|
|
1301
|
+
```
|
|
1302
|
+
|
|
1303
|
+
`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:
|
|
1304
|
+
|
|
1305
|
+
```ruby
|
|
1306
|
+
# Narrowing isn't provable, so this stays two passes (and `#>>` would reject it outright).
|
|
1307
|
+
Types::Array[Trim] / Types::Array[Types::String[/^a/]]
|
|
1308
|
+
|
|
1309
|
+
# Different containers, so no functor law to apply.
|
|
1310
|
+
Types::Array[Trim] / Types::Stream[Symbolize]
|
|
1311
|
+
```
|
|
1312
|
+
|
|
1313
|
+
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.
|
|
1314
|
+
|
|
950
1315
|
#### Concurrent arrays
|
|
951
1316
|
|
|
952
1317
|
Use `Types::Array#concurrent` to process array elements concurrently (using Concurrent Ruby for now).
|
|
@@ -1323,7 +1688,7 @@ class DBConfig < Types::Data
|
|
|
1323
1688
|
end
|
|
1324
1689
|
|
|
1325
1690
|
class Config < Types::Data
|
|
1326
|
-
attribute :host,
|
|
1691
|
+
attribute :host, Plumb::Codec::HTTPURIEncoder, writer: true
|
|
1327
1692
|
attribute :port, Types::Integer.default(80), writer: true
|
|
1328
1693
|
|
|
1329
1694
|
# Nested structs can have writers too
|
|
@@ -1388,6 +1753,30 @@ result = CreateUser.resolve(name: 'Joe', age: 40)
|
|
|
1388
1753
|
# result.value => User
|
|
1389
1754
|
```
|
|
1390
1755
|
|
|
1756
|
+
##### `#step` (non-strict) and `#step!` (strict)
|
|
1757
|
+
|
|
1758
|
+
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.
|
|
1759
|
+
|
|
1760
|
+
```ruby
|
|
1761
|
+
pl.step Types::Hash # non-strict: a later step may narrow this
|
|
1762
|
+
pl.step! Types::Hash[name: Types::String] # strict: raises if it can't accept the prior output
|
|
1763
|
+
```
|
|
1764
|
+
|
|
1765
|
+
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:
|
|
1766
|
+
|
|
1767
|
+
```ruby
|
|
1768
|
+
User = Data.define(:name)
|
|
1769
|
+
|
|
1770
|
+
pipeline = Types::Any.pipeline do |pl|
|
|
1771
|
+
pl.step Types::Hash[name: Types::String]
|
|
1772
|
+
# output type + a block that produces it (the block takes and returns a Result)
|
|
1773
|
+
pl.step(User) { |result| result.valid(User.new(result.value[:name])) }
|
|
1774
|
+
end
|
|
1775
|
+
|
|
1776
|
+
pipeline.output_type == Plumb::Composable.wrap(User) # true
|
|
1777
|
+
pipeline.parse(name: 'Joe') # => #<data User name="Joe">
|
|
1778
|
+
```
|
|
1779
|
+
|
|
1391
1780
|
Pipelines are Plumb steps, so they can be composed further.
|
|
1392
1781
|
|
|
1393
1782
|
```ruby
|
|
@@ -1513,10 +1902,6 @@ Pipe2 = DebuggablePipeline.new do |pl|
|
|
|
1513
1902
|
end
|
|
1514
1903
|
```
|
|
1515
1904
|
|
|
1516
|
-
### Plumb::Schema
|
|
1517
|
-
|
|
1518
|
-
TODO
|
|
1519
|
-
|
|
1520
1905
|
### Recursive types
|
|
1521
1906
|
|
|
1522
1907
|
You can use a proc to defer evaluation of recursive definitions.
|
|
@@ -1550,6 +1935,169 @@ LinkedList = Types::Hash[
|
|
|
1550
1935
|
|
|
1551
1936
|
|
|
1552
1937
|
|
|
1938
|
+
### Encoders and Codecs
|
|
1939
|
+
|
|
1940
|
+
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.
|
|
1941
|
+
|
|
1942
|
+
#### Defining encoders
|
|
1943
|
+
|
|
1944
|
+
An encoder is a class declaring an input and an output type, with `#decode` (input ⇒ output) and `#encode` (output ⇒ input) methods:
|
|
1945
|
+
|
|
1946
|
+
```ruby
|
|
1947
|
+
DateRange = Types::Range[Types::Date]
|
|
1948
|
+
JSONDateRange = Types::Hash[from: Types::Date, to: Types::Date]
|
|
1949
|
+
|
|
1950
|
+
class JSONDateRangeEncoder < Plumb::Encoder[JSONDateRange => DateRange]
|
|
1951
|
+
def encode(range) = { from: range.begin, to: range.end }
|
|
1952
|
+
def decode(hash) = hash[:from]..hash[:to]
|
|
1953
|
+
end
|
|
1954
|
+
```
|
|
1955
|
+
|
|
1956
|
+
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.
|
|
1957
|
+
|
|
1958
|
+
```ruby
|
|
1959
|
+
# Decode: the declared direction.
|
|
1960
|
+
FromJSON = JSONDateRange >> JSONDateRangeEncoder >> DateRange
|
|
1961
|
+
FromJSON.parse({ from: Date.new(2024, 1, 1), to: Date.new(2024, 2, 1) }) # => Date..Date range
|
|
1962
|
+
|
|
1963
|
+
# Encode: inferred from the DateRange on the left.
|
|
1964
|
+
ToJSON = DateRange >> JSONDateRangeEncoder >> JSONDateRange
|
|
1965
|
+
ToJSON.parse(Date.new(2024, 1, 1)..Date.new(2024, 2, 1)) # => { from: ..., to: ... }
|
|
1966
|
+
```
|
|
1967
|
+
|
|
1968
|
+
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`.
|
|
1969
|
+
|
|
1970
|
+
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:
|
|
1971
|
+
|
|
1972
|
+
```ruby
|
|
1973
|
+
JSONDateRangeEncoder.decoding # JSONDateRange -> DateRange, runs #decode
|
|
1974
|
+
JSONDateRangeEncoder.encoding # DateRange -> JSONDateRange, runs #encode
|
|
1975
|
+
|
|
1976
|
+
JSONDateRangeEncoder.decode(from: Date.new(2024, 1, 1), to: Date.new(2024, 2, 1)) # => a Range
|
|
1977
|
+
JSONDateRangeEncoder.encode(Date.new(2024, 1, 1)..Date.new(2024, 2, 1)) # => a Hash
|
|
1978
|
+
```
|
|
1979
|
+
|
|
1980
|
+
Encoders also express lenient unions — `Types::Date | SomeDateEncoder` accepts a `Date` or decodes a string into one.
|
|
1981
|
+
|
|
1982
|
+
#### Codecs
|
|
1983
|
+
|
|
1984
|
+
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`:
|
|
1985
|
+
|
|
1986
|
+
```ruby
|
|
1987
|
+
# Plumb::Codec::JSON ships noops for String, Numeric, booleans, Nil and bare
|
|
1988
|
+
# Hash/Array, plus built-in string encoders for Dates, Times, URIs,
|
|
1989
|
+
# Symbols and Decimals.
|
|
1990
|
+
class JSONCodec < Plumb::Codec::JSON
|
|
1991
|
+
encoder JSONDateRangeEncoder
|
|
1992
|
+
end
|
|
1993
|
+
```
|
|
1994
|
+
|
|
1995
|
+
(A subclass encoder registered for an equivalent type — eg. your own Date encoder — takes precedence over an inherited built-in.)
|
|
1996
|
+
|
|
1997
|
+
Composing a codec with a type rewrites the type deeply, in either direction:
|
|
1998
|
+
|
|
1999
|
+
```ruby
|
|
2000
|
+
Person = Types::Hash[name: Types::String, dates: DateRange]
|
|
2001
|
+
|
|
2002
|
+
JSONPerson = JSONCodec >> Person # decode: JSON structures -> Person
|
|
2003
|
+
JSONPerson.parse({ name: 'Joe', dates: { from: '2024-01-01', to: '2024-02-01' } })
|
|
2004
|
+
# => { name: 'Joe', dates: Date(2024-01-01)..Date(2024-02-01) }
|
|
2005
|
+
|
|
2006
|
+
EncodedPerson = Person >> JSONCodec # encode: Person -> JSON structures
|
|
2007
|
+
EncodedPerson.parse({ name: 'Joe', dates: Date.new(2024, 1, 1)..Date.new(2024, 2, 1) })
|
|
2008
|
+
# => { name: 'Joe', dates: { from: '2024-01-01', to: '2024-02-01' } }
|
|
2009
|
+
```
|
|
2010
|
+
|
|
2011
|
+
`Codec.for(type)` returns both directions as a `[decoding, encoding]` pair:
|
|
2012
|
+
|
|
2013
|
+
```ruby
|
|
2014
|
+
decoder, encoder = JSONCodec.for(Person)
|
|
2015
|
+
decoder.parse(json_data) # => a Person hash
|
|
2016
|
+
encoder.parse(person_hash) # => JSON structures
|
|
2017
|
+
```
|
|
2018
|
+
|
|
2019
|
+
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.
|
|
2020
|
+
|
|
2021
|
+
Codecs work with any type, not just schemas:
|
|
2022
|
+
|
|
2023
|
+
```ruby
|
|
2024
|
+
(JSONCodec >> Types::Date).parse('2024-01-01') # => Date
|
|
2025
|
+
(Types::Date >> JSONCodec).parse(Date.new(2024, 1, 1)) # => '2024-01-01'
|
|
2026
|
+
JSONCodec >> Types::String # => Types::String, unchanged (noop)
|
|
2027
|
+
```
|
|
2028
|
+
|
|
2029
|
+
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:
|
|
2030
|
+
|
|
2031
|
+
```ruby
|
|
2032
|
+
class Company < Types::Data
|
|
2033
|
+
attribute :name, Types::String
|
|
2034
|
+
attribute :founded, Types::Date
|
|
2035
|
+
end
|
|
2036
|
+
|
|
2037
|
+
decoder, encoder = JSONCodec.for(Company)
|
|
2038
|
+
company = decoder.parse({ name: 'ACME', founded: '2024-01-01' }) # => #<Company founded: Date>
|
|
2039
|
+
encoder.parse(company) # => { name: 'ACME', founded: '2024-01-01' }
|
|
2040
|
+
```
|
|
2041
|
+
|
|
2042
|
+
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:
|
|
2043
|
+
|
|
2044
|
+
```ruby
|
|
2045
|
+
class ParseRecord
|
|
2046
|
+
extend Plumb::Implementation[Types::Hash[on: Types::Date] => Record]
|
|
2047
|
+
|
|
2048
|
+
def self._call(result) = result.valid(Record.new(result.value[:on]))
|
|
2049
|
+
end
|
|
2050
|
+
|
|
2051
|
+
(JSONCodec >> ParseRecord).parse({ on: '2024-01-01' }) # => #<Record on: Date>
|
|
2052
|
+
```
|
|
2053
|
+
|
|
2054
|
+
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.
|
|
2055
|
+
|
|
2056
|
+
A field that matches no encoder and no noop is a composition-time error naming the field path:
|
|
2057
|
+
|
|
2058
|
+
```ruby
|
|
2059
|
+
JSONCodec >> Types::Hash[profile: Types::Hash[joined: Types::Any[Time]]]
|
|
2060
|
+
# raises Plumb::TypeError: ... field `profile.joined` (Any[Time]) matches no encoder ...
|
|
2061
|
+
```
|
|
2062
|
+
|
|
2063
|
+
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:
|
|
2064
|
+
|
|
2065
|
+
```ruby
|
|
2066
|
+
JSONPerson.to_json_schema
|
|
2067
|
+
# "dates" is described as { "type" => "object", "properties" => { "from" => { "type" => "string" }, ... } }
|
|
2068
|
+
```
|
|
2069
|
+
|
|
2070
|
+
#### `Codec::Forms`: string-based formats
|
|
2071
|
+
|
|
2072
|
+
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`).
|
|
2073
|
+
|
|
2074
|
+
```ruby
|
|
2075
|
+
Config = Types::Hash[
|
|
2076
|
+
host: Types::URI::HTTP,
|
|
2077
|
+
port: Types::Integer,
|
|
2078
|
+
active: Types::Boolean,
|
|
2079
|
+
starts_on: Types::Date | Types::Nil
|
|
2080
|
+
]
|
|
2081
|
+
|
|
2082
|
+
decoder, encoder = Plumb::Codec::Forms.for(Config)
|
|
2083
|
+
decoder.parse({ host: 'http://example.com', port: '80', active: '1', starts_on: '' })
|
|
2084
|
+
# => { host: URI(...), port: 80, active: true, starts_on: nil }
|
|
2085
|
+
encoder.parse({ host: URI.parse('http://example.com'), port: 80, active: true, starts_on: nil })
|
|
2086
|
+
# => { host: 'http://example.com', port: '80', active: 'true', starts_on: '' }
|
|
2087
|
+
```
|
|
2088
|
+
|
|
2089
|
+
`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.
|
|
2090
|
+
|
|
2091
|
+
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`.
|
|
2092
|
+
|
|
2093
|
+
Things to know:
|
|
2094
|
+
|
|
2095
|
+
* Direction inference needs a typed neighbour. Opaque contexts fall back to the declared direction — use `.decoding`/`.encoding` to be explicit.
|
|
2096
|
+
* 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.
|
|
2097
|
+
* `.defer`red fields rewrite lazily, so an unmatched type inside one surfaces at first resolution rather than at composition.
|
|
2098
|
+
* 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.
|
|
2099
|
+
* 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`.
|
|
2100
|
+
|
|
1553
2101
|
### Custom types
|
|
1554
2102
|
|
|
1555
2103
|
Every Plumb type exposes the following one-method interface:
|
|
@@ -1564,25 +2112,62 @@ The `Result::Valid` class has helper methods `#valid(value) => Result::Valid` an
|
|
|
1564
2112
|
|
|
1565
2113
|
#### Compose procs or lambdas directly
|
|
1566
2114
|
|
|
1567
|
-
Piping any `#call` object onto Plumb types
|
|
2115
|
+
Piping any `#call` object onto Plumb types wraps your object in a composable step, with all methods necessary for further composition.
|
|
1568
2116
|
|
|
1569
2117
|
```ruby
|
|
1570
2118
|
Greeting = Types::String >> ->(result) { result.valid("Hello #{result.value}") }
|
|
1571
2119
|
```
|
|
1572
2120
|
|
|
1573
|
-
####
|
|
2121
|
+
#### `Plumb::Function[input => output]`
|
|
2122
|
+
|
|
2123
|
+
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.
|
|
2124
|
+
|
|
2125
|
+
```ruby
|
|
2126
|
+
Greeting = Plumb::Function[String => String] do |result|
|
|
2127
|
+
result.valid("Hello #{result.value}")
|
|
2128
|
+
end
|
|
2129
|
+
|
|
2130
|
+
Greeting.parse('Joe') # => 'Hello Joe'
|
|
2131
|
+
Greeting.parse(10) # raises Plumb::ParseError ("Must be a String")
|
|
2132
|
+
```
|
|
2133
|
+
|
|
2134
|
+
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:
|
|
2135
|
+
|
|
2136
|
+
```ruby
|
|
2137
|
+
Greeting = Plumb::Function[MyGreeter.new, String => String]
|
|
2138
|
+
```
|
|
2139
|
+
|
|
2140
|
+
Because both ends are declared, the resulting step takes part in [composition type checks](#composition-type-checks) and JSON Schema generation, just like `#transform`:
|
|
1574
2141
|
|
|
1575
|
-
|
|
2142
|
+
```ruby
|
|
2143
|
+
StringLength = Plumb::Function[String => Integer] { |result| result.valid(result.value.size) }
|
|
2144
|
+
StringLength.input_type # => String
|
|
2145
|
+
StringLength.output_type # => Integer
|
|
2146
|
+
|
|
2147
|
+
Types::Integer >> StringLength # raises Plumb::TypeError at build time
|
|
2148
|
+
```
|
|
2149
|
+
|
|
2150
|
+
You can also pass a custom `#call(Result) => Result` interface as the first argument, to turn a callable into a typed function.
|
|
1576
2151
|
|
|
1577
2152
|
```ruby
|
|
1578
|
-
|
|
2153
|
+
TypedGreeting = Plumb::Function[Greeting.new('Mr.'), String => String]
|
|
2154
|
+
TypedGreeting.parse('Joe') # "Mr. Joe"
|
|
2155
|
+
TypedGreeting.parse(10) # raises Plumb::ParseError
|
|
2156
|
+
```
|
|
2157
|
+
|
|
2158
|
+
|
|
2159
|
+
|
|
2160
|
+
Omit the types when the callable is genuinely untyped. Both ends default to `Types::Any`, and the step opts out of composition checks.
|
|
2161
|
+
|
|
2162
|
+
```ruby
|
|
2163
|
+
Greeting = Plumb::Function[] do |result|
|
|
1579
2164
|
result.valid("Hello #{result.value}")
|
|
1580
2165
|
end
|
|
1581
2166
|
```
|
|
1582
2167
|
|
|
1583
|
-
Note that this example
|
|
2168
|
+
Note that this last example doesn't validate that the input is indeed a String, whereas `Plumb::Function[String => String]` does.
|
|
1584
2169
|
|
|
1585
|
-
|
|
2170
|
+
Either way, `Greeting` is a full Plumb step, which comes with all the Plumb methods and policies.
|
|
1586
2171
|
|
|
1587
2172
|
```ruby
|
|
1588
2173
|
# Greeting responds to #>>, #|, #default, #transform, etc etc
|
|
@@ -1599,11 +2184,11 @@ class Greeting
|
|
|
1599
2184
|
@gr = gr
|
|
1600
2185
|
end
|
|
1601
2186
|
|
|
1602
|
-
# The Plumb
|
|
2187
|
+
# The Plumb step interface
|
|
1603
2188
|
# @param result [Plumb::Result::Valid]
|
|
1604
2189
|
# @return [Plumb::Result::Valid, Plumb::Result::Invalid]
|
|
1605
2190
|
def call(result)
|
|
1606
|
-
result.valid("#{gr} #{result.value}")
|
|
2191
|
+
result.valid("#{@gr} #{result.value}")
|
|
1607
2192
|
end
|
|
1608
2193
|
end
|
|
1609
2194
|
|
|
@@ -1614,7 +2199,7 @@ This is useful when you want to parameterize your custom steps, for example by i
|
|
|
1614
2199
|
|
|
1615
2200
|
#### Include `Plumb::Composable` to make instance of a class full "steps"
|
|
1616
2201
|
|
|
1617
|
-
The class above will be wrapped
|
|
2202
|
+
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
2203
|
|
|
1619
2204
|
Including `Plumb::Composable` makes it support all Plumb methods directly.
|
|
1620
2205
|
|
|
@@ -1628,9 +2213,9 @@ class Greeting
|
|
|
1628
2213
|
@gr = gr
|
|
1629
2214
|
end
|
|
1630
2215
|
|
|
1631
|
-
# The
|
|
2216
|
+
# The step interface
|
|
1632
2217
|
def call(result)
|
|
1633
|
-
result.valid("#{gr} #{result.value}")
|
|
2218
|
+
result.valid("#{@gr} #{result.value}")
|
|
1634
2219
|
end
|
|
1635
2220
|
|
|
1636
2221
|
# This is optional, but it allows you to control your object's #inspect
|
|
@@ -1650,15 +2235,149 @@ LoudGreeting = Greeting.new('Hola').default('no greeting').invoke(:upcase)
|
|
|
1650
2235
|
class User
|
|
1651
2236
|
extend Composable
|
|
1652
2237
|
|
|
1653
|
-
def self.
|
|
2238
|
+
def self.call(result)
|
|
1654
2239
|
# do something here. Perhaps returning a Result with an instance of this class
|
|
1655
|
-
|
|
2240
|
+
result.valid(new)
|
|
1656
2241
|
end
|
|
1657
2242
|
end
|
|
1658
2243
|
```
|
|
1659
2244
|
|
|
1660
2245
|
This is how [Plumb::Types::Data](#typesdata) is implemented.
|
|
1661
2246
|
|
|
2247
|
+
#### Include `Plumb::Implementation[input => output]` to declare a class' types
|
|
2248
|
+
|
|
2249
|
+
`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.
|
|
2250
|
+
|
|
2251
|
+
`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`.
|
|
2252
|
+
|
|
2253
|
+
The mixin owns the public `#call`, which runs the declared checks around your `#_call`:
|
|
2254
|
+
|
|
2255
|
+
```
|
|
2256
|
+
result.map(input_type).map(_call).map(output_type)
|
|
2257
|
+
```
|
|
2258
|
+
|
|
2259
|
+
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.
|
|
2260
|
+
|
|
2261
|
+
```ruby
|
|
2262
|
+
class UserFinder
|
|
2263
|
+
include Plumb::Implementation[Types::UUID::V4 => User]
|
|
2264
|
+
|
|
2265
|
+
def initialize(user_scope)
|
|
2266
|
+
@user_scope = user_scope
|
|
2267
|
+
end
|
|
2268
|
+
|
|
2269
|
+
private def _call(result)
|
|
2270
|
+
user = User.where(level: @user_scope).find_by(id: result.value)
|
|
2271
|
+
return result.invalid(errors: 'no user!') unless user
|
|
2272
|
+
|
|
2273
|
+
result.valid(user)
|
|
2274
|
+
end
|
|
2275
|
+
end
|
|
2276
|
+
```
|
|
2277
|
+
|
|
2278
|
+
Instances are now fully typed steps:
|
|
2279
|
+
|
|
2280
|
+
```ruby
|
|
2281
|
+
finder = UserFinder.new('admin')
|
|
2282
|
+
|
|
2283
|
+
finder.parse(some_uuid) # => a User. Raises Plumb::ParseError unless the input is a UUID
|
|
2284
|
+
finder >> some_other_step # composition, type-checked at build time
|
|
2285
|
+
Types::UUID::V4 >> finder # ...on both sides
|
|
2286
|
+
Types::Integer >> finder # => Plumb::TypeError: Integer is not a subtype of UUID::V4
|
|
2287
|
+
|
|
2288
|
+
finder <= User # => true. Like a Function, it is identified by what it PRODUCES
|
|
2289
|
+
finder.to_json_schema # describes the INPUT side, like any other conversion
|
|
2290
|
+
|
|
2291
|
+
Types::Hash[user: finder] # use it anywhere a type is expected
|
|
2292
|
+
```
|
|
2293
|
+
|
|
2294
|
+
Both sides are wrapped with `Plumb::Composable.wrap`, so raw Ruby classes and hash literals work too: `Plumb::Implementation[{id: Types::String} => User]`.
|
|
2295
|
+
|
|
2296
|
+
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.
|
|
2297
|
+
|
|
2298
|
+
`include Plumb::Implementation` with no pair declares `Any => Any` — the opaque case, equivalent to `Plumb::Function.opaque`.
|
|
2299
|
+
|
|
2300
|
+
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.
|
|
2301
|
+
|
|
2302
|
+
```ruby
|
|
2303
|
+
class AdminFinder < UserFinder
|
|
2304
|
+
# input already validated as a UUID; the User you return is still checked
|
|
2305
|
+
private def _call(result) = result.valid(super.value.becomes(Admin))
|
|
2306
|
+
end
|
|
2307
|
+
```
|
|
2308
|
+
|
|
2309
|
+
#### Extend `Plumb::Implementation[input => output]` to make the class itself a typed step
|
|
2310
|
+
|
|
2311
|
+
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`.
|
|
2312
|
+
|
|
2313
|
+
```ruby
|
|
2314
|
+
class ParseUUID
|
|
2315
|
+
extend Plumb::Implementation[Types::String => Types::UUID::V4]
|
|
2316
|
+
|
|
2317
|
+
def self._call(result) = result.valid(result.value.downcase)
|
|
2318
|
+
end
|
|
2319
|
+
|
|
2320
|
+
ParseUUID.parse('E1D3...') # the class IS the step
|
|
2321
|
+
ParseUUID >> UserFinder.new('admin') # composes like any other type
|
|
2322
|
+
Types::Hash[id: ParseUUID]
|
|
2323
|
+
ParseUUID.to_json_schema
|
|
2324
|
+
```
|
|
2325
|
+
|
|
2326
|
+
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:
|
|
2327
|
+
|
|
2328
|
+
```ruby
|
|
2329
|
+
Plumb::Subtyping.subtype?(ParseUUID, Types::String) # => true
|
|
2330
|
+
```
|
|
2331
|
+
|
|
2332
|
+
#### Participating in subtype & composition checks
|
|
2333
|
+
|
|
2334
|
+
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.
|
|
2335
|
+
|
|
2336
|
+
The default leans on two methods your type already has:
|
|
2337
|
+
|
|
2338
|
+
- `#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.
|
|
2339
|
+
- `#==` — structural equality (provided by `Plumb::Composable`).
|
|
2340
|
+
|
|
2341
|
+
##### The hook
|
|
2342
|
+
|
|
2343
|
+
| Hook | Returns | Used by | Default |
|
|
2344
|
+
| --- | --- | --- | --- |
|
|
2345
|
+
| `#subtype_of?(other)` | `Boolean` | `#<=`, `Plumb::Subtyping.subtype?`, and so `#>>` | reflexive · atomic · same-class covariant `#children` |
|
|
2346
|
+
|
|
2347
|
+
`#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.
|
|
2348
|
+
|
|
2349
|
+
```ruby
|
|
2350
|
+
# An "even integer" refinement that knows it is a subtype of Integer (and
|
|
2351
|
+
# therefore Numeric), and defers everything else to the default.
|
|
2352
|
+
class EvenInteger
|
|
2353
|
+
include Plumb::Composable
|
|
2354
|
+
|
|
2355
|
+
def call(result)
|
|
2356
|
+
result.value.is_a?(::Integer) && result.value.even? ? result : result.invalid(errors: 'must be even')
|
|
2357
|
+
end
|
|
2358
|
+
|
|
2359
|
+
def subtype_of?(other)
|
|
2360
|
+
Plumb::Subtyping.subtype?(Types::Integer, other) || super
|
|
2361
|
+
end
|
|
2362
|
+
|
|
2363
|
+
private def _inspect = 'EvenInteger'
|
|
2364
|
+
end
|
|
2365
|
+
|
|
2366
|
+
even = EvenInteger.new
|
|
2367
|
+
even <= Types::Integer # => true
|
|
2368
|
+
even <= Types::Numeric # => true
|
|
2369
|
+
even <= Types::String # => false
|
|
2370
|
+
```
|
|
2371
|
+
|
|
2372
|
+
##### Type flow: `#input_type` / `#output_type`
|
|
2373
|
+
|
|
2374
|
+
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:
|
|
2375
|
+
|
|
2376
|
+
- a value-converting step declares a different `#output_type` (what `#transform`/`#build` do via `Plumb::Function`);
|
|
2377
|
+
- an opaque step (a wrapped proc, a generator) returns `Types::Any` for both, opting out of the `#>>` compatibility check.
|
|
2378
|
+
|
|
2379
|
+
Custom types are **values/leaves** in the algebra — you compose them with the built-in combinators (`#>>`, `#|`, `#transform`, `Types::Any`) rather than re-implementing those.
|
|
2380
|
+
|
|
1662
2381
|
### Custom policies
|
|
1663
2382
|
|
|
1664
2383
|
`Plumb.policy` can be used to encapsulate common type compositions, or compositions that can be configurable by parameters.
|
|
@@ -1709,7 +2428,7 @@ end
|
|
|
1709
2428
|
|
|
1710
2429
|
# Usage: annotate fields in a schema
|
|
1711
2430
|
AccountName = Types::String.admin
|
|
1712
|
-
AccountName.metadata # => {
|
|
2431
|
+
AccountName.metadata # => { admin: true }
|
|
1713
2432
|
```
|
|
1714
2433
|
|
|
1715
2434
|
#### Type-specific policies
|
|
@@ -1767,6 +2486,13 @@ Plumb.policy :split, SplitPolicy
|
|
|
1767
2486
|
|
|
1768
2487
|
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
2488
|
|
|
2489
|
+
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:
|
|
2490
|
+
|
|
2491
|
+
```ruby
|
|
2492
|
+
# Accepts a String, coerces it to an Integer
|
|
2493
|
+
Types::String.transform(Integer, &:to_i).to_json_schema # => { "type" => "string" }
|
|
2494
|
+
```
|
|
2495
|
+
|
|
1770
2496
|
```ruby
|
|
1771
2497
|
Payload = Types::Hash[name: String]
|
|
1772
2498
|
Payload.to_json_schema(root: true)
|
|
@@ -1826,12 +2552,57 @@ Types::DateTime.to_json_schema
|
|
|
1826
2552
|
# {"type"=>"string", "format"=>"date-time"}
|
|
1827
2553
|
```
|
|
1828
2554
|
|
|
2555
|
+
##### Node names for compositions
|
|
2556
|
+
|
|
2557
|
+
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):
|
|
2558
|
+
|
|
2559
|
+
| Node name | Built by | Meaning |
|
|
2560
|
+
| --------------- | --------------------------------- | ----------------------------------------------------------- |
|
|
2561
|
+
| `:and` | `#>>` with a converting side | Sequential composition — consumes the left's input, produces the right's output |
|
|
2562
|
+
| `:intersection` | `#>>`, `#/`, `#where`, `#check`, `#&` | The meet — both sides constrain the *same* value |
|
|
2563
|
+
| `:or` | `#\|` with a converting branch | Left-biased choice — a branch may coerce, so the ends differ |
|
|
2564
|
+
| `:union` | `#\|` with value-preserving branches | The join — a plain set of alternatives |
|
|
2565
|
+
|
|
2566
|
+
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.
|
|
2567
|
+
|
|
2568
|
+
### Mermaid diagrams
|
|
2569
|
+
|
|
2570
|
+
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).
|
|
2571
|
+
|
|
2572
|
+
```ruby
|
|
2573
|
+
type = (A >> B) | (C >> (D | B))
|
|
2574
|
+
puts type.to_mermaid
|
|
2575
|
+
```
|
|
2576
|
+
|
|
2577
|
+
```mermaid
|
|
2578
|
+
flowchart LR
|
|
2579
|
+
start(( ))
|
|
2580
|
+
n1["A"]
|
|
2581
|
+
n2["B"]
|
|
2582
|
+
n3["C"]
|
|
2583
|
+
n4["D"]
|
|
2584
|
+
n5["B"]
|
|
2585
|
+
start --> n1
|
|
2586
|
+
start --> n3
|
|
2587
|
+
n1 --> n2
|
|
2588
|
+
n3 --> n4
|
|
2589
|
+
n3 --> n5
|
|
2590
|
+
```
|
|
2591
|
+
|
|
2592
|
+
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.
|
|
2593
|
+
|
|
2594
|
+
The direction is configurable, and the visitor can be used directly:
|
|
2595
|
+
|
|
2596
|
+
```ruby
|
|
2597
|
+
type.to_mermaid(direction: 'TB')
|
|
2598
|
+
Plumb::MermaidVisitor.call(type)
|
|
2599
|
+
```
|
|
2600
|
+
|
|
1829
2601
|
|
|
1830
2602
|
|
|
1831
2603
|
## TODO:
|
|
1832
2604
|
|
|
1833
2605
|
- [ ] benchmarks and performace. Compare with `Parametric`, `ActiveModel::Attributes`, `ActionController::StrongParameters`
|
|
1834
|
-
- [ ] flesh out `Plumb::Schema`
|
|
1835
2606
|
- [x] `Plumb::Struct`
|
|
1836
2607
|
- [x] flesh out and document `Plumb::Pipeline`
|
|
1837
2608
|
- [ ] document custom visitors
|