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,247 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'plumb/composable'
|
|
4
|
+
require 'plumb/typed_step'
|
|
5
|
+
|
|
6
|
+
module Plumb
|
|
7
|
+
# Turn a custom Ruby class into a first-class Plumb type.
|
|
8
|
+
#
|
|
9
|
+
# {Plumb::Function} wraps a callable you hand it; an Implementation goes the
|
|
10
|
+
# other way round — YOUR class owns its constructor and state, and the mixin
|
|
11
|
+
# gives it the typed-node interface (`#input_type`, `#output_type`,
|
|
12
|
+
# composition, subtyping, visitors). The declared pair is given as a one-pair
|
|
13
|
+
# Hash literal, exactly like {Plumb::Encoder}.
|
|
14
|
+
#
|
|
15
|
+
# The mixin owns the public `#call(Result) => Result`; you implement a private
|
|
16
|
+
# `#_call(Result) => Result`. `#call` runs the declared checks around it:
|
|
17
|
+
#
|
|
18
|
+
# result.map(input_type).map(_call).map(output_type)
|
|
19
|
+
#
|
|
20
|
+
# ie. the input is validated (and coerced, if the input type converts) before
|
|
21
|
+
# `#_call` sees it, and what it returns is validated against the output type.
|
|
22
|
+
#
|
|
23
|
+
# INCLUDE it to make INSTANCES typed steps — the case for a parameterized
|
|
24
|
+
# step, where the constructor arguments are part of what the step does:
|
|
25
|
+
#
|
|
26
|
+
# class UserFinder
|
|
27
|
+
# include Plumb::Implementation[Types::UUID::V4 => User]
|
|
28
|
+
#
|
|
29
|
+
# def initialize(scope) = @scope = scope
|
|
30
|
+
#
|
|
31
|
+
# private def _call(result)
|
|
32
|
+
# user = User.where(level: @scope).find_by(id: result.value)
|
|
33
|
+
# return result.invalid(errors: 'no user!') unless user
|
|
34
|
+
#
|
|
35
|
+
# result.valid(user)
|
|
36
|
+
# end
|
|
37
|
+
# end
|
|
38
|
+
#
|
|
39
|
+
# finder = UserFinder.new('admin')
|
|
40
|
+
# finder.parse(some_uuid) # => a User (raises unless the input is a UUID)
|
|
41
|
+
# finder >> some_other_step # composition, type-checked at build time
|
|
42
|
+
# Types::UUID::V4 >> finder # ...on both sides
|
|
43
|
+
# finder <= User # true: identified by what it produces
|
|
44
|
+
# finder.to_json_schema # describes the INPUT side, like any function
|
|
45
|
+
#
|
|
46
|
+
# EXTEND it to make THE CLASS ITSELF the step — no instantiation, the class
|
|
47
|
+
# implements `self._call(result)` and answers `.input_type` / `.output_type`:
|
|
48
|
+
#
|
|
49
|
+
# class ParseUUID
|
|
50
|
+
# extend Plumb::Implementation[Types::String => Types::UUID::V4]
|
|
51
|
+
#
|
|
52
|
+
# def self._call(result) = result.valid(result.value.downcase)
|
|
53
|
+
# end
|
|
54
|
+
#
|
|
55
|
+
# ParseUUID.parse(str) # the class is the step
|
|
56
|
+
# ParseUUID >> UserFinder.new('admin')
|
|
57
|
+
# Types::Hash[id: ParseUUID]
|
|
58
|
+
#
|
|
59
|
+
# This mirrors `include Plumb::Composable` / `extend Plumb::Composable`, and
|
|
60
|
+
# like those the two forms are alternatives — pick one per class. The extended
|
|
61
|
+
# form deliberately does NOT get {Plumb::Naming}/{Plumb::Equality} (see
|
|
62
|
+
# Composable.included): a class must keep Ruby's own `#name`, `#inspect`,
|
|
63
|
+
# `#==` and `#<=` (module ancestry). Ask for the subtype relation explicitly
|
|
64
|
+
# instead: `Plumb::Subtyping.subtype?(ParseUUID, Types::String)`.
|
|
65
|
+
#
|
|
66
|
+
# Either way the step reports `node_name` `:function`, so every visitor, JSON
|
|
67
|
+
# Schema handler and base-type resolution that already understands a
|
|
68
|
+
# conversion node understands it too. Define your own `#node_name` (or
|
|
69
|
+
# `self.node_name`) after the mixin if you have visitors of your own.
|
|
70
|
+
#
|
|
71
|
+
# Declaring no pair (`include`/`extend Plumb::Implementation`) is the OPAQUE
|
|
72
|
+
# case: both ends are `Types::Any`, like {Plumb::Function.opaque} — the
|
|
73
|
+
# checks can't fail and `#>>` opts out of type-checking.
|
|
74
|
+
module Implementation
|
|
75
|
+
# Build the mixin: `Implementation[Input => Output]`.
|
|
76
|
+
# Both sides are wrapped as Plumb types, so raw Ruby classes/Hashes work
|
|
77
|
+
# (`Implementation[{id: Types::String} => User]`).
|
|
78
|
+
#
|
|
79
|
+
# @param pair [Hash] a one-pair Hash: input type => output type
|
|
80
|
+
# @return [Module] a mixin to include (typed instances) or extend (typed class)
|
|
81
|
+
def self.[](pair)
|
|
82
|
+
unless pair.is_a?(::Hash) && pair.size == 1
|
|
83
|
+
raise ArgumentError,
|
|
84
|
+
'Plumb::Implementation[Input => Output] expects a one-pair Hash ' \
|
|
85
|
+
"(eg. Plumb::Implementation[Types::String => User]), got #{pair.inspect}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
input, output = pair.first
|
|
89
|
+
build(Composable.wrap(input), Composable.wrap(output))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# `include`/`extend Plumb::Implementation` with no declared types — the
|
|
93
|
+
# opaque case.
|
|
94
|
+
def self.included(base) = setup_instances(base, Types::Any, Types::Any)
|
|
95
|
+
def self.extended(base) = setup_class(base, Types::Any, Types::Any)
|
|
96
|
+
|
|
97
|
+
# @param input [Composable]
|
|
98
|
+
# @param output [Composable]
|
|
99
|
+
# @return [Module]
|
|
100
|
+
def self.build(input, output)
|
|
101
|
+
label = "Plumb::Implementation[#{input.inspect} => #{output.inspect}]"
|
|
102
|
+
mod = Module.new
|
|
103
|
+
mod.define_singleton_method(:included) { |base| Implementation.setup_instances(base, input, output) }
|
|
104
|
+
mod.define_singleton_method(:extended) { |base| Implementation.setup_class(base, input, output) }
|
|
105
|
+
mod.define_singleton_method(:inspect) { label }
|
|
106
|
+
mod.define_singleton_method(:to_s) { label }
|
|
107
|
+
mod
|
|
108
|
+
end
|
|
109
|
+
private_class_method :build
|
|
110
|
+
|
|
111
|
+
# The INCLUDE path: instances of `base` become typed steps.
|
|
112
|
+
#
|
|
113
|
+
# Order matters. Composable goes in FIRST so that TypeInterface — included
|
|
114
|
+
# after it, therefore ahead of it in the ancestor chain — wins over the
|
|
115
|
+
# `#input_type = self` / `#output_type = self` defaults every plain type
|
|
116
|
+
# carries, and over Callable#call. #node_name is defined on the class itself
|
|
117
|
+
# because Naming defines it there too (a module could not override it); a
|
|
118
|
+
# `def node_name` in the class body after the include still wins over both.
|
|
119
|
+
#
|
|
120
|
+
# @api private
|
|
121
|
+
def self.setup_instances(base, input, output)
|
|
122
|
+
base.include(Composable)
|
|
123
|
+
base.include(TypeInterface)
|
|
124
|
+
base.include(Inspect)
|
|
125
|
+
base.include(types_module(input, output))
|
|
126
|
+
base.define_method(:node_name) { :function }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The EXTEND path: `base` itself becomes a typed step. Same interface, all
|
|
130
|
+
# of it on the singleton — and WITHOUT Naming/Equality, which would
|
|
131
|
+
# otherwise take over the class' own #name/#inspect/#==/#<= (this is why it
|
|
132
|
+
# goes through #extend rather than `singleton_class.include`, mirroring
|
|
133
|
+
# `extend Plumb::Composable`).
|
|
134
|
+
#
|
|
135
|
+
# @api private
|
|
136
|
+
def self.setup_class(base, input, output)
|
|
137
|
+
base.extend(Composable)
|
|
138
|
+
base.extend(TypeInterface)
|
|
139
|
+
base.extend(types_module(input, output))
|
|
140
|
+
base.define_singleton_method(:node_name) { :function }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The declared pair, as a module so that a `def input_type` in the host can
|
|
144
|
+
# override it and still reach the declaration with `super`.
|
|
145
|
+
def self.types_module(input, output)
|
|
146
|
+
Module.new do
|
|
147
|
+
define_method(:input_type) { input }
|
|
148
|
+
define_method(:output_type) { output }
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
private_class_method :types_module
|
|
152
|
+
|
|
153
|
+
# The typed-node interface, shared by both paths. The OTHER implementation of
|
|
154
|
+
# {Plumb::TypedStep} — same contract as {Plumb::Function}, differing only in
|
|
155
|
+
# that the host owns the object, so the declared pair comes from the mixin and
|
|
156
|
+
# the core is reached through the host's private #_call.
|
|
157
|
+
module TypeInterface
|
|
158
|
+
include Plumb::TypedStep
|
|
159
|
+
|
|
160
|
+
# The step interface, owned by the mixin: the declared checks around the
|
|
161
|
+
# host's #_call. Included AFTER Composable, so this is the #call that runs
|
|
162
|
+
# (Callable#call, the "implement me" stub, sits behind it).
|
|
163
|
+
#
|
|
164
|
+
# @param result [Result]
|
|
165
|
+
# @return [Result]
|
|
166
|
+
def call(result)
|
|
167
|
+
result = result.map(input_type)
|
|
168
|
+
return result if result.invalid?
|
|
169
|
+
|
|
170
|
+
_checked_call(result).map(output_type)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# The middle of #call — the host's #_call plus the contract check on what it
|
|
174
|
+
# returned — as a `Result => Result` callable, which is exactly what a
|
|
175
|
+
# {Plumb::Function}'s `fn` is. Exposing it is what lets a chain of
|
|
176
|
+
# Implementations FUSE: `Length.new >> Double.new` collapses to a single
|
|
177
|
+
# Function running both, instead of an And re-checking Integer at the seam.
|
|
178
|
+
#
|
|
179
|
+
# A fresh Method object per call, so read it at COMPOSITION time only (as
|
|
180
|
+
# #fuse_with does), never per value.
|
|
181
|
+
def fn = method(:_checked_call)
|
|
182
|
+
|
|
183
|
+
# What this step IS, for Function#== once fused: the host itself, so two
|
|
184
|
+
# fused chains compare by whatever equality the host defines.
|
|
185
|
+
def identity = self
|
|
186
|
+
|
|
187
|
+
# This family's canonical #call is the one defined above, so a host that
|
|
188
|
+
# replaced it runs different checks. @see Plumb::TypedStep#fusable_step?
|
|
189
|
+
def standard_call? = method(:call).owner.equal?(TypeInterface)
|
|
190
|
+
|
|
191
|
+
# Fuse forwards through a temporary Function — the same `input -> fn ->
|
|
192
|
+
# output` shape this node already runs, so Function#fuse_with's proofs hold
|
|
193
|
+
# verbatim and there is no second implementation of them to keep in step.
|
|
194
|
+
def fuse_with(other)
|
|
195
|
+
return nil unless fusable_step?
|
|
196
|
+
|
|
197
|
+
Plumb::Function.new(input_type, output_type, fn, identity:).fuse_with(other)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
private def _checked_call(result)
|
|
201
|
+
out = _call(result)
|
|
202
|
+
unless out.is_a?(Result)
|
|
203
|
+
raise Plumb::TypeError,
|
|
204
|
+
"#{_host_name}#_call(Plumb::Result) must return a Plumb::Result, got #{out.inspect}"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
out
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# The value-level contract, implemented by the host (privately, by
|
|
211
|
+
# convention — it is the inside of #call, not part of the step interface).
|
|
212
|
+
#
|
|
213
|
+
# @param result [Result] already validated against #input_type
|
|
214
|
+
# @return [Result] validated against #output_type by #call
|
|
215
|
+
def _call(_result)
|
|
216
|
+
raise NotImplementedError,
|
|
217
|
+
"Implement #{is_a?(::Module) ? 'self.' : '#'}_call(Plumb::Result) => Plumb::Result in #{_host_name}"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Visitors (and Composable#==) read a node's children. Computed, unlike
|
|
221
|
+
# Function's frozen ivar — the host owns its constructor, so there is no
|
|
222
|
+
# #initialize here to build one in.
|
|
223
|
+
def children = [input_type, output_type]
|
|
224
|
+
|
|
225
|
+
# The host, for error messages — the class itself when extended, the
|
|
226
|
+
# instance's class when included.
|
|
227
|
+
private def _host_name
|
|
228
|
+
is_a?(::Module) ? (name || inspect) : (self.class.name || self.class.inspect)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# INCLUDE path only: an instance would otherwise inspect as Ruby's
|
|
233
|
+
# `#<UserFinder:0x…>` (or, through Naming, as an empty string until frozen).
|
|
234
|
+
# An extended CLASS already inspects as itself, and overriding Module#inspect
|
|
235
|
+
# would change how it prints everywhere.
|
|
236
|
+
#
|
|
237
|
+
# Not routed through Naming#name either: a user class may well define its
|
|
238
|
+
# own #name, and its inspect should not depend on that.
|
|
239
|
+
module Inspect
|
|
240
|
+
def inspect = _inspect
|
|
241
|
+
|
|
242
|
+
private def _inspect
|
|
243
|
+
"#{self.class.name || 'Implementation'}(#{input_type.inspect} -> #{output_type.inspect})"
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
@@ -17,6 +17,25 @@ module Plumb
|
|
|
17
17
|
other.is_a?(self.class) && other.method_names == method_names
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
# Interface <= Interface: self is a subtype of a *looser* interface — one
|
|
21
|
+
# that requires a subset of self's methods (more methods = narrower type).
|
|
22
|
+
def subtype_of?(other)
|
|
23
|
+
return (other.method_names - method_names).empty? if other.is_a?(self.class)
|
|
24
|
+
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# An Interface is a *supertype* of any type whose underlying Ruby class(es)
|
|
29
|
+
# define all of its methods — the duck-typing rule, driven by the interface
|
|
30
|
+
# (the right side of `a <= self`). Consulted by Plumb::Subtyping.subtype? via
|
|
31
|
+
# the #supertype_of? hook, so eg. `Types::String <= Types::Interface[:to_s]`.
|
|
32
|
+
def supertype_of?(other)
|
|
33
|
+
classes = Plumb.resolve_base_types(other)
|
|
34
|
+
classes.any? && classes.all? do |klass|
|
|
35
|
+
klass.is_a?(::Module) && method_names.all? { |m| klass.method_defined?(m) }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
20
39
|
def of(*args)
|
|
21
40
|
case args
|
|
22
41
|
in Array => symbols if symbols.all? { |s| s.is_a?(::Symbol) }
|
|
@@ -51,7 +70,7 @@ module Plumb
|
|
|
51
70
|
# @param other [InterfaceClass]
|
|
52
71
|
# @return [InterfaceClass]
|
|
53
72
|
def &(other)
|
|
54
|
-
|
|
73
|
+
return super unless other.is_a?(self.class)
|
|
55
74
|
|
|
56
75
|
self.class.new(method_names & other.method_names)
|
|
57
76
|
end
|
|
@@ -59,7 +78,7 @@ module Plumb
|
|
|
59
78
|
def call(result)
|
|
60
79
|
obj = result.value
|
|
61
80
|
missing_methods = @method_names.reject { |m| obj.respond_to?(m) }
|
|
62
|
-
return result.invalid(errors: "Invalid #{self.name}. Missing methods: #{missing_methods.join(', ')}") if missing_methods.any?
|
|
81
|
+
return result.invalid!(errors: "Invalid #{self.name}. Missing methods: #{missing_methods.join(', ')}") if missing_methods.any?
|
|
63
82
|
|
|
64
83
|
result
|
|
65
84
|
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'plumb/composable'
|
|
4
|
+
require 'plumb/conjunction'
|
|
5
|
+
|
|
6
|
+
module Plumb
|
|
7
|
+
# THE LATTICE MEET — the greatest lower bound of two types. Built by `#>>`, `#/`,
|
|
8
|
+
# `#where` and `#check` when neither side changes the value, and by `#&` when it
|
|
9
|
+
# cannot narrow the operands into one node.
|
|
10
|
+
#
|
|
11
|
+
# Both sides validate the SAME value and return it untouched, so unlike {And} this
|
|
12
|
+
# is not a pipeline between two types — it IS a type, describing the values that
|
|
13
|
+
# satisfy both. The longer the chain, the narrower. Each side is a composed partial
|
|
14
|
+
# identity: a check `A ⇀ A` narrowing the semantic type without touching the value.
|
|
15
|
+
class Intersection
|
|
16
|
+
include Composable
|
|
17
|
+
include Conjunction
|
|
18
|
+
|
|
19
|
+
def initialize(left, right)
|
|
20
|
+
@left = left
|
|
21
|
+
@right = right
|
|
22
|
+
@input_type = left.input_type
|
|
23
|
+
# A meet IS its own output type — nothing to resolve through. The whole
|
|
24
|
+
# difference from And.
|
|
25
|
+
@output_type = self
|
|
26
|
+
@children = [left, right].freeze
|
|
27
|
+
freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# An invariant, not a computation: Conjunction.build only produces an Intersection
|
|
31
|
+
# when both sides preserve the value, so `is_a?(Intersection)` alone answers "is
|
|
32
|
+
# this a pure refinement?" — which Optimizer.reduce_step relies on.
|
|
33
|
+
def value_preserving? = true
|
|
34
|
+
|
|
35
|
+
# A refinement accepts the constraint it actually passes — the right side's
|
|
36
|
+
# resolved output — not its #input_type, which is only the type the chain opens
|
|
37
|
+
# with and would ignore the refinement (`Integer[1..10].input_type` is just
|
|
38
|
+
# Integer, but it accepts only 1..10). Deliberately the RIGHT CHILD's output and
|
|
39
|
+
# not `#output_type`: that is the whole intersection including the left's gate,
|
|
40
|
+
# and accepting against it would make `#>>` stricter than it has ever been.
|
|
41
|
+
def accepted_type = Plumb::Subtyping.resolved_output(children.last)
|
|
42
|
+
|
|
43
|
+
# node_name comes from Naming (:intersection). JSONSchemaVisitor handles it
|
|
44
|
+
# separately from :and, merging both sides' specs unconditionally since both
|
|
45
|
+
# describe one value.
|
|
46
|
+
end
|
|
47
|
+
end
|