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
data/lib/plumb/hash_class.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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))
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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(
|
|
95
|
-
|
|
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(
|
|
98
|
-
|
|
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
|
-
|
|
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 =
|
|
113
|
-
field_result = Result.valid(nil)
|
|
213
|
+
output = {}
|
|
114
214
|
|
|
115
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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) &&
|
|
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
|
-
|
|
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
|
data/lib/plumb/hash_map.rb
CHANGED
|
@@ -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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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"
|