plumb 0.2.0.beta.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 04bf6f39fffef2ead633842bb41f5cc202bcb837f93de6b39eec34af2db4f3cb
4
- data.tar.gz: e28fdab916fb639c37106cb1f7200e364ae0267588ee91b628782c1a8a5587b5
3
+ metadata.gz: 119f529616c7dfe5fe87e12e26e9cf91f3cb4c28fd19cacf89dc15c271e534de
4
+ data.tar.gz: 514598daebbf8a7ee477b68c8dd05cb74e9c5102da4f9f7f8a682f2de5939a66
5
5
  SHA512:
6
- metadata.gz: d310e169c82bfddb24fc1f63a5e8965b9f47e03e00743e7cde1729b5bc54cf0e2a01100ec98497149aa958f2864d4680610efa854310afcab8e4da855ad0db61
7
- data.tar.gz: d65eaf8128e53a7c21f4247a489f11d50da0a9b5178836524a20dfd3a89055463e9c7865dd91a552d3c7d0a1dc73a0fc7c874b77d8c06d532ee01aacf8459249
6
+ metadata.gz: 8997e52706716448c59bff278df20b9768aa0cc1e7d870c3e6dce8d01d15bf248c1ceefd7cff3b91a78462d0a56b8645f1a7085ca6a809aa9701cb4b26bdf862
7
+ data.tar.gz: e4dcaa6edaf31597fe1985efea84656a4efb1e9ba75a1f72ad9316a36257af79b4f36bafdd3d051f632ee0a7b005cbac3e07d9e051d12d5b7ff18aae8842e077
data/README.md CHANGED
@@ -519,6 +519,27 @@ str.parse() # 'nope'
519
519
  str.parse('yup') # 'yup'
520
520
  ```
521
521
 
522
+ A block generates the value on every invocation, instead of returning a fixed one:
523
+
524
+ ```ruby
525
+ id = Types::UUID::V4.default { SecureRandom.uuid }
526
+ id.parse() # a fresh UUID each time
527
+ ```
528
+
529
+ The step _declares_ the type it defaults — so a `Types::Date.default { Date.today }`
530
+ is still a `Date` for subtyping, JSON Schema and [Codecs](#encoders-and-codecs) — and
531
+ what the block returns is checked against it, failing where it is defaulted rather
532
+ than somewhere downstream.
533
+
534
+ What is checked is the type's **output**, and the type itself is not re-run on the
535
+ generated value: a converting type expects the block to produce the converted value.
536
+
537
+ ```ruby
538
+ int = Types::String.transform(::Integer, &:to_i)
539
+ int.default { 10 }.parse() # 10
540
+ int.default { '10' }.parse() # raises — a String is not what this type produces
541
+ ```
542
+
522
543
  Note that this is syntax sugar for:
523
544
 
524
545
  ```ruby
@@ -835,6 +856,9 @@ type.resolve('2024-02-02') # => Result::Valid with Date object
835
856
  type.resolve('2024-') # => Result::Invalid with error message
836
857
  ```
837
858
 
859
+ The guard keeps the type it wraps: the example above is still a `Date` for subtyping,
860
+ JSON Schema and [Codecs](#encoders-and-codecs).
861
+
838
862
  ### `Types::Interface`
839
863
 
840
864
  Use this for objects that must respond to one or more methods.
@@ -2067,6 +2091,34 @@ JSONPerson.to_json_schema
2067
2091
  # "dates" is described as { "type" => "object", "properties" => { "from" => { "type" => "string" }, ... } }
2068
2092
  ```
2069
2093
 
2094
+ #### Codec instances: a registry of pre-built pairs
2095
+
2096
+ Composing a codec rewrites the whole type tree, so it belongs at boot — not on the path of every message. A codec _instance_ is a registry of `[decoder, encoder]` pairs, each built once by `register` and then looked up by key:
2097
+
2098
+ ```ruby
2099
+ CODECS = JSONCodec.new do |c|
2100
+ c.register('person.created', Person)
2101
+ c.register('company.created', Company)
2102
+ c.register(Types::Date) # the key defaults to the type itself
2103
+ end
2104
+
2105
+ CODECS.decode('person.created', payload) # => a Person hash
2106
+ CODECS.encode('person.created', person) # => JSON structures
2107
+ CODECS.decode(Types::Date, '2024-01-01') # => Date
2108
+ ```
2109
+
2110
+ Keys are yours to choose — a message name, a content type, the type itself. `decode` and `encode` only `#parse`, so the rewrite is paid for once.
2111
+
2112
+ An instance built with a block is frozen when the block returns. Without one it stays open, and `register` chains:
2113
+
2114
+ ```ruby
2115
+ registry = JSONCodec.new
2116
+ registry.register('day', Types::Date).register('person', Person)
2117
+ registry.freeze
2118
+ ```
2119
+
2120
+ `key?` asks what is registered; an unknown key raises `Plumb::Codec::NoEntryError` (a `KeyError`). Payloads are still validated by their type — a bad one raises `Plumb::ParseError` as usual.
2121
+
2070
2122
  #### `Codec::Forms`: string-based formats
2071
2123
 
2072
2124
  The second built-in codec targets HTML forms, query strings and other formats where **every value arrives as a string**. Unlike `Codec::JSON` there are almost no native scalars: strings pass through, untyped containers recurse (Rack-style nested params), and everything else maps through an encoder with a strictly-patterned string input type — integers (`/\A-?\d+\z/`), floats, decimals, booleans (`"true"/"1"`, `"false"/"0"`, case-insensitive), ISO 8601 dates and times, scheme-prefixed URIs, and the empty string for `nil` (so `Types::Date | Types::Nil` decodes `''` to `nil`).
@@ -2616,7 +2668,7 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
2616
2668
 
2617
2669
  ## Contributing
2618
2670
 
2619
- Bug reports and pull requests are welcome on GitHub at https://github.com/ismasan/plumb.
2671
+ Bug reports and pull requests are welcome on GitHub at [github.com/ismasan/plumb](https://github.com/ismasan/plumb).
2620
2672
 
2621
2673
  ## License
2622
2674
 
@@ -45,22 +45,17 @@ module Plumb
45
45
  def output_type = Plumb::Subtyping.map_children(self) { |c| Plumb::Subtyping.resolved_output(c) }
46
46
 
47
47
  def concurrent
48
- ConcurrentArrayClass.new(element_type:)
48
+ concurrent_class.new(element_type:)
49
49
  end
50
50
 
51
51
  def stream
52
52
  StreamClass.new(element_type:)
53
53
  end
54
54
 
55
+ # A lenient version of this Array: it accepts any Array and emits one with only
56
+ # the valid elements, dropping the rest. @see FilteredArray
55
57
  def filtered
56
- Constraint.new(::Array) >> Function.opaque(inspect: "Array[#{element_type}].filtered",
57
- identity: [:filtered_array, element_type]) do |result|
58
- arr = result.value.each.with_object([]) do |e, memo|
59
- r = element_type.resolve(e)
60
- memo << r.value if r.valid?
61
- end
62
- result.valid!(arr)
63
- end
58
+ filtered_class.new(element_type:)
64
59
  end
65
60
 
66
61
  def call(result)
@@ -78,6 +73,12 @@ module Plumb
78
73
 
79
74
  attr_reader :element_type
80
75
 
76
+ # Named, not hardcoded, so the two combine in EITHER order: each subclass points
77
+ # at the variant that keeps what it already is, instead of the second call
78
+ # dropping the first.
79
+ def concurrent_class = ConcurrentArrayClass
80
+ def filtered_class = FilteredArray
81
+
81
82
  def _inspect
82
83
  %(Array[#{element_type}])
83
84
  end
@@ -136,6 +137,68 @@ module Plumb
136
137
 
137
138
  [values, errors]
138
139
  end
140
+
141
+ def filtered_class = ConcurrentFilteredArray
142
+ end
143
+
144
+ # Same element type as an ArrayClass, but drops invalid elements instead of
145
+ # rejecting the whole Array. Being an ArrayClass is what keeps it rewritable: a
146
+ # visitor — or a Codec — that maps the element type rebuilds a FilteredArray, not
147
+ # a plain one. @see HashMap::FilteredHashMap, the same arrangement for maps.
148
+ class FilteredArray < self
149
+ # It drops elements, so it changes the value whatever its element type does.
150
+ def value_preserving? = false
151
+
152
+ # Lenient: it never rejects an Array, so as a #>> consumer it accepts any
153
+ # enumerable — without this `Types::Array >> Array[String].filtered` is an
154
+ # illegal narrowing.
155
+ def accepted_type = Types::Each
156
+
157
+ # `Array[T].filtered.stream` IS `Array[T].stream.filtered` — a filtered Stream
158
+ # drops the same elements, lazily.
159
+ def stream = super.filtered
160
+
161
+ def call(result)
162
+ return result.invalid!(errors: 'is not an Array') unless ::Array === result.value
163
+
164
+ result.valid!(valid_elements(result.value))
165
+ end
166
+
167
+ private
168
+
169
+ def concurrent_class = ConcurrentFilteredArray
170
+
171
+ # The only thing the concurrent variant replaces. No errors to collect, unlike
172
+ # #map_array_elements — an invalid element is simply not there.
173
+ def valid_elements(array)
174
+ array.each.with_object([]) do |e, memo|
175
+ r = element_type.resolve(e)
176
+ memo << r.value if r.valid?
177
+ end
178
+ end
179
+
180
+ def _inspect = "Array[#{element_type}].filtered"
181
+ end
182
+
183
+ # Both at once, reached from either side (`.filtered.concurrent`,
184
+ # `.concurrent.filtered`), and itself under both.
185
+ class ConcurrentFilteredArray < FilteredArray
186
+ private
187
+
188
+ def filtered_class = ConcurrentFilteredArray
189
+
190
+ # A rejected future re-raises, as ConcurrentArrayClass does: an element step
191
+ # that RAISED is a bug to surface, not an element to drop.
192
+ def valid_elements(array)
193
+ futures = array.map { |e| Concurrent::Future.execute { element_type.resolve(e) } }
194
+
195
+ futures.each_with_object([]) do |future, memo|
196
+ r = future.value # blocks until settled; nil when rejected
197
+ raise future.reason if future.rejected?
198
+
199
+ memo << r.value if r.valid?
200
+ end
201
+ end
139
202
  end
140
203
  end
141
204
  end
data/lib/plumb/codec.rb CHANGED
@@ -32,9 +32,8 @@ module Plumb
32
32
  # algebra, so parsing, subtyping and visitors work on it unchanged. It works
33
33
  # on any type, not just Hash schemas: `JSONCodec >> Types::Date` returns the
34
34
  # matched encoder's decode step.
35
+ #
35
36
  class Codec
36
- include Composable
37
-
38
37
  class << self
39
38
  # Register one or more Encoder subclasses, in matching-priority order.
40
39
  # The registry is inheritable: subclassing a codec extends it, and a
@@ -46,7 +45,7 @@ module Plumb
46
45
  raise ArgumentError, "expected an Encoder subclass, got #{enc.inspect}"
47
46
  end
48
47
 
49
- own_encoders << enc
48
+ encoders << enc
50
49
  end
51
50
  self
52
51
  end
@@ -56,154 +55,179 @@ module Plumb
56
55
  # nothing. Real encoders always match first, so a noop never shadows a
57
56
  # registered encoder for the same type.
58
57
  def noop(*types)
59
- types.each { |t| own_noop_types << Composable.wrap(t) }
58
+ types.each { |t| noop_types << Composable.wrap(t) }
60
59
  self
61
60
  end
62
61
 
63
- # All registered encoders, inherited first, own last (later registrations
64
- # win ties in matching).
65
- def encoders = inherited_registry(:encoders, own_encoders)
62
+ # A subclass starts from a COPY of its parent's registries and appends its own,
63
+ # so `encoders` reads inherited-first, own-last (later registrations win ties in
64
+ # matching). Copied at subclass time rather than resolved at read time because
65
+ # registration happens in a class body: a parent is complete before a subclass
66
+ # of it exists.
67
+ def inherited(subclass)
68
+ super
69
+ subclass.encoders = encoders.dup
70
+ subclass.noop_types = noop_types.dup
71
+ end
66
72
 
67
- # All registered pass-through types, inherited first.
68
- def noop_types = inherited_registry(:noop_types, own_noop_types)
73
+ def encoders = @encoders ||= []
74
+ def noop_types = @noop_types ||= []
69
75
 
70
- # Class-level composition delegates to a memoized instance, so a Codec
71
- # subclass composes directly: `JSONCodec >> Person`.
72
- def instance = @instance ||= new
73
- def >>(other) = instance >> other
74
- def |(other) = instance | other
75
- def &(other) = instance & other
76
- def for(type) = instance.for(type)
77
- def to_plumb_type(op:, left:) = instance.to_plumb_type(op:, left:)
78
- def to_composable = instance
79
- def call(result) = instance.call(result)
76
+ # Only #inherited seeds a registry; everything else registers through
77
+ # #encoder / #noop.
78
+ attr_writer :encoders, :noop_types
79
+ protected :encoders=, :noop_types=
80
80
 
81
- private
81
+ # Decode direction: rewrite `other` so it accepts the encoders' input form
82
+ # and produces the output values `other` describes. The rewritten type
83
+ # REPLACES the composition — no codec node remains.
84
+ def >>(other)
85
+ Rewriter.new(self, :decode).call(Composable.wrap(other))
86
+ end
82
87
 
83
- # A registry a subclass extends rather than replaces: whatever the
84
- # ancestor exposes under `name`, plus this class's own registrations.
85
- def inherited_registry(name, own)
86
- inherited = superclass.respond_to?(name) ? superclass.public_send(name) : BLANK_ARRAY
87
- inherited + own
88
+ # Both directions for a type, as a [decoding, encoding] pair:
89
+ #
90
+ # decoder, encoder = Plumb::Codec::JSON.for(Person)
91
+ # decoder.parse(input_data) # => a Person hash
92
+ # encoder.parse(person) # => output structures
93
+ #
94
+ # @param type [Composable, Object]
95
+ # @return [Array(Composable, Composable)]
96
+ def for(type)
97
+ type = Composable.wrap(type)
98
+ [self >> type, type >> self]
99
+ end
100
+
101
+ # Encode direction, reached when the codec is the RIGHT operand
102
+ # (`Person >> JSONCodec` — see Composable.resolve_operand): build an encode
103
+ # rewrite of what `left` produces. Composable#>> then composes
104
+ # `And(left, rewrite)`: the left validates its input once, the
105
+ # rewrite encodes. Building from `left`'s OUTPUT (not `left` itself)
106
+ # avoids re-running its coercions on already-parsed values.
107
+ def to_plumb_type(op:, left:)
108
+ unless op == :>>
109
+ raise Plumb::TypeError, "#{inspect} only composes with #>> (got #{op}); a Codec is not a value type"
110
+ end
111
+
112
+ left = Composable.wrap(left)
113
+ # A plain-include struct wraps as an opaque Step (output Any), so its
114
+ # rewrite target is the struct node itself, not its resolved output.
115
+ target = Plumb::Attributes.struct_class(left) ? left : Plumb::Subtyping.resolved_output(left)
116
+ Rewriter.new(self, :encode).call(target)
88
117
  end
89
118
 
90
- def own_encoders = @own_encoders ||= []
91
- def own_noop_types = @own_noop_types ||= []
92
- end
119
+ def |(_other) = raise Plumb::TypeError, "#{inspect} only composes with #>>; a Codec is not a value type"
120
+ alias & |
93
121
 
94
- attr_reader :encoders, :noop_types
122
+ # A Codec is not a value type, and has no node to stand in for one. Both of
123
+ # these say so at the point of the mistake — without #to_composable,
124
+ # Composable.wrap would see a `#call` and build an opaque step out of the class.
125
+ def to_composable = raise(Plumb::TypeError, "#{inspect} is not a type; compose it with a type via #>>")
95
126
 
96
- # @param extra_encoders [Array<Class>] encoders for this instance, appended
97
- # after (and winning ties over) the class-level registry.
98
- def initialize(*extra_encoders)
99
- @encoders = self.class.encoders + extra_encoders
100
- @noop_types = self.class.noop_types
101
- @noop_union = @noop_types.reduce(:|)
102
- freeze
103
- end
127
+ def call(_result)
128
+ raise Plumb::TypeError, "#{inspect} is not a runtime type; compose it with a type via #>>"
129
+ end
104
130
 
105
- # Decode direction: rewrite `other` so it accepts the encoders' input form
106
- # and produces the output values `other` describes. The rewritten type
107
- # REPLACES the composition no codec node remains.
108
- def >>(other)
109
- Rewriter.new(self, :decode).call(Composable.wrap(other))
110
- end
131
+ # The best matching encoder for `type`, or nil. Matching is against each
132
+ # encoder's output type schemas are written in output
133
+ # terms in both directions. Most-specific wins; equivalent types
134
+ # tie-break to the last registered; incomparable multi-matches raise.
135
+ def encoder_for(type, path = BLANK_ARRAY)
136
+ matches = encoders.select { |e| Plumb::Subtyping.subtype?(type, e.output_type) }
137
+ return nil if matches.empty?
138
+ return matches.first if matches.size == 1
111
139
 
112
- # Both directions for a type, as a [decoding, encoding] pair:
113
- #
114
- # decoder, encoder = Plumb::Codec::JSON.for(Person)
115
- # decoder.parse(input_data) # => a Person hash
116
- # encoder.parse(person) # => output structures
117
- #
118
- # @param type [Composable, Object]
119
- # @return [Array(Composable, Composable)]
120
- def for(type)
121
- type = Composable.wrap(type)
122
- [self >> type, type >> self]
123
- end
140
+ minimal = matches.reject do |e|
141
+ # e is dominated when another match's output type is strictly narrower.
142
+ matches.any? do |o|
143
+ !o.equal?(e) && Plumb::Subtyping.strict_subtype?(o.output_type, e.output_type)
144
+ end
145
+ end
124
146
 
125
- # Encode direction, reached when the codec is the RIGHT operand
126
- # (`Person >> JSONCodec` see Composable#to_plumb_type): build an encode
127
- # rewrite of what `left` produces. Composable#>> then composes
128
- # `And(left, rewrite)`: the left validates its input once, the
129
- # rewrite encodes. Building from `left`'s OUTPUT (not `left` itself)
130
- # avoids re-running its coercions on already-parsed values.
131
- def to_plumb_type(op:, left:)
132
- unless op == :>>
133
- raise Plumb::TypeError, "#{inspect} only composes with #>> (got #{op}); a Codec is not a value type"
134
- end
135
-
136
- left = Composable.wrap(left)
137
- # A plain-include struct wraps as an opaque Step (output Any), so its
138
- # rewrite target is the struct node itself, not its resolved output.
139
- target = Plumb::Attributes.struct_class(left) ? left : Plumb::Subtyping.resolved_output(left)
140
- Rewriter.new(self, :encode).call(target)
141
- end
147
+ first = minimal.first
148
+ unless minimal.all? { |e| Plumb::Subtyping.equivalent?(e.output_type, first.output_type) }
149
+ raise Plumb::TypeError,
150
+ "#{inspect}: #{at_path(path)} (#{type.inspect}) matches multiple incomparable encoders: " \
151
+ "#{minimal.map(&:inspect).join(', ')}. Register a more specific encoder or restructure."
152
+ end
142
153
 
143
- def |(_other) = raise Plumb::TypeError, "#{inspect} only composes with #>>; a Codec is not a value type"
144
- alias & |
154
+ minimal.last
155
+ end
145
156
 
146
- def call(_result)
147
- raise Plumb::TypeError, "#{inspect} is not a runtime type; compose it with a type via #>>"
148
- end
157
+ # Is `type` (one side of it, per direction) covered by the registered noop
158
+ # types? Decode checks what the type ACCEPTS (it will be fed raw input
159
+ # data); encode checks what it PRODUCES (its output lands in the encoded
160
+ # document).
161
+ def noop?(type, direction)
162
+ return false unless noop_union
149
163
 
150
- # The best matching encoder for `type`, or nil. Matching is against each
151
- # encoder's output type schemas are written in output
152
- # terms in both directions. Most-specific wins; equivalent types
153
- # tie-break to the last registered; incomparable multi-matches raise.
154
- def encoder_for(type, path = BLANK_ARRAY)
155
- matches = encoders.select { |e| Plumb::Subtyping.subtype?(type, e.output_type) }
156
- return nil if matches.empty?
157
- return matches.first if matches.size == 1
158
-
159
- minimal = matches.reject do |e|
160
- # e is dominated when another match's output type is strictly narrower.
161
- matches.any? do |o|
162
- !o.equal?(e) && Plumb::Subtyping.strict_subtype?(o.output_type, e.output_type)
163
- end
164
+ side = direction == :decode ? Plumb::Subtyping.accepted_type(type) : Plumb::Subtyping.resolved_output(type)
165
+ # A pure filter with an opaque side is judged by the type itself: a
166
+ # bare-matcher Constraint (a branch of a factored refinement union, eg.
167
+ # the `String[/\Atrue\z/i] | String['1']` boolean input type) reports Any
168
+ # as its accepted type, but as a value-preserving refinement it IS its
169
+ # own honest description.
170
+ side = type if side.is_a?(AnyClass) && Plumb::Subtyping.value_preserving?(type)
171
+ Plumb::Subtyping.subtype?(side, noop_union)
164
172
  end
165
173
 
166
- first = minimal.first
167
- unless minimal.all? { |e| Plumb::Subtyping.equivalent?(e.output_type, first.output_type) }
168
- raise Plumb::TypeError,
169
- "#{inspect}: #{at_path(path)} (#{type.inspect}) matches multiple incomparable encoders: " \
170
- "#{minimal.map(&:inspect).join(', ')}. Register a more specific encoder or restructure."
174
+ # Is this concrete VALUE covered by the noop types? Used for Static nodes,
175
+ # whose fixed value can be validated directly — subtyping over the node
176
+ # can't relate an atomic Static to a container noop (`Static[[]]` vs
177
+ # `Types::Array`), but the value itself can just be checked.
178
+ def noop_value?(value)
179
+ return false unless noop_union
180
+
181
+ noop_union === value
171
182
  end
172
183
 
173
- minimal.last
184
+ def at_path(path) = path.empty? ? 'the root type' : "field `#{path.join('.')}`"
185
+
186
+ # Named, so an anonymous `Class.new(Codec)` still says what it carries.
187
+ def inspect = "#{name || superclass.name}[#{encoders.map(&:inspect).join(', ')}]"
188
+
189
+ private
190
+
191
+ # Derived from #noop_types, so resolved once — after boot, when the first
192
+ # composition asks.
193
+ def noop_union
194
+ return @noop_union if defined?(@noop_union)
195
+
196
+ @noop_union = noop_types.reduce(:|)
197
+ end
174
198
  end
175
199
 
176
- # Is `type` (one side of it, per direction) covered by the registered noop
177
- # types? Decode checks what the type ACCEPTS (it will be fed raw input
178
- # data); encode checks what it PRODUCES (its output lands in the encoded
179
- # document).
180
- def noop?(type, direction)
181
- return false unless @noop_union
182
-
183
- side = direction == :decode ? Plumb::Subtyping.accepted_type(type) : Plumb::Subtyping.resolved_output(type)
184
- # A pure filter with an opaque side is judged by the type itself: a
185
- # bare-matcher Constraint (a branch of a factored refinement union, eg.
186
- # the `String[/\Atrue\z/i] | String['1']` boolean input type) reports Any
187
- # as its accepted type, but as a value-preserving refinement it IS its
188
- # own honest description.
189
- side = type if side.is_a?(AnyClass) && Plumb::Subtyping.value_preserving?(type)
190
- Plumb::Subtyping.subtype?(side, @noop_union)
200
+ Entry = Data.define(:decoder, :encoder)
201
+ NoEntryError = Class.new(KeyError)
202
+
203
+ def initialize(&)
204
+ @entries = {}
205
+ return unless block_given?
206
+
207
+ yield self
208
+ freeze
191
209
  end
192
210
 
193
- # Is this concrete VALUE covered by the noop types? Used for Static nodes,
194
- # whose fixed value can be validated directly — subtyping over the node
195
- # can't relate an atomic Static to a container noop (`Static[[]]` vs
196
- # `Types::Array`), but the value itself can just be checked.
197
- def noop_value?(value)
198
- return false unless @noop_union
211
+ def freeze
212
+ @entries.freeze
213
+ super
214
+ end
199
215
 
200
- @noop_union === value
216
+ # Named, not splatted: both sides are types that #parse, so a swapped pair would
217
+ # decode where it should encode without anything raising.
218
+ def register(key, type = key)
219
+ decoder, encoder = self.class.for(type)
220
+ @entries[key] = Entry.new(decoder:, encoder:)
221
+ self
201
222
  end
202
223
 
203
- def at_path(path) = path.empty? ? 'the root type' : "field `#{path.join('.')}`"
224
+ def key?(key) = @entries.key?(key)
204
225
 
205
- private def _inspect
206
- "#{self.class == Codec ? 'Plumb::Codec' : self.class.name}[#{encoders.map(&:inspect).join(', ')}]"
226
+ def decode(key, payload) = entry(key).decoder.parse(payload)
227
+ def encode(key, payload) = entry(key).encoder.parse(payload)
228
+
229
+ def inspect
230
+ %(#<#{self.class}:#{object_id} [#{@entries.size} entries]>)
207
231
  end
208
232
 
209
233
  # The deep rewrite walker. Top-down, per node:
@@ -293,11 +317,16 @@ module Plumb
293
317
  return NodeMapper.map(type) { |t| visit(t, path) }
294
318
  end
295
319
 
320
+ # Before encoder matching, for the Static reason: a source IS a subtype of what
321
+ # it declares, so an encoder would replace the code that produces the value.
322
+ return visit_source(type, path) if source_step?(type)
323
+
296
324
  enc = encoder_for(type, path)
297
325
  return replace(type, enc, path) if enc
298
326
 
299
327
  case type
300
328
  when HashClass then visit_hash(type, path)
329
+ when FilteredHash then visit_filtered_hash(type, path)
301
330
  when ArrayClass, StreamClass then visit_array(type, path)
302
331
  when TupleClass then visit_tuple(type, path)
303
332
  when HashMap then visit_hash_map(type, path)
@@ -311,13 +340,37 @@ module Plumb
311
340
  end
312
341
  end
313
342
 
314
- # A leaf: nothing to recurse into structurally. It survives when the
315
- # format already carries it (the noop check), or — decoding — when the
343
+ # A leaf: nothing to recurse into structurally. It survives when it is OPAQUE,
344
+ # when the format already carries it (the noop check), or — decoding — when the
316
345
  # codec can bridge what it CONSUMES.
317
346
  def visit_leaf(type, path)
347
+ return type if opaque_step?(type)
348
+
318
349
  bridge_input(type, path) || noop_or_fail(type, path)
319
350
  end
320
351
 
352
+ # An OPAQUE step (`Any -> Any`) is a black box — a `#generate` generator, a bare
353
+ # proc in a schema. It names no type to rewrite, so it passes through, and what
354
+ # it emits lands unencoded, exactly as its `Any` output says.
355
+ def opaque_step?(type) = type.is_a?(Plumb::TypedStep) && type.opaque?
356
+
357
+ # A SOURCE step declares `Any -> T`: it produces a T out of whatever it is
358
+ # handed. The `#default { }` generator is one.
359
+ def source_step?(type)
360
+ type.is_a?(Plumb::TypedStep) && type.input_type.is_a?(AnyClass) && !type.output_type.is_a?(AnyClass)
361
+ end
362
+
363
+ # DECODE: leave it alone. Accepting `Any` it already accepts the encoded form,
364
+ # and it produces the decoded value itself (`Date.default { Date.today }` yields
365
+ # a Date, not a wire string).
366
+ #
367
+ # ENCODE: the rewrite is composed AFTER the type it encodes (see
368
+ # Codec#to_plumb_type), so the source has already run and the T it declared is
369
+ # what sits here. Rewriting the node would run the generator a second time.
370
+ def visit_source(type, path)
371
+ @direction == :decode ? type : visit(type.output_type, path)
372
+ end
373
+
321
374
  # DECODE: a converting leaf (a Function, a Plumb::Implementation — any node
322
375
  # whose accepted type is a distinct node) consumes values the encoded
323
376
  # document does not carry: `Hash[time: Time] -> Thing` wants a Time, a JSON
@@ -443,12 +496,10 @@ module Plumb
443
496
  # constant) still rewrite their input once.
444
497
  input = enc.input_type_for(type)
445
498
  rewritten_input = @input_memo.fetch(type) do
446
- begin
447
- @input_stack.push(enc)
448
- @input_memo[type] = visit(input, path + ["<#{enc.inspect} input>"])
449
- ensure
450
- @input_stack.pop
451
- end
499
+ @input_stack.push(enc)
500
+ @input_memo[type] = visit(input, path + ["<#{enc.inspect} input>"])
501
+ ensure
502
+ @input_stack.pop
452
503
  end
453
504
 
454
505
  # Splice the input into the step unless it is the encoder's declared
@@ -480,6 +531,21 @@ module Plumb
480
531
  end
481
532
  end
482
533
 
534
+ # DECODE rewrites the schema a filter FILTERS and rebuilds the filter around it,
535
+ # so decoding happens inside it, per field: #bridge_input's spliced step would
536
+ # decode the whole hash first, making one unreadable field fatal — what
537
+ # .filtered exists to avoid. (The filtered Array and HashMap need none of this;
538
+ # being containers they rewrite through #visit_array / #visit_hash_map.)
539
+ #
540
+ # ENCODE: `resolved_output` reduces the filter to its relaxed schema before the
541
+ # rewrite, so this is only reached defensively.
542
+ def visit_filtered_hash(type, path)
543
+ return visit(type.output_type, path) unless @direction == :decode
544
+
545
+ schema = visit(type.input_type, path)
546
+ schema.equal?(type.input_type) ? type : schema.filtered
547
+ end
548
+
483
549
  def visit_array(type, path)
484
550
  return noop_or_fail(type, path) if type.children.first.is_a?(AnyClass) # untyped — a leaf
485
551
 
@@ -507,11 +573,16 @@ module Plumb
507
573
  NodeMapper.map_children(type) { |variant| visit(variant, path) }
508
574
  end
509
575
 
576
+ # Rewriting can collapse two branches onto the SAME node — encoding a
577
+ # `#default { }`, both resolve to the type. Keep one: the second is unreachable,
578
+ # and a failing encode would otherwise report its errors twice.
510
579
  def visit_or(type, path)
511
580
  left, right = type.children
512
581
  l = visit(left, path)
513
582
  r = visit(right, path)
514
- l.equal?(left) && r.equal?(right) ? type : Disjunction.build(l, r)
583
+ return type if l.equal?(left) && r.equal?(right)
584
+
585
+ l == r ? l : Disjunction.build(l, r)
515
586
  end
516
587
 
517
588
  # A MEET: a data-bearing type refined by pure filters (a #where's
@@ -560,27 +631,41 @@ module Plumb
560
631
  # Rewriting it too decodes twice: #bridge_input splices `b`'s own decode step in
561
632
  # front, the already-decoded value hits a step expecting the encoded form, and the
562
633
  # pipeline rejects everything. A step only counts as having consumed the wire if
563
- # its rewrite actually CHANGED it.
634
+ # its rewrite actually CHANGED it — or if it is a black box, which says the same
635
+ # thing: what it hands on is unknown. `Types::Integer.generate { 1 }` is
636
+ # `(opaque >> Integer)`, and rewriting that Integer into the codec's
637
+ # `String -> Integer` step would feed it the generated Integer and reject
638
+ # everything.
564
639
  def visit_composition(type, path)
565
640
  left, right = type.children
566
641
  l = pure_refinement?(left) ? left : visit(left, path)
567
- # `right` receives what `left` produced, so it faces the wire only when
568
- # `left`'s rewrite left it unchanged.
569
- wire_consumed = @direction == :decode && !l.equal?(left)
570
- r = pure_refinement?(right) || wire_consumed ? right : visit(right, path)
642
+ r = pure_refinement?(right) || wire_consumed?(left, l) ? right : visit(right, path)
571
643
 
572
644
  l.equal?(left) && r.equal?(right) ? type : Conjunction.build(l, r)
573
645
  end
574
646
 
647
+ # Encoding, nothing consumes a wire: every step is rewritten in place.
648
+ def wire_consumed?(left, rewritten)
649
+ return false unless @direction == :decode
650
+
651
+ !rewritten.equal?(left) || opaque_step?(rewritten) || source_step?(rewritten)
652
+ end
653
+
575
654
  # A pure refinement carries no encodable type — it filters the adjacent
576
655
  # type's values. A baseless Constraint refining a sibling qualifies, but a
577
656
  # base-type Constraint — `Types::Date` IS `Constraint(::Date)` — is data an
578
657
  # encoder must see (eg. in `Types::Date.where(year: ...)` ==
579
658
  # `And(Constraint(::Date), AVM)`), and so is a baseless Module gate, which
580
659
  # names a type rather than filtering one.
660
+ #
661
+ # `Any` is the TRIVIAL filter, so it qualifies too — but only ALONGSIDE a sibling
662
+ # that carries a type; on its own it is still a leaf, and still an error. It gets
663
+ # here from an opaque step's output slot, which is what `resolved_output` leaves
664
+ # behind (`Integer.generate { 1 }` resolves to `(Any >> Integer)`): the value is
665
+ # unknown until the Integer beside it vouches for it, and that is what to encode.
581
666
  def pure_refinement?(child)
582
667
  case child
583
- when AttributeValueMatch, ValueClass, Not then true
668
+ when AttributeValueMatch, ValueClass, Not, AnyClass then true
584
669
  when Constraint then child.base.nil? && !child.matcher.is_a?(::Module)
585
670
  else false
586
671
  end
@@ -608,7 +693,6 @@ module Plumb
608
693
  'Register an encoder for it, or declare it with .noop.'
609
694
  end
610
695
  end
611
-
612
696
  end
613
697
 
614
698
  # ------------------------------------------------------------------
@@ -634,9 +718,10 @@ module Plumb
634
718
  def decode(str) = ::Date.parse(str)
635
719
  end
636
720
 
637
- # Time <=> ISO 8601 date-time string ("2024-08-30T20:15:23Z").
721
+ # Time <=> ISO 8601 date-time string ("2024-08-30T20:15:23.456789Z").
722
+ #
638
723
  class TimeEncoder < Encoder[Types::String[TIME_EXPR].metadata(format: 'date-time') => Types::Time]
639
- def encode(time) = time.iso8601
724
+ def encode(time) = time.iso8601(6)
640
725
  def decode(str) = ::Time.parse(str)
641
726
  end
642
727
 
@@ -791,5 +876,11 @@ module Plumb
791
876
  encoder DateEncoder, TimeEncoder, SymbolEncoder, DecimalEncoder
792
877
  encoder URIEncoder, HTTPURIEncoder, FileURIEncoder
793
878
  end
879
+
880
+ private
881
+
882
+ def entry(key)
883
+ @entries.fetch(key) { raise NoEntryError, "no encoder/decoder registered for #{key}" }
884
+ end
794
885
  end
795
886
  end
@@ -232,8 +232,8 @@ module Plumb
232
232
  callable
233
233
  elsif callable.respond_to?(:to_composable)
234
234
  # The context-free resolution hook for objects that become a type on
235
- # demand: an Encoder class resolves to its default (declared)
236
- # direction, a Codec class to its instance. Used in schema literals,
235
+ # demand: an Encoder class resolves to its default (declared) direction; a
236
+ # Codec raises, having no type to become. Used in schema literals,
237
237
  # `Array[Enc]`, `#/`, etc. The composition operators (#>>, #|, #&)
238
238
  # consult #to_plumb_type BEFORE wrapping, so a composed operand can
239
239
  # resolve against context and never reaches this branch.
@@ -453,11 +453,12 @@ module Plumb
453
453
  # A filtered Hash is lenient: it accepts ANY hash and drops invalid/missing/
454
454
  # extra fields (it never rejects a Hash), so as a #>> consumer it accepts any
455
455
  # hash-like value. (Its #input_type still declares the schema it relaxes.)
456
- # Memoized at the class level (instances are frozen; Types isn't loaded yet
457
- # when this file is).
458
- def self.each_pair_interface = @each_pair_interface ||= Types::Interface[:each_pair]
456
+ def accepted_type = Types::EachPair
459
457
 
460
- def accepted_type = FilteredHash.each_pair_interface
458
+ # Re-derived, NOT rebuilt around the old #fn: the filter closes over the schema it
459
+ # was built from, so Function#with_children returns a node that inspects as the
460
+ # new schema and still filters by the old one.
461
+ def with_children(children) = children.first.filtered
461
462
 
462
463
  # Neither boundary check runs — the per-field validation in HashClass#filtered
463
464
  # subsumes both. Overriding #call is also what excludes this node from fusion,
@@ -93,11 +93,7 @@ module Plumb
93
93
  # entries (it never rejects a Hash), so as a #>> consumer it accepts any
94
94
  # hash-like value — not itself. Without this, `Hash >> Hash[K, V].filtered`
95
95
  # would be flagged as an illegal narrowing.
96
- # Memoized at the class level (instances are frozen; Types isn't loaded
97
- # yet when this file is).
98
- def self.each_pair_interface = @each_pair_interface ||= Types::Interface[:each_pair]
99
-
100
- def accepted_type = FilteredHashMap.each_pair_interface
96
+ def accepted_type = Types::EachPair
101
97
 
102
98
  def call(result)
103
99
  result.invalid(errors: 'must be a Hash') unless result.value.is_a?(::Hash)
@@ -41,11 +41,7 @@ module Plumb
41
41
  # #>> composition check. This lets a producer of a raw Enumerator/Array (eg.
42
42
  # a CSV enumerator) feed a Stream. Covariant Stream[X] <= Stream[Y]
43
43
  # subtyping is unaffected (that goes through #subtype_of? / #children).
44
- # Memoized at the class level (instances are frozen; Types isn't loaded yet
45
- # when this file is) — #call hits it on every data invocation.
46
- def self.each_interface = @each_interface ||= Types::Interface[:each]
47
-
48
- def input_type = StreamClass.each_interface
44
+ def input_type = Types::Each
49
45
 
50
46
  # The [Step] interface
51
47
  # @param result [Result]
data/lib/plumb/types.rb CHANGED
@@ -96,11 +96,16 @@ module Plumb
96
96
  # Works with a block too:
97
97
  # date = Type::Any[Date].default { Date.today }
98
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.
99
104
  policy :default, helper: true do |type, value = Undefined, &block|
100
105
  val_type = if value == Undefined
101
- Function.opaque(inspect: 'default proc', identity: [:default, block]) do |result|
102
- result.valid(block.call)
103
- end
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])
104
109
  else
105
110
  Types::Static[value]
106
111
  end
@@ -112,12 +117,20 @@ module Plumb
112
117
  # Expect a specific exception class, and return an invalid result if it is raised.
113
118
  # Usage:
114
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.
115
125
  policy :rescue do |type, exception_class|
116
- Function.opaque(inspect: 'Rescue', identity: [:rescue, type, exception_class]) do |result|
126
+ fn = lambda do |result|
117
127
  type.call(result)
118
128
  rescue exception_class => e
119
129
  result.invalid(errors: e.message)
120
130
  end
131
+
132
+ GuaranteedFunction.new(Types::Any, Plumb::Subtyping.resolved_output(type), fn,
133
+ inspect: 'Rescue', identity: [:rescue, type, exception_class])
121
134
  end
122
135
 
123
136
  # Split a string into an array. Default separator is /\s*,\s*/
@@ -168,6 +181,10 @@ module Plumb
168
181
  Email = String[URI::MailTo::EMAIL_REGEXP].as_node(:email)
169
182
  Date = Any[::Date]
170
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]
171
188
 
172
189
  # A type that recursively converts string keys to symbols in nested hashes.
173
190
  # This is commonly used for normalizing payload data in commands and events.
data/lib/plumb/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Plumb
4
- VERSION = '0.2.0.beta.1'
4
+ VERSION = '0.2.0.beta.2'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: plumb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0.beta.1
4
+ version: 0.2.0.beta.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ismael Celis