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
@@ -3,6 +3,7 @@
3
3
  require 'plumb/composable'
4
4
  require 'plumb/key'
5
5
  require 'plumb/static_class'
6
+ require 'plumb/function'
6
7
  require 'plumb/hash_map'
7
8
  require 'plumb/tagged_hash'
8
9
 
@@ -14,12 +15,49 @@ module Plumb
14
15
 
15
16
  attr_reader :_schema
16
17
 
17
- def initialize(schema: BLANK_HASH, inclusive: false)
18
+ # @param schema [Hash] field definitions keyed by literal or matcher keys
19
+ # @param closed [Boolean] whether values contain only declared keys
20
+ def initialize(schema: BLANK_HASH, closed: false)
21
+ @closed = closed
18
22
  @_schema = wrap_keys_and_values(schema)
19
- @inclusive = inclusive
23
+ # Partition once (the instance is frozen): literal keys use exact lookup in
24
+ # #call; matcher keys (typed keys and the `_` catch-all) are matched against
25
+ # leftover input keys. `_schema` stays the source of truth for ==/subtyping.
26
+ @literal_fields = @_schema.select { |k, _| k.literal? }
27
+ @matcher_fields = @_schema.reject { |k, _| k.literal? }
28
+ # The `_` catch-all's value type, if present (there is at most one).
29
+ @catch_all_type = @_schema.find { |k, _| k.catch_all? }&.last
20
30
  freeze
21
31
  end
22
32
 
33
+ # The `_` catch-all value type (what every otherwise-unmatched key must be),
34
+ # or nil when the schema is closed. See #call / #&.
35
+ attr_reader :catch_all_type
36
+
37
+ # Input schemas are open because #call ignores extra keys; produced literal-key
38
+ # records are closed because #call emits only declared keys.
39
+ # @return [Boolean] whether values contain only declared keys
40
+ def closed? = @closed
41
+
42
+ # Returns the closed form emitted by a literal-key schema.
43
+ # Matcher-key and empty schemas remain open because they can pass unknown keys.
44
+ # @return [HashClass]
45
+ def closed
46
+ return self if @closed || _schema.empty? || !@matcher_fields.empty?
47
+
48
+ self.class.new(schema: _schema, closed: true)
49
+ end
50
+
51
+ # The literal (Symbol/String) entries of the schema.
52
+ attr_reader :literal_fields
53
+
54
+ # The matcher (typed/catch-all) entries of the schema.
55
+ attr_reader :matcher_fields
56
+
57
+ # Only a single `_` catch-all and no named/typed keys — the "open any-key map"
58
+ # shape (`Hash[_: V]`). Used by HashMap subtyping.
59
+ def only_catch_all? = @literal_fields.empty? && @matcher_fields.size == 1 && !@catch_all_type.nil?
60
+
23
61
  # A Hash type with a specific schema.
24
62
  # Option 1: a Hash representing schema
25
63
  #
@@ -31,7 +69,7 @@ module Plumb
31
69
  def schema(*args)
32
70
  case args
33
71
  in [::Hash => hash]
34
- self.class.new(schema: _schema.merge(wrap_keys_and_values(hash)), inclusive: @inclusive)
72
+ self.class.new(schema: _schema.merge(wrap_keys_and_values(hash)))
35
73
  in [key_type, value_type]
36
74
  HashMap.new(Composable.wrap(key_type), Composable.wrap(value_type))
37
75
  else
@@ -53,76 +91,147 @@ module Plumb
53
91
  raise ArgumentError, "expected a HashClass or Hash, got #{other.class}"
54
92
  end
55
93
 
56
- self.class.new(schema: merge_rightmost_keys(_schema, other_schema), inclusive: @inclusive)
94
+ self.class.new(schema: merge_rightmost_keys(_schema, other_schema))
57
95
  end
58
96
 
97
+ # Intersects shared fields and any fields admitted by the other schema's
98
+ # catch-all. The empty schema is the Hash top; disjoint non-empty schemas
99
+ # produce Never. Non-Hash operands use the generic intersection.
100
+ # @param other [Object]
101
+ # @return [HashClass, Composable]
59
102
  def &(other)
60
- raise ArgumentError, "expected a HashClass, got #{other.class}" unless other.is_a?(HashClass)
103
+ # Route through the intersection hook (not bare Composable.wrap) so a
104
+ # context-resolving operand — eg. an Encoder class — orients against this
105
+ # Hash instead of defaulting to its decode direction (which would make
106
+ # `Hash & EncoderClass` collapse to Never). Mirrors Composable#&.
107
+ other = Composable.resolve_operand(other, op: :&, left: self)
108
+ return super unless other.is_a?(HashClass)
109
+
110
+ # The any-Hash top is the identity of intersection: Hash[] & X == X.
111
+ return other if _schema.empty?
112
+ return self if other._schema.empty?
113
+
114
+ my_catch = catch_all_type
115
+ their_catch = other.catch_all_type
116
+ result = {}
61
117
 
62
- intersected_keys = other._schema.keys & _schema.keys
63
- intersected = intersected_keys.each.with_object({}) do |k, memo|
64
- memo[k] = other.at_key(k)
118
+ non_catch_all_schema.each do |my_key, my_field|
119
+ if (other_key = other.stored_key(my_key))
120
+ # shared key: intersect fields; optionality from `other` (right wins).
121
+ result[other_key] = my_field & other._schema[other_key]
122
+ elsif their_catch # kept only if the other side's catch-all admits it
123
+ result[my_key] = my_field & their_catch
124
+ end
65
125
  end
66
126
 
67
- self.class.new(schema: intersected, inclusive: @inclusive)
127
+ other.non_catch_all_schema.each do |their_key, their_field|
128
+ next if stored_key(their_key) # already handled as a shared key
129
+
130
+ result[their_key] = their_field & my_catch if my_catch
131
+ end
132
+
133
+ # Preserve every non-Any constraint on unmatched keys. A closed side may drop
134
+ # extras, but an open side still validates them; dropping its lone catch-all
135
+ # would admit values that the open side rejects.
136
+ tail = my_catch && their_catch ? my_catch & their_catch : (my_catch || their_catch)
137
+ result[Key.new(Types::Any)] = tail if tail && !tail.is_a?(AnyClass)
138
+
139
+ return Types::Never if result.empty? # two closed schemas that share nothing
140
+
141
+ self.class.new(schema: result)
68
142
  end
69
143
 
70
144
  def tagged_by(key, *types)
71
145
  TaggedHash.new(self, key, types)
72
146
  end
73
147
 
74
- def inclusive
75
- self.class.new(schema: _schema, inclusive: true)
76
- end
77
-
78
148
  def at_key(a_key)
79
149
  _schema[Key.wrap(a_key)]
80
150
  end
81
151
 
82
152
  def to_h = _schema
83
153
 
154
+ # A lenient version of this Hash: it accepts any Hash and emits one with only
155
+ # the valid schema fields, dropping invalid/missing/extra ones. As a type it
156
+ # declares `#input_type` as this schema and `#output_type` as this schema
157
+ # with every key relaxed to optional (any field may be dropped), so it
158
+ # participates in subtyping (see FilteredHash).
84
159
  def filtered
85
160
  op = lambda do |result|
86
- return result.invalid(errors: 'must be a Hash') unless result.value.is_a?(::Hash)
161
+ return result.invalid!(errors: 'must be a Hash') unless result.value.is_a?(::Hash)
87
162
  return result unless _schema.any?
88
163
 
89
164
  input = result.value
90
- field_result = BLANK_RESULT.dup
91
- output = _schema.each.with_object({}) do |(key, field), ret|
92
- key_s = key.to_sym
165
+ # Reuse the incoming cursor as the per-field scratch (see #call): `input`
166
+ # is captured above and `result` is only flipped at the end, so fields
167
+ # reset it in place with no scratch allocation.
168
+ output = {}
169
+ @literal_fields.each do |key, field|
170
+ key_s = key.to_key
93
171
  if input.key?(key_s)
94
- r = field.call(field_result.reset(input[key_s]))
95
- ret[key_s] = r.value if r.valid?
172
+ r = field.call(result.reset(input[key_s]))
173
+ output[key_s] = r.value if r.valid?
96
174
  elsif !key.optional?
97
- r = field.call(BLANK_RESULT)
98
- ret[key_s] = r.value if r.valid?
175
+ r = field.call(result.reset(Undefined))
176
+ output[key_s] = r.value if r.valid?
177
+ end
178
+ end
179
+ unless @matcher_fields.empty?
180
+ input.each do |k, v|
181
+ next if output.key?(k)
182
+
183
+ match = @matcher_fields.find { |mk, _| mk.match?(k) }
184
+ next unless match
185
+
186
+ r = match[1].call(result.reset(v))
187
+ output[k] = r.value if r.valid?
99
188
  end
100
189
  end
101
- result.valid(output)
190
+ result.valid!(output)
102
191
  end
103
- Step.new(op, [_inspect, 'filtered'].join('.'))
192
+ # `op` is built fresh per call, so name what the step IS as its identity —
193
+ # otherwise every `.filtered` node is unequal to every other, and so is any
194
+ # composite containing one. @see Function#==
195
+ FilteredHash.new(self, relaxed_to_optional, op, identity: [:filtered_hash, self])
196
+ end
197
+
198
+ # A version of this Hash that first symbolizes string keys (via
199
+ # Types::SymbolizedHash) and then validates against this schema. Use it
200
+ # instead of `Types::SymbolizedHash >> self`, which the strict composition
201
+ # check rejects — a Symbol-keyed map doesn't guarantee this schema's keys, so
202
+ # this declares the step as a #transform (conversion) instead.
203
+ def symbolized
204
+ Types::SymbolizedHash / self
104
205
  end
105
206
 
106
207
  def call(result)
107
- return result.invalid(errors: NOT_A_HASH) unless result.value.is_a?(::Hash)
208
+ return result.invalid!(errors: NOT_A_HASH) unless result.value.is_a?(::Hash)
108
209
  return result unless _schema.any?
109
210
 
110
211
  input = result.value
111
212
  errors = nil # Do not allocate errors unless needed
112
- output = @inclusive ? input.dup : {}
113
- field_result = Result.valid(nil)
213
+ output = {}
114
214
 
115
- _schema.each do |key, field|
215
+ # Pass 1 — literal keys by exact lookup (the fast path). Reuse the incoming
216
+ # cursor as the per-field scratch: `input` is captured above and `result` is
217
+ # not read again until the final flip below, so each field can reset it in
218
+ # place — a Hash validates with zero Result allocations of its own.
219
+ # `output`/`errors` hold the fields' values/errors by reference (read out
220
+ # immediately per field), and #reset only reassigns the cursor's slots, so
221
+ # previously stored entries are never mutated. This mirrors ArrayClass's
222
+ # element-cursor reuse; a field whose value is lazily consumed later (a
223
+ # Stream) snapshots its own source, so it stays correct.
224
+ @literal_fields.each do |key, field|
116
225
  key_s = key.to_key
117
226
  if input.key?(key_s)
118
- r = field.call(field_result.reset(input[key_s]))
227
+ r = field.call(result.reset(input[key_s]))
119
228
  output[key_s] = r.value
120
229
  unless r.valid?
121
230
  errors ||= {}
122
231
  errors[key_s] = r.errors
123
232
  end
124
233
  elsif !key.optional?
125
- r = field.call(BLANK_RESULT)
234
+ r = field.call(result.reset(Undefined))
126
235
  output[key_s] = r.value unless r.value == Undefined
127
236
  unless r.valid?
128
237
  errors ||= {}
@@ -131,22 +240,190 @@ module Plumb
131
240
  end
132
241
  end
133
242
 
134
- errors ? result.invalid(output, errors:) : result.valid(output)
243
+ # Pass 2 — leftover input keys against matcher keys (typed keys + the `_`
244
+ # catch-all), first match wins. Keys matching nothing are dropped (the
245
+ # non-inclusive default). Matcher keys never impose a required key.
246
+ unless @matcher_fields.empty?
247
+ input.each do |k, v|
248
+ next if output.key?(k)
249
+
250
+ match = @matcher_fields.find { |mk, _| mk.match?(k) }
251
+ next unless match
252
+
253
+ r = match[1].call(result.reset(v))
254
+ output[k] = r.value
255
+ unless r.valid?
256
+ errors ||= {}
257
+ errors[k] = r.errors
258
+ end
259
+ end
260
+ end
261
+
262
+ errors ? result.invalid!(output, errors:) : result.valid!(output)
135
263
  end
136
264
 
137
265
  def ==(other)
138
- other.is_a?(self.class) && other._schema == _schema
266
+ return false unless other.is_a?(self.class) && _schema.size == other._schema.size
267
+
268
+ # `Key#eql?`/`#hash` compare names only, so a raw `_schema == _schema`
269
+ # would treat `name?:` and `name:` as equal. Two Hash types that differ in
270
+ # a key's optionality are different types, so compare that too.
271
+ _schema.all? do |key, value|
272
+ other_key, other_value = other._schema.find { |k, _| k.eql?(key) }
273
+ other_key && key.optional? == other_key.optional? && value == other_value
274
+ end
275
+ end
276
+
277
+ # Width + depth subtyping over Hash schemas. `self <= other` when, for every
278
+ # key `other` requires, `self` provides it as a *required* key whose type is
279
+ # a subtype (depth) — `self` may add keys (width), and a key `other` makes
280
+ # optional need not be present. A key `other` requires but `self` only holds
281
+ # optionally is NOT enough: `self` could omit it. An empty schema
282
+ # (`Types::Hash`) is the "any Hash" top within the Hash family.
283
+ def subtype_of?(other)
284
+ return true if self == other
285
+ return hashmap_subtype?(other) if other.is_a?(HashMap)
286
+ return false unless other.is_a?(HashClass)
287
+
288
+ # The unconstrained Hash is the top of this family, never a narrower subtype.
289
+ return false if _schema.empty?
290
+ return true if other._schema.empty?
291
+
292
+ named_keys_ok?(other) && carried_keys_ok?(other) && tail_ok?(other)
293
+ end
294
+
295
+ # As a consumer, this Hash accepts each field relaxed to what *that* field
296
+ # accepts — so a converting field (eg. `price: Integer.build(Money)`) accepts
297
+ # an Integer, not the Money it produces. Only field types change; keys and
298
+ # optionality are preserved, so ordinary record subtyping is unchanged. This
299
+ # is what lets `Hash[price: Integer] >> Hash[price: Integer.build(Money)]`
300
+ # type-check (the front-end/back-end coercion pattern).
301
+ def accepted_type
302
+ relaxed = _schema.each_with_object({}) do |(key, field), h|
303
+ h[key] = Plumb::Subtyping.accepted_type(field)
304
+ end
305
+ self.class.new(schema: relaxed)
139
306
  end
140
307
 
308
+ # The value you GET after running the schema: each field resolved to what it
309
+ # produces, so `Hash[age: String >> Integer].output_type` is `Hash[age:
310
+ # Integer]` (mirror of #accepted_type on the input side). Idempotent — when
311
+ # no field converts, every field's resolved output IS the field, so this
312
+ # returns `self` and Subtyping.resolved_output reaches its fixpoint in one
313
+ # step. Only field types change; keys and optionality are preserved.
314
+ def output_type
315
+ resolved = _schema.each_with_object({}) do |(key, field), h|
316
+ h[key] = Plumb::Subtyping.resolved_output(field)
317
+ end
318
+ resolved.any? { |k, f| !f.equal?(_schema[k]) } ? self.class.new(schema: resolved) : self
319
+ end
320
+
321
+ protected
322
+
323
+ # The schema entries excluding the `_` catch-all (the named + typed keys).
324
+ def non_catch_all_schema = @_schema.reject { |k, _| k.catch_all? }
325
+
326
+ # The Key instance stored in this schema that is eql? to `key` (so callers can
327
+ # read the owning side's optionality), or nil.
328
+ def stored_key(key) = @_schema.each_key.find { |k| k.eql?(key) }
329
+
141
330
  private
142
331
 
332
+ # Checks required keys and the types of optional keys that self may carry.
333
+ def named_keys_ok?(other)
334
+ other.literal_fields.all? do |other_key, other_field|
335
+ mine_key, mine_field = _schema.find { |k, _| k.eql?(other_key) }
336
+ if mine_field
337
+ Plumb::Subtyping.subtype?(mine_field, other_field) &&
338
+ (other_key.optional? || !mine_key.optional?)
339
+ else
340
+ # "Optional" does not mean impossible: matcher keys or a catch-all may
341
+ # still carry this name. Closed records omit it; open records must
342
+ # constrain any value they can carry there.
343
+ other_key.optional? && (closed? || promises?(guarantee_for(other_key.to_key), other_field))
344
+ end
345
+ end
346
+ end
347
+
348
+ # Returns the first matcher field that claims an undeclared key.
349
+ def guarantee_for(key_name)
350
+ @matcher_fields.find { |mk, _| mk.match?(key_name) }&.last
351
+ end
352
+
353
+ # Checks that self's open tail satisfies every matcher field in other.
354
+ def tail_ok?(other)
355
+ return true if closed? # no tail for `other`'s matcher keys to constrain
356
+
357
+ other.matcher_fields.all? { |_k, f| promises?(catch_all_type, f) }
358
+ end
359
+
360
+ # Tests whether an optional guarantee satisfies a requested field type.
361
+ def promises?(guaranteed, wanted)
362
+ return true if wanted.is_a?(AnyClass)
363
+
364
+ !guaranteed.nil? && Plumb::Subtyping.subtype?(guaranteed, wanted)
365
+ end
366
+
367
+ # Checks every carried key against overlapping matcher fields in other.
368
+ def carried_keys_ok?(other)
369
+ _schema.all? do |mine_key, mine_field|
370
+ theirs = other._schema.find { |ok, _| ok.eql?(mine_key) }
371
+ if theirs
372
+ # named_keys_ok? already checked literal fields.
373
+ mine_key.literal? || Plumb::Subtyping.subtype?(mine_field, theirs.last)
374
+ else
375
+ other.matcher_fields.all? do |other_key, other_field|
376
+ !keys_may_overlap?(mine_key, other_key) ||
377
+ Plumb::Subtyping.subtype?(mine_field, other_field)
378
+ end
379
+ end
380
+ end
381
+ end
382
+
383
+ # Tests literal overlap exactly; assumes two arbitrary matchers may overlap.
384
+ def keys_may_overlap?(mine, theirs)
385
+ return mine.eql?(theirs) if mine.literal? && theirs.literal?
386
+ return theirs.match?(mine.to_key) if mine.literal?
387
+ return mine.match?(theirs.to_key) if theirs.literal?
388
+
389
+ true
390
+ end
391
+
392
+ def hashmap_subtype?(other)
393
+ return false if _schema.empty?
394
+ # Open records need a catch-all to constrain every map entry.
395
+ return false unless closed? || catch_all_type
396
+
397
+ key_type, value_type = other.children
398
+ _schema.all? do |key, field|
399
+ key_matcher = key.literal? ? Composable.wrap(key.to_key) : key.matcher
400
+ Plumb::Subtyping.subtype?(key_matcher, key_type) &&
401
+ Plumb::Subtyping.subtype?(field, value_type)
402
+ end
403
+ end
404
+
405
+ # This schema with every key relaxed to optional (same value types). Used as
406
+ # the output type of #filtered, which may drop any field. Matcher keys (typed/
407
+ # catch-all) are already optional, so they pass through unchanged.
408
+ def relaxed_to_optional
409
+ relaxed = _schema.each_with_object({}) do |(key, type), h|
410
+ new_key = key.literal? ? Key.new(key.to_key, optional: true) : key
411
+ h[new_key] = type
412
+ end
413
+ self.class.new(schema: relaxed)
414
+ end
415
+
143
416
  def _inspect
144
417
  %(Hash[#{_schema.map { |(k, v)| [k.inspect, v.inspect].join(': ') }.join(', ')}])
145
418
  end
146
419
 
147
420
  def wrap_keys_and_values(hash)
148
421
  hash.each.with_object({}) do |(k, v), ret|
149
- ret[Key.wrap(k)] = Composable.wrap(v)
422
+ # `_:` is sugar for a catch-all key over the Any top. Only intercept a raw
423
+ # `:_` symbol (the schema-literal syntax); an already-wrapped Key (from #+,
424
+ # #&, relaxed/accepted rebuilds, or a Data attribute) passes through as-is.
425
+ key = k == :_ ? Key.new(Types::Any) : Key.wrap(k)
426
+ ret[key] = Composable.wrap(v)
150
427
  end
151
428
  end
152
429
 
@@ -160,4 +437,34 @@ module Plumb
160
437
  end
161
438
  end
162
439
  end
440
+
441
+ # The typed node returned by HashClass#filtered. It is a Function — so #>>
442
+ # treats it as a conversion (subtype by its output, accepted by its input) and
443
+ # it bypasses the strict composition check — declaring `input_type` as the
444
+ # filtered schema and `output_type` as that schema with all keys optional. But
445
+ # unlike a plain Function it does NOT strict-validate its input: #call runs
446
+ # the lenient filter, which accepts any Hash and drops invalid/missing/extra
447
+ # fields.
448
+ class FilteredHash < Function
449
+ # Naming derives #node_name when Composable is *included* (on Function), so
450
+ # the subclass would otherwise inherit :function.
451
+ def node_name = :filtered_hash
452
+
453
+ # A filtered Hash is lenient: it accepts ANY hash and drops invalid/missing/
454
+ # extra fields (it never rejects a Hash), so as a #>> consumer it accepts any
455
+ # hash-like value. (Its #input_type still declares the schema it relaxes.)
456
+ def accepted_type = Types::EachPair
457
+
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
462
+
463
+ # Neither boundary check runs — the per-field validation in HashClass#filtered
464
+ # subsumes both. Overriding #call is also what excludes this node from fusion,
465
+ # with nothing further to declare. @see Function#fusable_step?
466
+ def call(result) = fn.call(result)
467
+
468
+ private def _inspect = "#{input_type.inspect}.filtered"
469
+ end
163
470
  end
@@ -5,6 +5,7 @@ require 'plumb/composable'
5
5
  module Plumb
6
6
  class HashMap
7
7
  include Composable
8
+ include CovariantFusion
8
9
 
9
10
  attr_reader :children
10
11
 
@@ -15,8 +16,44 @@ module Plumb
15
16
  freeze
16
17
  end
17
18
 
19
+ # A HashMap is a Hash, so it is a subtype of the "any Hash" top — an
20
+ # empty-schema HashClass like Types::Hash — or of an open catch-all-only
21
+ # HashClass `Hash[_: V]` when this map's value type is a subtype of `V` (the
22
+ # catch-all's Any key admits any map key). It is NOT a subtype of a HashClass
23
+ # that requires specific keys a map doesn't guarantee. Against another HashMap
24
+ # it is covariant in key and value types (handled by the default #subtype_of?).
25
+ def subtype_of?(other)
26
+ if other.is_a?(HashClass)
27
+ return true if other._schema.empty?
28
+
29
+ # Maps constrain every value but do not guarantee any named key is present.
30
+ return false if other.literal_fields.any? { |k, _| !k.optional? }
31
+
32
+ return other._schema.all? { |_k, field| Plumb::Subtyping.subtype?(@value_type, field) }
33
+ end
34
+
35
+ super
36
+ end
37
+
38
+ # A HashMap re-maps each key and value through its key/value types, so it
39
+ # preserves the value only when both do (a coercing key or value would change
40
+ # the hash).
41
+ def value_preserving? = children.all? { |c| Plumb::Subtyping.value_preserving?(c) }
42
+
43
+ # Rebuild around new children (see Plumb::Subtyping.map_children).
44
+ # self.class (not HashMap) preserves FilteredHashMap's leniency.
45
+ def with_children(children) = self.class.new(children[0], children[1])
46
+
47
+ # As a consumer, a HashMap accepts keys/values relaxed to what its key and
48
+ # value types accept (see HashClass#accepted_type).
49
+ def accepted_type = Plumb::Subtyping.map_children(self) { |c| Plumb::Subtyping.accepted_type(c) }
50
+
51
+ # The value you GET after re-mapping each key/value: both resolved to what
52
+ # they produce (mirror of #accepted_type).
53
+ def output_type = Plumb::Subtyping.map_children(self) { |c| Plumb::Subtyping.resolved_output(c) }
54
+
18
55
  def call(result)
19
- return result.invalid(errors: 'must be a Hash') unless result.value.is_a?(::Hash)
56
+ return result.invalid!(errors: 'must be a Hash') unless result.value.is_a?(::Hash)
20
57
 
21
58
  errors = {}
22
59
 
@@ -30,7 +67,7 @@ module Plumb
30
67
  memo[key_r.value] = value_r.value
31
68
  end
32
69
 
33
- errors.empty? ? result.valid(parsed) : result.invalid(errors:)
70
+ errors.empty? ? result.valid!(parsed) : result.invalid!(errors:)
34
71
  end
35
72
 
36
73
  def filtered
@@ -39,17 +76,24 @@ module Plumb
39
76
 
40
77
  private def _inspect = "HashMap[#{@key_type.inspect}, #{@value_type.inspect}]"
41
78
 
42
- class FilteredHashMap
43
- include Composable
44
-
45
- attr_reader :children
46
-
47
- def initialize(key_type, value_type)
48
- @key_type = key_type
49
- @value_type = value_type
50
- @children = [key_type, value_type].freeze
51
- freeze
52
- end
79
+ # Same key/value types as a HashMap (so it inherits #initialize, #children
80
+ # and the hash-family #subtype_of?), but filters out invalid entries instead
81
+ # of rejecting the whole Hash.
82
+ class FilteredHashMap < HashMap
83
+ # Naming derives #node_name when Composable is *included* (on HashMap), so
84
+ # the subclass would otherwise inherit :hash_map. Restore its own name so
85
+ # visitors still dispatch to their :filtered_hash_map handlers.
86
+ def node_name = :filtered_hash_map
87
+
88
+ # A filtered map DROPS non-matching entries, so it changes the value
89
+ # regardless of its key/value types — never value-preserving.
90
+ def value_preserving? = false
91
+
92
+ # A filtered map is lenient: it accepts ANY hash and drops non-matching
93
+ # entries (it never rejects a Hash), so as a #>> consumer it accepts any
94
+ # hash-like value — not itself. Without this, `Hash >> Hash[K, V].filtered`
95
+ # would be flagged as an illegal narrowing.
96
+ def accepted_type = Types::EachPair
53
97
 
54
98
  def call(result)
55
99
  result.invalid(errors: 'must be a Hash') unless result.value.is_a?(::Hash)
@@ -60,7 +104,7 @@ module Plumb
60
104
  memo[key_r.value] = value_r.value if key_r.valid? && value_r.valid?
61
105
  end
62
106
 
63
- result.valid(hash)
107
+ result.valid!(hash)
64
108
  end
65
109
 
66
110
  private def _inspect = "HashMap[#{@key_type.inspect}, #{@value_type.inspect}].filtered"