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
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plumb
|
|
4
|
+
# Process-level memoization for computed type graphs (resolved input/output
|
|
5
|
+
# and accepted types — see Plumb::Subtyping). Keyed by type identity in
|
|
6
|
+
# WeakKeyMaps, so entries are pruned by GC together with their types. Only
|
|
7
|
+
# frozen types are cached: a mutable type (an unfrozen Pipeline, a Deferred)
|
|
8
|
+
# may change its answer over time, so it always recomputes.
|
|
9
|
+
module TypeCache
|
|
10
|
+
MAPS = {
|
|
11
|
+
resolved_input: ObjectSpace::WeakKeyMap.new,
|
|
12
|
+
resolved_output: ObjectSpace::WeakKeyMap.new,
|
|
13
|
+
accepted_type: ObjectSpace::WeakKeyMap.new,
|
|
14
|
+
value_preserving: ObjectSpace::WeakKeyMap.new
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
LOCK = Mutex.new
|
|
18
|
+
|
|
19
|
+
# Sentinel distinguishing "no entry" from a cached `false`/`nil` — WeakKeyMap#[]
|
|
20
|
+
# returns nil for both, so a truthiness check would never cache a falsy value
|
|
21
|
+
# (e.g. #value_preserving? is a boolean, false for every transform/container).
|
|
22
|
+
ABSENT = Object.new.freeze
|
|
23
|
+
private_constant :ABSENT
|
|
24
|
+
|
|
25
|
+
# Presence-based so cached `false`/`nil` are returned as hits, not recomputed.
|
|
26
|
+
#
|
|
27
|
+
# NOTE: compute OUTSIDE the lock — computations recurse back into #fetch
|
|
28
|
+
# (And/Or children, resolved_* chains) and a non-reentrant Mutex would
|
|
29
|
+
# deadlock. A race may compute the same pure value twice; the first write wins.
|
|
30
|
+
def self.fetch(slot, key)
|
|
31
|
+
return yield unless key.frozen?
|
|
32
|
+
|
|
33
|
+
map = MAPS.fetch(slot)
|
|
34
|
+
cached = LOCK.synchronize { map.key?(key) ? map[key] : ABSENT }
|
|
35
|
+
return cached unless cached.equal?(ABSENT)
|
|
36
|
+
|
|
37
|
+
value = yield
|
|
38
|
+
LOCK.synchronize { map.key?(key) ? map[key] : (map[key] = value) }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/plumb/type_registry.rb
CHANGED
|
@@ -1,7 +1,67 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Plumb
|
|
4
|
+
# Mix-in that turns a module into a namespace of named Plumb types: every
|
|
5
|
+
# {Composable} assigned to one of its constants is named after that constant,
|
|
6
|
+
# so it inspects under the module's path rather than as an anonymous type.
|
|
7
|
+
#
|
|
8
|
+
# There are two ways to attach it, and they cover different lifecycles:
|
|
9
|
+
#
|
|
10
|
+
# ## `extend TypeRegistry` — name a module's own constants
|
|
11
|
+
#
|
|
12
|
+
# Extend a module to make it a type registry. From that point, {#const_added}
|
|
13
|
+
# fires for each constant assigned on it and names the type in place. Nested
|
|
14
|
+
# modules are extended automatically, so naming continues down the tree.
|
|
15
|
+
#
|
|
16
|
+
# @example Defining your own registry
|
|
17
|
+
# module MyTypes
|
|
18
|
+
# extend Plumb::TypeRegistry
|
|
19
|
+
#
|
|
20
|
+
# # `String | Integer` builds an anonymous Or; assigning it names it.
|
|
21
|
+
# Name = Plumb::Types::String.present
|
|
22
|
+
# Score = Plumb::Types::Integer[0..100]
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# MyTypes::Name.inspect # => "MyTypes::Name"
|
|
26
|
+
# MyTypes::Score.inspect # => "MyTypes::Score"
|
|
27
|
+
#
|
|
28
|
+
# ## `include <a registry>` — re-home its types into your namespace
|
|
29
|
+
#
|
|
30
|
+
# Including a module that is already a registry triggers {#included}, which
|
|
31
|
+
# extends the host (so its future constants are named too) and copies the
|
|
32
|
+
# source's predefined types into the host under the host's own namespace.
|
|
33
|
+
# Composable types are `dup`ed so the shared originals are left untouched;
|
|
34
|
+
# nested namespace modules are rebuilt recursively.
|
|
35
|
+
#
|
|
36
|
+
# @example Inheriting an existing registry's types
|
|
37
|
+
# module MyTypes
|
|
38
|
+
# include Plumb::Types # Plumb::Types is itself a registry (see below)
|
|
39
|
+
#
|
|
40
|
+
# # `String`/`Integer` now resolve to MyTypes' own copies.
|
|
41
|
+
# Foo = String | Integer
|
|
42
|
+
# end
|
|
43
|
+
#
|
|
44
|
+
# MyTypes::String.inspect # => "MyTypes::String" (a copy, host-namespaced)
|
|
45
|
+
# MyTypes::Foo.inspect # => "MyTypes::Foo"
|
|
46
|
+
#
|
|
47
|
+
# {Plumb::Types} is the canonical registry: it does `extend TypeRegistry` so
|
|
48
|
+
# all its built-in types (`String`, `Integer`, `Lax::*`, …) are named, and
|
|
49
|
+
# so `include Plumb::Types` re-homes them into your own module.
|
|
50
|
+
#
|
|
51
|
+
# @note {#const_added} names the assigned instance *in place*. Assigning a
|
|
52
|
+
# type that composes/derives another (e.g. `Types::Integer[18..]`,
|
|
53
|
+
# `Types::String.present`) builds a fresh instance, so only the new
|
|
54
|
+
# constant is named. Aliasing an existing type directly
|
|
55
|
+
# (`Age = Types::Integer`) assigns the same shared instance and renames
|
|
56
|
+
# the original — assign a derived copy if you want an independent name.
|
|
4
57
|
module TypeRegistry
|
|
58
|
+
# `Module#const_added` hook (Ruby >= 3.2). Fires whenever a constant is
|
|
59
|
+
# assigned on a host extended with this module. Names the assigned type
|
|
60
|
+
# instance after the constant, and extends nested modules so the naming
|
|
61
|
+
# continues down the tree.
|
|
62
|
+
#
|
|
63
|
+
# @param const_name [Symbol] the name of the just-assigned constant
|
|
64
|
+
# @return [void]
|
|
5
65
|
def const_added(const_name)
|
|
6
66
|
obj = const_get(const_name)
|
|
7
67
|
case obj
|
|
@@ -13,6 +73,17 @@ module Plumb
|
|
|
13
73
|
end
|
|
14
74
|
end
|
|
15
75
|
|
|
76
|
+
# `Module#included` hook. Fires when a module carrying this registry (e.g.
|
|
77
|
+
# {Plumb::Types}) is included into a host. Copies every predefined type
|
|
78
|
+
# from the source into the host under the host's namespace, and extends the
|
|
79
|
+
# host so that constants assigned afterwards are named via {#const_added}.
|
|
80
|
+
#
|
|
81
|
+
# Composable types are `dup`ed before renaming so the shared originals are
|
|
82
|
+
# left untouched; nested namespace modules are rebuilt so their own
|
|
83
|
+
# constants are recursively re-homed under the host.
|
|
84
|
+
#
|
|
85
|
+
# @param host [Module] the module or class including the source
|
|
86
|
+
# @return [void]
|
|
16
87
|
def included(host)
|
|
17
88
|
host.extend TypeRegistry
|
|
18
89
|
constants(false).each do |const_name|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plumb
|
|
4
|
+
# A TYPED STEP declares an #input_type, an #output_type and a `Result => Result`
|
|
5
|
+
# core, and its #call runs that core between the two checks.
|
|
6
|
+
#
|
|
7
|
+
# Two implementations exist, differing only in WHO OWNS THE STORAGE:
|
|
8
|
+
#
|
|
9
|
+
# - {Plumb::Function} — Plumb owns the object, so the declared pair and the
|
|
10
|
+
# callable are ivars behind plain readers.
|
|
11
|
+
# - {Plumb::Implementation::TypeInterface} — the HOST owns its object and its
|
|
12
|
+
# constructor, so the pair comes from the declaration and the core is reached
|
|
13
|
+
# through the host's private #_call.
|
|
14
|
+
#
|
|
15
|
+
# This module holds only what follows from the DECLARATION, and is therefore
|
|
16
|
+
# identical either way. Anything that depends on the storage (#call, #children,
|
|
17
|
+
# #fn, #identity, #==) stays with each implementation, as does #standard_call?.
|
|
18
|
+
#
|
|
19
|
+
# Include AFTER Composable, whose #subtype_identity / #accepted_type defaults
|
|
20
|
+
# these replace.
|
|
21
|
+
module TypedStep
|
|
22
|
+
# Declaring no types at all: both ends are Any (top), so neither check can fail
|
|
23
|
+
# and neither is worth dropping. A statement about the declared types only — a node
|
|
24
|
+
# that WRAPS a caller's callable says so through Function#wraps_callable?, since a
|
|
25
|
+
# wrapper that has absorbed a neighbouring type into a slot is not opaque.
|
|
26
|
+
def opaque? = input_type == Types::Any && output_type == Types::Any
|
|
27
|
+
|
|
28
|
+
# A conversion is identified for subtyping by what it PRODUCES — the input
|
|
29
|
+
# constraints do not carry through to the output.
|
|
30
|
+
# @see Plumb::Subtyping.subtype?
|
|
31
|
+
def subtype_identity = output_type
|
|
32
|
+
|
|
33
|
+
# As a `left >> self` consumer, a step accepts what its INPUT type accepts —
|
|
34
|
+
# not the input node verbatim. When that input is a Hash/container whose fields
|
|
35
|
+
# are themselves converting (eg. a codec's decode schema, whose `flags` field is
|
|
36
|
+
# a `String -> Integer` step), the accepted type is the input relaxed per field
|
|
37
|
+
# to the type it consumes. This is what lets an encode pipeline compose with the
|
|
38
|
+
# matching decode pipeline (`encode >> decode`): both meet at the same input
|
|
39
|
+
# type. Mirrors HashClass#accepted_type.
|
|
40
|
+
def accepted_type = Plumb::Subtyping.accepted_type(input_type)
|
|
41
|
+
|
|
42
|
+
# Does #call end by validating against #output_type? True for the plain
|
|
43
|
+
# `input -> fn -> output` mapping; false only where that check is provably
|
|
44
|
+
# redundant (see GuaranteedFunction). This is what tells Function#fuse_with
|
|
45
|
+
# whether a seam has an output check to drop, and whether the node it builds
|
|
46
|
+
# needs to keep one — replacing two `is_a?(GuaranteedFunction)` tests, which
|
|
47
|
+
# gave a non-Function step the right answer only by default.
|
|
48
|
+
#
|
|
49
|
+
# Defaults to true, the fail-safe direction: a wrong `true` keeps a redundant
|
|
50
|
+
# check (slower, still correct), a wrong `false` drops a necessary one.
|
|
51
|
+
def checks_output? = true
|
|
52
|
+
|
|
53
|
+
# Can Function#fuse_with drop this node's boundary checks? Only when #call is
|
|
54
|
+
# the canonical mapping for its kind — a node that replaced #call runs
|
|
55
|
+
# different checks, and nothing can be assumed about which of them a seam
|
|
56
|
+
# removes. Excluded when OPAQUE too: the only checks dropped would be no-ops,
|
|
57
|
+
# so there is nothing to win.
|
|
58
|
+
def fusable_step? = !opaque? && standard_call?
|
|
59
|
+
|
|
60
|
+
# Is #call still the canonical mapping described above? Only the node can
|
|
61
|
+
# answer, and each implementation answers against its own.
|
|
62
|
+
# @return [Boolean]
|
|
63
|
+
def standard_call?
|
|
64
|
+
raise NotImplementedError, "#{self.class} must define #standard_call?"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/plumb/types.rb
CHANGED
|
@@ -19,11 +19,20 @@ module Plumb
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
# Generic options policy for all other types.
|
|
22
|
+
# An Array argument is treated as an enumeration of allowed values.
|
|
23
|
+
# Any other argument (Range, Regexp, Class, etc.) is delegated to a Match
|
|
24
|
+
# constraint, ie `type[opts]`, so it composes with the rest of the library
|
|
25
|
+
# (eg. a Range serializes to JSON Schema as `minimum`/`maximum`).
|
|
22
26
|
# Usage:
|
|
23
27
|
# type = Types::String.options(['a', 'b'])
|
|
28
|
+
# type = Types::Integer.options(10..20)
|
|
24
29
|
policy :options do |type, opts|
|
|
25
|
-
|
|
26
|
-
opts.
|
|
30
|
+
if opts.is_a?(::Array)
|
|
31
|
+
type.check("must be included in #{opts.inspect}") do |v|
|
|
32
|
+
opts.include?(v)
|
|
33
|
+
end
|
|
34
|
+
else
|
|
35
|
+
type[opts]
|
|
27
36
|
end
|
|
28
37
|
end
|
|
29
38
|
|
|
@@ -87,9 +96,16 @@ module Plumb
|
|
|
87
96
|
# Works with a block too:
|
|
88
97
|
# date = Type::Any[Date].default { Date.today }
|
|
89
98
|
#
|
|
99
|
+
# A generated default declares, and is checked against, `type`'s OUTPUT — not `type`
|
|
100
|
+
# itself, which would re-run a conversion on a value the block already produced
|
|
101
|
+
# (`Types::String.transform(::Integer).default { 10 }` generates the Integer).
|
|
102
|
+
# Declaring it also tells the rest of the library what fills this position: without
|
|
103
|
+
# it the branch resolves to `Any`, and a Codec has nothing to encode.
|
|
90
104
|
policy :default, helper: true do |type, value = Undefined, &block|
|
|
91
105
|
val_type = if value == Undefined
|
|
92
|
-
|
|
106
|
+
fn = ->(result) { result.valid(block.call) }
|
|
107
|
+
Function.new(Types::Any, Plumb::Subtyping.resolved_output(type), fn,
|
|
108
|
+
inspect: 'default proc', identity: [:default, block])
|
|
93
109
|
else
|
|
94
110
|
Types::Static[value]
|
|
95
111
|
end
|
|
@@ -101,12 +117,20 @@ module Plumb
|
|
|
101
117
|
# Expect a specific exception class, and return an invalid result if it is raised.
|
|
102
118
|
# Usage:
|
|
103
119
|
# type = Types::String.build(Date, :parse).policy(:rescue, Date::Error)
|
|
120
|
+
#
|
|
121
|
+
# The guard returns what `type` returned, so it declares `type`'s OUTPUT — opaque,
|
|
122
|
+
# it would erase the type it wraps (the closure is invisible) and resolve to `Any`.
|
|
123
|
+
# Unchecked: `type` already validated what it produced. The INPUT stays `Any`, or
|
|
124
|
+
# `type`'s own input check runs a second time, outside the rescue.
|
|
104
125
|
policy :rescue do |type, exception_class|
|
|
105
|
-
|
|
126
|
+
fn = lambda do |result|
|
|
106
127
|
type.call(result)
|
|
107
128
|
rescue exception_class => e
|
|
108
129
|
result.invalid(errors: e.message)
|
|
109
130
|
end
|
|
131
|
+
|
|
132
|
+
GuaranteedFunction.new(Types::Any, Plumb::Subtyping.resolved_output(type), fn,
|
|
133
|
+
inspect: 'Rescue', identity: [:rescue, type, exception_class])
|
|
110
134
|
end
|
|
111
135
|
|
|
112
136
|
# Split a string into an array. Default separator is /\s*,\s*/
|
|
@@ -133,11 +157,13 @@ module Plumb
|
|
|
133
157
|
extend TypeRegistry
|
|
134
158
|
|
|
135
159
|
Any = AnyClass.new
|
|
160
|
+
Never = NeverClass.new
|
|
136
161
|
Undefined = Any.value(Plumb::Undefined)
|
|
137
162
|
String = Any[::String]
|
|
138
163
|
Symbol = Any[::Symbol]
|
|
139
164
|
Numeric = Any[::Numeric]
|
|
140
165
|
Integer = Any[::Integer]
|
|
166
|
+
Float = Any[::Float]
|
|
141
167
|
Decimal = Any[BigDecimal]
|
|
142
168
|
Static = StaticClass.new
|
|
143
169
|
Value = ValueClass.new
|
|
@@ -149,11 +175,16 @@ module Plumb
|
|
|
149
175
|
Stream = StreamClass.new
|
|
150
176
|
Tuple = TupleClass.new
|
|
151
177
|
Hash = HashClass.new
|
|
178
|
+
Range = RangeClass.new
|
|
152
179
|
Not = Plumb::Not.new
|
|
153
180
|
Interface = InterfaceClass.new
|
|
154
181
|
Email = String[URI::MailTo::EMAIL_REGEXP].as_node(:email)
|
|
155
182
|
Date = Any[::Date]
|
|
156
183
|
Time = Any[::Time]
|
|
184
|
+
# What a lenient container accepts (a Stream, a `.filtered` Array/Hash/HashMap).
|
|
185
|
+
# Here, not beside those classes, because Types is loaded after all of them.
|
|
186
|
+
Each = Interface[:each]
|
|
187
|
+
EachPair = Interface[:each_pair]
|
|
157
188
|
|
|
158
189
|
# A type that recursively converts string keys to symbols in nested hashes.
|
|
159
190
|
# This is commonly used for normalizing payload data in commands and events.
|
|
@@ -166,7 +197,7 @@ module Plumb
|
|
|
166
197
|
# SymbolizedHash.parse({ 'count' => 1, 'active' => true }) # => { count: 1, active: true }
|
|
167
198
|
SymbolizedHash = Hash[
|
|
168
199
|
# String keys are converted to symbols, existing symbols are preserved
|
|
169
|
-
(Symbol | String.transform(::Symbol,
|
|
200
|
+
(Symbol | String.transform(::Symbol, :to_sym)),
|
|
170
201
|
# Hash values are recursively symbolized, other types pass through unchanged
|
|
171
202
|
Any.defer { SymbolizedHash } | Any
|
|
172
203
|
]
|
|
@@ -191,54 +222,24 @@ module Plumb
|
|
|
191
222
|
|
|
192
223
|
String = Types::String \
|
|
193
224
|
| Types::Decimal.transform(::String) { |v| v.to_s('F') } \
|
|
194
|
-
| Types::Numeric.transform(::String,
|
|
225
|
+
| Types::Numeric.transform(::String, :to_s)
|
|
195
226
|
|
|
196
|
-
Symbol = Types::Symbol | Types::String.transform(::Symbol,
|
|
227
|
+
Symbol = Types::Symbol | Types::String.transform(::Symbol, :to_sym)
|
|
197
228
|
|
|
198
229
|
NumberString = Types::String.match(NUMBER_EXPR)
|
|
199
230
|
CoercibleNumberString = NumberString.transform(::String) { |v| v.tr(',', '') }
|
|
200
231
|
|
|
201
|
-
Numeric = Types::Numeric | CoercibleNumberString.transform(::Numeric,
|
|
232
|
+
Numeric = Types::Numeric | CoercibleNumberString.transform(::Numeric, :to_f)
|
|
202
233
|
|
|
203
234
|
Decimal = Types::Decimal | \
|
|
204
|
-
(Types::Numeric.transform(::String,
|
|
235
|
+
(Types::Numeric.transform(::String, :to_s) | CoercibleNumberString) \
|
|
205
236
|
.transform(::BigDecimal) { |v| BigDecimal(v) }
|
|
206
237
|
|
|
207
|
-
Integer = Numeric.transform(::Integer,
|
|
238
|
+
Integer = Numeric.transform(::Integer, :to_i)
|
|
208
239
|
end
|
|
209
240
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
Types::String[/^true$/i] \
|
|
214
|
-
| Types::String['1'] \
|
|
215
|
-
| Types::Integer[1]
|
|
216
|
-
).transform(::TrueClass) { |_| true }
|
|
217
|
-
|
|
218
|
-
False = Types::False \
|
|
219
|
-
| (
|
|
220
|
-
Types::String[/^false$/i] \
|
|
221
|
-
| Types::String['0'] \
|
|
222
|
-
| Types::Integer[0]
|
|
223
|
-
).transform(::FalseClass) { |_| false }
|
|
224
|
-
|
|
225
|
-
Boolean = True | False
|
|
226
|
-
|
|
227
|
-
Nil = Nil | (String[BLANK_STRING] >> nil)
|
|
228
|
-
|
|
229
|
-
# Accept a Date, or a string that can be parsed into a Date
|
|
230
|
-
# via Date.parse
|
|
231
|
-
Date = Date | (String >> Any.build(::Date, :parse).policy(:rescue, ::Date::Error))
|
|
232
|
-
Time = Time | (String >> Any.build(::Time, :parse).policy(:rescue, ::ArgumentError))
|
|
233
|
-
|
|
234
|
-
# Turn strings into different URI types
|
|
235
|
-
module URI
|
|
236
|
-
# URI.parse is very permisive - a blank string is valid.
|
|
237
|
-
# We want to ensure that a generic URI at least starts with a scheme as per RFC 3986
|
|
238
|
-
Generic = Types::URI::Generic | (String[/^([a-z][a-z0-9+\-.]*)/].build(::URI, :parse))
|
|
239
|
-
HTTP = Generic[::URI::HTTP]
|
|
240
|
-
File = Generic[::URI::File]
|
|
241
|
-
end
|
|
242
|
-
end
|
|
241
|
+
# NOTE: the one-way Types::Forms coercions were replaced by the two-way
|
|
242
|
+
# Plumb::Codec::Forms — apply it to a schema written in output types
|
|
243
|
+
# (`Codec::Forms >> schema`), or use its encoders per-field.
|
|
243
244
|
end
|
|
244
245
|
end
|
data/lib/plumb/union.rb
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'plumb/composable'
|
|
4
|
+
require 'plumb/disjunction'
|
|
5
|
+
|
|
6
|
+
module Plumb
|
|
7
|
+
# THE LATTICE JOIN — the least upper bound of two types, dual to
|
|
8
|
+
# {Plumb::Intersection}. Built by `#|` when every branch returns its value
|
|
9
|
+
# untouched, so the node describes exactly "a value in either set" and is its own
|
|
10
|
+
# output type. That is the difference from {Plumb::Or}, whose ends differ.
|
|
11
|
+
class Union
|
|
12
|
+
include Composable
|
|
13
|
+
include Disjunction
|
|
14
|
+
|
|
15
|
+
# No lazy rebuild, no identity guard, no allocation — the mapping Or needs exists
|
|
16
|
+
# only because a converting branch makes its ends differ.
|
|
17
|
+
#
|
|
18
|
+
# NOT symmetric with #input_type, which is inherited from Disjunction: a branch may
|
|
19
|
+
# accept more than it describes, so the input side still has to map.
|
|
20
|
+
def output_type = self
|
|
21
|
+
|
|
22
|
+
# An invariant of the node: Disjunction.build only produces a Union when every
|
|
23
|
+
# branch preserves the value. @see Intersection#value_preserving?
|
|
24
|
+
def value_preserving? = true
|
|
25
|
+
|
|
26
|
+
# node_name comes from Naming (:union, derived from the class). The JSON
|
|
27
|
+
# Schema visitor handles it separately from :or — a union is a plain anyOf,
|
|
28
|
+
# with none of the default-value handling a choice needs.
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/plumb/value_class.rb
CHANGED
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
require 'plumb/composable'
|
|
4
4
|
|
|
5
5
|
module Plumb
|
|
6
|
+
# A single literal value, matched by `==`.
|
|
7
|
+
#
|
|
8
|
+
# Unlike the other bare matchers — a Constraint with no base, an
|
|
9
|
+
# AttributeValueMatch — this does NOT report `Types::Any` as its #input_type.
|
|
10
|
+
# Those opt out of #>>'s subtype check because they narrow arbitrary input and
|
|
11
|
+
# have no declared domain to check against; a literal's domain is a known
|
|
12
|
+
# singleton, so it keeps the default (`self`) and accepts only its own value.
|
|
13
|
+
# That is what makes `Value['a'] >> Value['b']` raise rather than build a chain
|
|
14
|
+
# no value can satisfy. Narrowing a wider type down to a literal is spelled
|
|
15
|
+
# `#[]` (`Types::String['a']`), which builds the refinement directly.
|
|
6
16
|
class ValueClass
|
|
7
17
|
include Composable
|
|
8
18
|
|
|
@@ -10,14 +20,23 @@ module Plumb
|
|
|
10
20
|
|
|
11
21
|
def initialize(value = Undefined)
|
|
12
22
|
@value = value
|
|
23
|
+
# Pre-build the failure message once (the object is frozen and @value is
|
|
24
|
+
# fixed), so a miss on the hot path — eg. every non-matching branch of a
|
|
25
|
+
# `Value[a] | Value[b]` enum union — doesn't allocate a fresh String.
|
|
26
|
+
# Frozen because it's shared across every failing call (and across threads
|
|
27
|
+
# under Types::Array#concurrent) and only ever read, never mutated.
|
|
28
|
+
@error = "Must be equal to #{value}".freeze
|
|
13
29
|
@children = [value].freeze
|
|
14
30
|
freeze
|
|
15
31
|
end
|
|
16
32
|
|
|
17
33
|
def [](value) = self.class.new(value)
|
|
18
34
|
|
|
35
|
+
# Matches a specific value and passes it through unchanged.
|
|
36
|
+
def value_preserving? = true
|
|
37
|
+
|
|
19
38
|
def call(result)
|
|
20
|
-
@value == result.value ? result : result.invalid(errors:
|
|
39
|
+
@value == result.value ? result : result.invalid!(errors: @error)
|
|
21
40
|
end
|
|
22
41
|
|
|
23
42
|
private
|
data/lib/plumb/version.rb
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
module Plumb
|
|
4
4
|
module VisitorHandlers
|
|
5
|
+
# Node names introduced by the type/computation split, each mapped to the name
|
|
6
|
+
# the node was reported under before it existed. `Plumb::Intersection` used to
|
|
7
|
+
# be an `And` and `Plumb::Union` used to be an `Or`, so a visitor written
|
|
8
|
+
# against the old AST defines only `on(:and)` / `on(:or)` — without this it
|
|
9
|
+
# would raise "no handler" on a type it used to understand.
|
|
10
|
+
#
|
|
11
|
+
# Consulted ONLY when the specific handler is absent, so a visitor that does
|
|
12
|
+
# distinguish the two (see JSONSchemaVisitor) keeps full control. Plumb's own
|
|
13
|
+
# visitors register both names explicitly; this is for visitors outside the gem.
|
|
14
|
+
NODE_NAME_FALLBACKS = { intersection: :and, union: :or }.freeze
|
|
15
|
+
|
|
5
16
|
def self.included(base)
|
|
6
17
|
base.extend(ClassMethods)
|
|
7
18
|
end
|
|
@@ -28,11 +39,16 @@ module Plumb
|
|
|
28
39
|
end
|
|
29
40
|
|
|
30
41
|
def visit_name(method_name, node, props = BLANK_HASH)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
42
|
+
target = :"visit_#{method_name}"
|
|
43
|
+
|
|
44
|
+
if !respond_to?(target) && (fallback = NODE_NAME_FALLBACKS[method_name])
|
|
45
|
+
target = :"visit_#{fallback}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if respond_to?(target)
|
|
49
|
+
send(target, node, props)
|
|
34
50
|
else
|
|
35
|
-
on_missing_handler(node, props,
|
|
51
|
+
on_missing_handler(node, props, target)
|
|
36
52
|
end
|
|
37
53
|
end
|
|
38
54
|
|
data/lib/plumb.rb
CHANGED
|
@@ -57,27 +57,114 @@ module Plumb
|
|
|
57
57
|
def self.decorate(type, &block)
|
|
58
58
|
Decorator.call(type, &block)
|
|
59
59
|
end
|
|
60
|
+
|
|
61
|
+
# Recursively resolve the underlying Ruby classes ("base types") of a node.
|
|
62
|
+
# Types::String => [String]
|
|
63
|
+
# Types::String | Integer => [String, Integer]
|
|
64
|
+
#
|
|
65
|
+
# This is a temporary helper to preserve type-specific policy resolution
|
|
66
|
+
# (see Composable#policy) until proper subtyping checks are implemented.
|
|
67
|
+
# Returns the value's matching domain, widened to Numeric because numeric
|
|
68
|
+
# equality crosses concrete classes (`5 == 5.0`).
|
|
69
|
+
# Keeping a concrete numeric class would falsely make compatible literal and
|
|
70
|
+
# numeric types appear disjoint and reduce their intersection to Never.
|
|
71
|
+
# @param value [Object]
|
|
72
|
+
# @return [Class]
|
|
73
|
+
def self.value_base_class(value) = SemanticMatcher.value_domain(value)
|
|
74
|
+
|
|
75
|
+
def self.resolve_base_types(node)
|
|
76
|
+
return [node] if node.is_a?(::Class)
|
|
77
|
+
return [] unless node.respond_to?(:node_name)
|
|
78
|
+
|
|
79
|
+
# A transparent wrapper (Policy / Metadata / #as_node Node) only RE-LABELS the type
|
|
80
|
+
# it wraps, so it has the same base types. Peeled here rather than per branch: a
|
|
81
|
+
# Node's node_name is whatever #as_node was given, so it cannot be a `when` at all,
|
|
82
|
+
# and every #as_node type would report NO base types — which callers read as
|
|
83
|
+
# "unknown base, allow", silently skipping build-time checks like
|
|
84
|
+
# `Types::Email[1..20]`.
|
|
85
|
+
unwrapped = Plumb::Subtyping.unwrap_transparent(node)
|
|
86
|
+
return resolve_base_types(unwrapped) unless unwrapped.equal?(node)
|
|
87
|
+
|
|
88
|
+
case node.node_name
|
|
89
|
+
when :or, :union
|
|
90
|
+
node.children.flat_map { |child| resolve_base_types(child) }
|
|
91
|
+
when :function
|
|
92
|
+
resolve_base_types(node.output_type)
|
|
93
|
+
when :intersection
|
|
94
|
+
# A meet narrows a single value, so its base types are its LEFT's —
|
|
95
|
+
# `String.where(size: 1..3)` is still a String.
|
|
96
|
+
resolve_base_types(node.children[0])
|
|
97
|
+
when :and
|
|
98
|
+
# Descend into whichever side carries the resulting type: a value-preserving right
|
|
99
|
+
# NARROWS what the left produces, a converting right REPLACES it.
|
|
100
|
+
#
|
|
101
|
+
# Deliberately NOT `node.output_type`, which encodes the same rule but is not
|
|
102
|
+
# guaranteed to be a smaller node — for `Array[<record with a coercing field>]
|
|
103
|
+
# .where(size: 1..)` it is a DIFFERENT And whose own output type is itself, so
|
|
104
|
+
# following it ping-pongs until the stack blows. A child always terminates.
|
|
105
|
+
left, right = node.children
|
|
106
|
+
resolve_base_types(Plumb::Subtyping.value_preserving?(right) ? left : right)
|
|
107
|
+
when :constraint
|
|
108
|
+
# A refinement matcher carries its base type — resolve that (eg.
|
|
109
|
+
# `Integer[1..10]` => [Integer], `User.check {}` => the User's base types).
|
|
110
|
+
return resolve_base_types(node.base) if node.base
|
|
111
|
+
|
|
112
|
+
SemanticMatcher.matcher_domain(node.children.first)
|
|
113
|
+
when :array, :tuple then [::Array]
|
|
114
|
+
when :hash, :hash_map, :tagged_hash, :filtered_hash, :filtered_hash_map then [::Hash]
|
|
115
|
+
when :stream then [::Enumerator]
|
|
116
|
+
when :range then [::Range]
|
|
117
|
+
when :not
|
|
118
|
+
# A complement has no finite base-class representation.
|
|
119
|
+
[]
|
|
120
|
+
when :value
|
|
121
|
+
# Literals match by ==, including across numeric classes.
|
|
122
|
+
value = node.children.first
|
|
123
|
+
value.equal?(Undefined) ? [] : [value_base_class(value)]
|
|
124
|
+
when :static
|
|
125
|
+
value = node.children.first
|
|
126
|
+
[value.is_a?(::Class) ? value : value_base_class(value)]
|
|
127
|
+
else
|
|
128
|
+
node.respond_to?(:children) ? node.children.flat_map { |child| resolve_base_types(child) } : []
|
|
129
|
+
end
|
|
130
|
+
end
|
|
60
131
|
end
|
|
61
132
|
|
|
62
133
|
require 'plumb/result'
|
|
63
134
|
require 'plumb/type_registry'
|
|
135
|
+
require 'plumb/relation'
|
|
136
|
+
require 'plumb/semantic_matcher'
|
|
64
137
|
require 'plumb/composable'
|
|
138
|
+
require 'plumb/typed_step'
|
|
139
|
+
require 'plumb/node_mapper'
|
|
140
|
+
require 'plumb/covariant_fusion'
|
|
65
141
|
require 'plumb/any_class'
|
|
66
|
-
require 'plumb/
|
|
142
|
+
require 'plumb/never_class'
|
|
143
|
+
require 'plumb/conjunction'
|
|
67
144
|
require 'plumb/and'
|
|
145
|
+
require 'plumb/intersection'
|
|
146
|
+
require 'plumb/function'
|
|
147
|
+
require 'plumb/encoder'
|
|
148
|
+
require 'plumb/implementation'
|
|
68
149
|
require 'plumb/pipeline'
|
|
69
150
|
require 'plumb/static_class'
|
|
70
151
|
require 'plumb/value_class'
|
|
71
|
-
require 'plumb/
|
|
152
|
+
require 'plumb/constraint'
|
|
72
153
|
require 'plumb/not'
|
|
154
|
+
require 'plumb/disjunction'
|
|
73
155
|
require 'plumb/or'
|
|
156
|
+
require 'plumb/union'
|
|
74
157
|
require 'plumb/tuple_class'
|
|
75
158
|
require 'plumb/array_class'
|
|
76
159
|
require 'plumb/stream_class'
|
|
77
160
|
require 'plumb/hash_class'
|
|
161
|
+
require 'plumb/range_class'
|
|
78
162
|
require 'plumb/interface_class'
|
|
79
163
|
require 'plumb/attributes'
|
|
164
|
+
require 'plumb/subtyping'
|
|
165
|
+
require 'plumb/optimizer'
|
|
80
166
|
require 'plumb/types'
|
|
167
|
+
require 'plumb/codec'
|
|
81
168
|
require 'plumb/json_schema_visitor'
|
|
82
|
-
require 'plumb/
|
|
169
|
+
require 'plumb/mermaid_visitor'
|
|
83
170
|
require 'plumb/decorator'
|