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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +887 -64
  3. data/bench/compare_dry_schema.rb +79 -0
  4. data/bench/compare_dry_types.rb +37 -0
  5. data/bench/compare_parametric_schema.rb +2 -80
  6. data/bench/dry_schema_hash.rb +103 -0
  7. data/bench/dry_types_hash.rb +125 -0
  8. data/bench/json_schema_profile.rb +107 -0
  9. data/bench/plumb_hash.rb +17 -11
  10. data/bench/results_allocations.rb +137 -0
  11. data/bench/sample_data.rb +78 -0
  12. data/examples/command_objects.rb +1 -1
  13. data/examples/concurrent_downloads.rb +16 -9
  14. data/examples/event_registry.rb +6 -1
  15. data/examples/weekdays.rb +1 -1
  16. data/lib/plumb/and.rb +63 -6
  17. data/lib/plumb/any_class.rb +12 -2
  18. data/lib/plumb/array_class.rb +133 -25
  19. data/lib/plumb/attribute_value_match.rb +41 -1
  20. data/lib/plumb/attributes.rb +59 -19
  21. data/lib/plumb/codec.rb +886 -0
  22. data/lib/plumb/composable.rb +451 -39
  23. data/lib/plumb/conjunction.rb +50 -0
  24. data/lib/plumb/constraint.rb +234 -0
  25. data/lib/plumb/covariant_fusion.rb +46 -0
  26. data/lib/plumb/decorator.rb +12 -22
  27. data/lib/plumb/deferred.rb +13 -5
  28. data/lib/plumb/disjunction.rb +112 -0
  29. data/lib/plumb/encoder.rb +207 -0
  30. data/lib/plumb/function.rb +347 -0
  31. data/lib/plumb/hash_class.rb +339 -32
  32. data/lib/plumb/hash_map.rb +58 -14
  33. data/lib/plumb/implementation.rb +247 -0
  34. data/lib/plumb/interface_class.rb +21 -2
  35. data/lib/plumb/intersection.rb +47 -0
  36. data/lib/plumb/json_schema_visitor.rb +255 -36
  37. data/lib/plumb/key.rb +63 -13
  38. data/lib/plumb/mermaid_visitor.rb +129 -0
  39. data/lib/plumb/metadata.rb +10 -1
  40. data/lib/plumb/metadata_visitor.rb +36 -34
  41. data/lib/plumb/never_class.rb +38 -0
  42. data/lib/plumb/node_mapper.rb +97 -0
  43. data/lib/plumb/not.rb +34 -2
  44. data/lib/plumb/optimizer.rb +444 -0
  45. data/lib/plumb/or.rb +26 -29
  46. data/lib/plumb/pipeline.rb +99 -11
  47. data/lib/plumb/policy.rb +17 -4
  48. data/lib/plumb/range_class.rb +46 -0
  49. data/lib/plumb/relation.rb +57 -0
  50. data/lib/plumb/result.rb +55 -23
  51. data/lib/plumb/semantic_matcher.rb +393 -0
  52. data/lib/plumb/static_class.rb +20 -1
  53. data/lib/plumb/stream_class.rb +28 -6
  54. data/lib/plumb/subtyping.rb +461 -0
  55. data/lib/plumb/tagged_hash.rb +45 -4
  56. data/lib/plumb/tuple_class.rb +21 -4
  57. data/lib/plumb/type_cache.rb +41 -0
  58. data/lib/plumb/type_registry.rb +71 -0
  59. data/lib/plumb/typed_step.rb +67 -0
  60. data/lib/plumb/types.rb +44 -43
  61. data/lib/plumb/union.rb +30 -0
  62. data/lib/plumb/value_class.rb +20 -1
  63. data/lib/plumb/version.rb +1 -1
  64. data/lib/plumb/visitor_handlers.rb +20 -4
  65. data/lib/plumb.rb +90 -3
  66. metadata +30 -8
  67. data/lib/plumb/build.rb +0 -22
  68. data/lib/plumb/match_class.rb +0 -42
  69. data/lib/plumb/schema.rb +0 -195
  70. data/lib/plumb/step.rb +0 -27
  71. 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
- raise ArgumentError, "expected another Types::Interface, but got #{other.inspect}" unless other.is_a?(self.class)
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