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
|
@@ -7,6 +7,11 @@ module Plumb
|
|
|
7
7
|
class JSONSchemaVisitor
|
|
8
8
|
include VisitorHandlers
|
|
9
9
|
|
|
10
|
+
# Raised when a value that has no JSON-native representation would end up in
|
|
11
|
+
# the generated schema. Register a handler (via `.on(...)`) that converts the
|
|
12
|
+
# offending type to a JSON-native value to resolve it.
|
|
13
|
+
InvalidJSONValueError = Class.new(StandardError)
|
|
14
|
+
|
|
10
15
|
TYPE = 'type'
|
|
11
16
|
PROPERTIES = 'properties'
|
|
12
17
|
REQUIRED = 'required'
|
|
@@ -16,6 +21,8 @@ module Plumb
|
|
|
16
21
|
NOT = 'not'
|
|
17
22
|
ENUM = 'enum'
|
|
18
23
|
CONST = 'const'
|
|
24
|
+
REF = '$ref'
|
|
25
|
+
DEFS = '$defs'
|
|
19
26
|
ITEMS = 'items'
|
|
20
27
|
PATTERN = 'pattern'
|
|
21
28
|
MINIMUM = 'minimum'
|
|
@@ -25,15 +32,47 @@ module Plumb
|
|
|
25
32
|
MIN_LENGTH = 'minLength'
|
|
26
33
|
MAX_LENGTH = 'maxLength'
|
|
27
34
|
FORMAT = 'format'
|
|
35
|
+
EXCLUSIVE_MAXIMUM = 'exclusiveMaximum'
|
|
36
|
+
FORMAT_MINIMUM = 'formatMinimum'
|
|
37
|
+
FORMAT_MAXIMUM = 'formatMaximum'
|
|
38
|
+
FORMAT_EXCLUSIVE_MAXIMUM = 'formatExclusiveMaximum'
|
|
28
39
|
ENVELOPE = {
|
|
29
40
|
'$schema' => 'https://json-schema.org/draft-08/schema#'
|
|
30
41
|
}.freeze
|
|
31
42
|
|
|
32
43
|
def self.call(node, root: true)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
44
|
+
visitor = new
|
|
45
|
+
data = visitor.visit(node)
|
|
46
|
+
# Deferred types register named entries in a `$defs` table and reference
|
|
47
|
+
# them by `$ref` (see the :deferred handler). Hoist that table to the top of
|
|
48
|
+
# the document so the root-relative `#/$defs/...` pointers resolve.
|
|
49
|
+
defs = visitor.defs
|
|
50
|
+
data = data.merge(DEFS => defs) unless defs.empty?
|
|
51
|
+
data = ENVELOPE.merge(data) if root
|
|
52
|
+
normalize_json(data)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Recursively normalize the generated schema into JSON-native values and
|
|
56
|
+
# fail loudly on anything that has no JSON representation.
|
|
57
|
+
# Symbols are coerced to strings; everything that isn't a JSON-native value
|
|
58
|
+
# raises, so the user knows they need to register a custom visitor handler.
|
|
59
|
+
def self.normalize_json(value)
|
|
60
|
+
case value
|
|
61
|
+
when ::Hash
|
|
62
|
+
value.each_with_object({}) do |(key, val), hash|
|
|
63
|
+
hash[normalize_json(key)] = normalize_json(val)
|
|
64
|
+
end
|
|
65
|
+
when ::Array
|
|
66
|
+
value.map { |val| normalize_json(val) }
|
|
67
|
+
when ::Symbol
|
|
68
|
+
value.to_s
|
|
69
|
+
when ::String, ::Numeric, true, false, nil
|
|
70
|
+
value
|
|
71
|
+
else
|
|
72
|
+
raise InvalidJSONValueError,
|
|
73
|
+
"#{value.class} value #{value.inspect} cannot be serialized to JSON Schema. " \
|
|
74
|
+
'Register a Plumb::JSONSchemaVisitor.on(...) handler that converts it to a JSON-native value.'
|
|
75
|
+
end
|
|
37
76
|
end
|
|
38
77
|
|
|
39
78
|
# Some rules that are dependent on combinations of aggregates keys
|
|
@@ -45,52 +84,177 @@ module Plumb
|
|
|
45
84
|
|
|
46
85
|
private def stringify_keys(hash) = hash.transform_keys(&:to_s)
|
|
47
86
|
|
|
87
|
+
# The `$defs` table of materialized Deferred schemas built during this visit,
|
|
88
|
+
# hoisted to the document root by `.call`. Empty unless a Deferred was visited.
|
|
89
|
+
def defs = @defs || BLANK_HASH
|
|
90
|
+
|
|
91
|
+
# Register a Deferred under a stable `$defs` name and return that name. Keyed
|
|
92
|
+
# by the RESOLVED target type's identity (not the Deferred wrapper's), so
|
|
93
|
+
# distinct `defer { … }` wrappers pointing at the same type share one def.
|
|
94
|
+
# Resolving via #type is cheap (memoized) and does no visitor recursion, so we
|
|
95
|
+
# can key on it up front. The name is recorded BEFORE the body is visited, so a
|
|
96
|
+
# self-reference encountered while visiting finds the name already present and
|
|
97
|
+
# emits a `$ref` instead of recursing forever.
|
|
98
|
+
private def deferred_ref(node)
|
|
99
|
+
@defs ||= {}
|
|
100
|
+
@deferred_names ||= {}.compare_by_identity
|
|
101
|
+
target = node.type
|
|
102
|
+
existing = @deferred_names[target]
|
|
103
|
+
return existing if existing
|
|
104
|
+
|
|
105
|
+
name = "Deferred#{@deferred_names.size + 1}"
|
|
106
|
+
@deferred_names[target] = name
|
|
107
|
+
@defs[name] = visit(target)
|
|
108
|
+
name
|
|
109
|
+
end
|
|
110
|
+
|
|
48
111
|
on(:any) do |_node, props|
|
|
49
112
|
props
|
|
50
113
|
end
|
|
51
114
|
|
|
52
|
-
|
|
53
|
-
|
|
115
|
+
# The bottom type matches nothing — JSON Schema `{ "not": {} }` (the empty
|
|
116
|
+
# schema `{}` matches everything, so its negation matches nothing).
|
|
117
|
+
on(:never) do |_node, props|
|
|
118
|
+
props.merge('not' => {})
|
|
54
119
|
end
|
|
55
120
|
|
|
56
|
-
on(:
|
|
57
|
-
|
|
121
|
+
on(:pipeline) do |node, props|
|
|
122
|
+
visit_children(node, props)
|
|
58
123
|
end
|
|
59
124
|
|
|
60
125
|
on(:interface) do |_node, props|
|
|
61
126
|
props
|
|
62
127
|
end
|
|
63
128
|
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
129
|
+
# A Deferred materializes to a concrete type (via #type), but that type may
|
|
130
|
+
# reference the Deferred back (a recursive/self-referential schema), so we
|
|
131
|
+
# can't inline it — that would recurse forever. Instead each Deferred becomes a
|
|
132
|
+
# named `$defs` entry referenced by `$ref`, the standard JSON Schema idiom for
|
|
133
|
+
# recursion. `deferred_ref` builds the def (once) and returns its name; `.call`
|
|
134
|
+
# hoists the `$defs` table to the document root.
|
|
67
135
|
on(:deferred) do |node, props|
|
|
68
|
-
props
|
|
136
|
+
props.merge(REF => "#/#{DEFS}/#{deferred_ref(node)}")
|
|
69
137
|
end
|
|
70
138
|
|
|
71
139
|
on(:hash) do |node, props|
|
|
72
|
-
|
|
140
|
+
# Named (literal) keys become properties/required. Typed/catch-all keys are
|
|
141
|
+
# not fixed property names: the `_` catch-all becomes `additionalProperties`
|
|
142
|
+
# (a schema; `{}` for Any = any value), and a pattern-backed typed key becomes
|
|
143
|
+
# a `patternProperties` entry (reusing the Regexp -> `pattern` handler).
|
|
144
|
+
literal = node.literal_fields
|
|
145
|
+
schema = {
|
|
73
146
|
TYPE => 'object',
|
|
74
|
-
PROPERTIES =>
|
|
147
|
+
PROPERTIES => literal.each_with_object({}) do |(key, value), hash|
|
|
75
148
|
hash[key.to_s] = visit(value)
|
|
76
149
|
end,
|
|
77
|
-
REQUIRED =>
|
|
78
|
-
|
|
150
|
+
REQUIRED => literal.reject { |key, _value| key.optional? }.keys.map(&:to_s)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
schema['additionalProperties'] = visit(node.catch_all_type) if node.catch_all_type
|
|
154
|
+
|
|
155
|
+
patterns = node.matcher_fields.each_with_object({}) do |(key, value), h|
|
|
156
|
+
next if key.catch_all?
|
|
157
|
+
|
|
158
|
+
pattern = visit(key.matcher)[PATTERN]
|
|
159
|
+
h[pattern] = visit(value) if pattern
|
|
160
|
+
end
|
|
161
|
+
schema['patternProperties'] = patterns unless patterns.empty?
|
|
162
|
+
|
|
163
|
+
props.merge(schema)
|
|
79
164
|
end
|
|
80
165
|
|
|
81
166
|
on(:data) do |node, props|
|
|
82
167
|
visit_name :hash, node._schema, props
|
|
83
168
|
end
|
|
84
169
|
|
|
170
|
+
# A refinement (`And`): both sides describe the SAME value. Build from the
|
|
171
|
+
# input side and fold in the output side when it shares the input's type (a
|
|
172
|
+
# constraint such as `pattern`/`minimum`/`format`), or when the input is
|
|
173
|
+
# untyped. Both sides are validators over one value, so both are visited.
|
|
174
|
+
# A factored union `And(base, Or(suffix, …))` (built by Composable#| when
|
|
175
|
+
# branches share a base type). Unlike the generic `:and` handler, the
|
|
176
|
+
# disjunction is a *refinement* of the base, not a different type: render the
|
|
177
|
+
# base, then fold the (type-less) disjunction's `anyOf` into that same spec.
|
|
178
|
+
on(:refined_union) do |node, props|
|
|
179
|
+
base, disjunction = node.type.children # the And's two sides, not its io types
|
|
180
|
+
props = visit(base, props) # base -> {type: …}
|
|
181
|
+
visit(disjunction, props) # Or(suffixes) -> merge anyOf into it
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# A MEET (`Intersection`): both sides constrain the SAME value, so both specs
|
|
185
|
+
# describe it and both are merged — the left contributes the type, the right
|
|
186
|
+
# the narrowing keywords (`pattern`, `minimum`, `format`, …). No shape test is
|
|
187
|
+
# needed, which is the whole difference from the `:and` handler below: two
|
|
188
|
+
# sides that disagreed on `type` would describe an uninhabitable value, and
|
|
189
|
+
# `#&` folds that to Never before a schema is ever asked for.
|
|
190
|
+
on(:intersection) do |node, props|
|
|
191
|
+
left, right = node.children.map { |c| visit(c) }
|
|
192
|
+
merge_same_type_specs(props, left, right)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# A COMPOSITION (`And`): the right side may convert, so the two sides can
|
|
196
|
+
# describe genuinely different types. A JSON Schema describes accepted INPUTS,
|
|
197
|
+
# so build from the left and fold the right in only when it cannot contradict
|
|
198
|
+
# it — when the left is untyped (all we know comes from the right), or when
|
|
199
|
+
# both agree on `type` (the right is then narrowing the same type). When they
|
|
200
|
+
# disagree, the right is a conversion target and is dropped.
|
|
85
201
|
on(:and) do |node, props|
|
|
86
202
|
left, right = node.children.map { |c| visit(c) }
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
203
|
+
if !left.key?(TYPE) || left[TYPE] == right[TYPE]
|
|
204
|
+
merge_same_type_specs(props, left, right)
|
|
205
|
+
else
|
|
206
|
+
props.merge(left)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Combine two specs that describe the SAME value: merge both sets of keywords
|
|
211
|
+
# and keep whichever `type` is present. Shared by the two handlers above, which
|
|
212
|
+
# differ only in WHEN they are allowed to do this.
|
|
213
|
+
private def merge_same_type_specs(props, left, right)
|
|
214
|
+
merged = props.merge(left).merge(right)
|
|
215
|
+
type = left[TYPE] || right[TYPE]
|
|
216
|
+
type ? merged.merge(TYPE => type) : merged
|
|
91
217
|
end
|
|
92
218
|
|
|
93
|
-
# A
|
|
219
|
+
# A conversion (`Function`) produces a genuinely different value. A JSON
|
|
220
|
+
# Schema describes accepted *inputs*, so it is built from the input side and
|
|
221
|
+
# the output is dropped — and crucially NOT visited, so a transform whose
|
|
222
|
+
# output type has no schema handler (eg. a custom class via #build) is fine.
|
|
223
|
+
# Only when the input is untyped (eg. `Any.transform(::Integer)`) do we fall
|
|
224
|
+
# back to the output type, since that is then all we know.
|
|
225
|
+
# Encoder steps (see Plumb::Encoder) are plain Functions, so this covers
|
|
226
|
+
# them too: a decode-direction step's input IS the encoded form, which is
|
|
227
|
+
# why `Codec >> Type` describes the encoded side of a schema.
|
|
228
|
+
on(:function) do |node, props|
|
|
229
|
+
left = visit(node.input_type)
|
|
230
|
+
return props.merge(left) if left.key?(TYPE)
|
|
231
|
+
|
|
232
|
+
props.merge(left).merge(visit(node.output_type))
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# A filtered Hash may drop any field, so its schema is its relaxed
|
|
236
|
+
# (all-optional) output.
|
|
237
|
+
on(:filtered_hash) do |node, props|
|
|
238
|
+
props.merge(visit(node.output_type))
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# A JOIN (`Union`): every branch passes its value through, so this is a plain
|
|
242
|
+
# `anyOf` over the branch schemas. Nothing here has to look for a default: a
|
|
243
|
+
# `#default` is `(Undefined >> Static) | type`, whose first branch converts, so
|
|
244
|
+
# it is always a `:or` and never reaches this handler.
|
|
245
|
+
on(:union) do |node, props|
|
|
246
|
+
any_of = node.children.map { |c| visit(c) }.uniq.filter(&:any?)
|
|
247
|
+
return props.merge(any_of.first) if any_of.size == 1
|
|
248
|
+
|
|
249
|
+
props.merge(ANY_OF => splice_any_of(any_of))
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# A CHOICE (`Or`): a branch may convert, which is what makes the two special
|
|
253
|
+
# shapes below possible.
|
|
254
|
+
#
|
|
255
|
+
# A "default" value is `expected_value | (undefined >> static_value)`: the
|
|
256
|
+
# static branch's spec is a DEFAULT rather than an alternative, so it is folded
|
|
257
|
+
# into the other branch instead of becoming an `anyOf` arm.
|
|
94
258
|
on(:or) do |node, props|
|
|
95
259
|
left, right = node.children.map { |c| visit(c) }
|
|
96
260
|
any_of = [left, right].uniq.filter(&:any?)
|
|
@@ -100,10 +264,18 @@ module Plumb
|
|
|
100
264
|
val = any_of[defidx.zero? ? 1 : 0]
|
|
101
265
|
props.merge(val).merge(DEFAULT => any_of[defidx][DEFAULT])
|
|
102
266
|
else
|
|
103
|
-
props.merge(ANY_OF => any_of)
|
|
267
|
+
props.merge(ANY_OF => splice_any_of(any_of))
|
|
104
268
|
end
|
|
105
269
|
end
|
|
106
270
|
|
|
271
|
+
# anyOf is associative, so splice a child that is itself a BARE `anyOf` (a
|
|
272
|
+
# nested disjunction from an n-ary factored union) into this level to keep the
|
|
273
|
+
# schema flat. Only when it is pure anyOf — other keys would be lost, and a
|
|
274
|
+
# default-bearing branch is deliberately left un-spliced (see :or).
|
|
275
|
+
private def splice_any_of(any_of)
|
|
276
|
+
any_of.flat_map { |s| s.keys == [ANY_OF] ? s[ANY_OF] : [s] }.uniq
|
|
277
|
+
end
|
|
278
|
+
|
|
107
279
|
on(:not) do |node, props|
|
|
108
280
|
props.merge(NOT => visit_children(node))
|
|
109
281
|
end
|
|
@@ -120,10 +292,6 @@ module Plumb
|
|
|
120
292
|
visit(value, props)
|
|
121
293
|
end
|
|
122
294
|
|
|
123
|
-
on(:transform) do |node, props|
|
|
124
|
-
visit_children(node, props)
|
|
125
|
-
end
|
|
126
|
-
|
|
127
295
|
on(:undefined) do |_node, props|
|
|
128
296
|
props
|
|
129
297
|
end
|
|
@@ -162,7 +330,10 @@ module Plumb
|
|
|
162
330
|
end
|
|
163
331
|
|
|
164
332
|
on(:options_policy) do |node, props|
|
|
165
|
-
|
|
333
|
+
# Only an Array argument maps to `enum`. Any other argument was delegated
|
|
334
|
+
# to a Constraint by the policy, so its schema (eg. `minimum`/
|
|
335
|
+
# `maximum` for a Range) has already been built by visiting the children.
|
|
336
|
+
node.arg.is_a?(::Array) ? props.merge(ENUM => node.arg) : props
|
|
166
337
|
end
|
|
167
338
|
|
|
168
339
|
on(:with_size_attribute) do |node, props|
|
|
@@ -201,12 +372,19 @@ module Plumb
|
|
|
201
372
|
props
|
|
202
373
|
end
|
|
203
374
|
|
|
204
|
-
on(:
|
|
375
|
+
on(:constraint) do |node, props|
|
|
376
|
+
# A refinement matcher describes its base first (eg. `Integer[1..10]` gets
|
|
377
|
+
# `type: integer` from the base Integer), then folds in the matcher's own
|
|
378
|
+
# constraint (`minimum`/`maximum`/`pattern`/`const`).
|
|
379
|
+
props = visit(node.base, props) if node.base
|
|
380
|
+
|
|
205
381
|
# Set const if primitive
|
|
206
382
|
matcher = node.children.first
|
|
207
383
|
props = case matcher
|
|
208
384
|
when ::String, ::Symbol, ::Numeric
|
|
209
385
|
props.merge(CONST => matcher)
|
|
386
|
+
when ::Date, ::Time # also covers ::DateTime
|
|
387
|
+
props.merge(CONST => matcher.iso8601)
|
|
210
388
|
else
|
|
211
389
|
props
|
|
212
390
|
end
|
|
@@ -262,15 +440,32 @@ module Plumb
|
|
|
262
440
|
props.merge(PATTERN => node.source, TYPE => props[TYPE] || 'string')
|
|
263
441
|
end
|
|
264
442
|
|
|
443
|
+
# A Set is a membership matcher (`Set#===` is `include?`), so it maps to
|
|
444
|
+
# `enum` — the same shape the :options_policy handler produces for an Array
|
|
445
|
+
# argument. `Integer[1, 2, 3]` / `Integer[Set[1, 2, 3]]` build a Constraint
|
|
446
|
+
# whose base sets `type` and whose Set matcher folds in the members here.
|
|
447
|
+
on(::Set) do |node, props|
|
|
448
|
+
props.merge(ENUM => node.to_a)
|
|
449
|
+
end
|
|
450
|
+
|
|
265
451
|
on(::Range) do |node, props|
|
|
266
452
|
element = node.begin || node.end
|
|
267
453
|
opts = visit(element.class)
|
|
268
|
-
|
|
454
|
+
case element
|
|
455
|
+
when ::Numeric
|
|
269
456
|
opts[MINIMUM] = node.min if node.begin
|
|
457
|
+
opts[MAXIMUM] = node.max if node.end
|
|
458
|
+
when ::Date, ::Time # also covers ::DateTime
|
|
459
|
+
# JSON Schema has no native min/max for date/time strings, so use the
|
|
460
|
+
# `formatMinimum`/`formatMaximum` keywords (the format-assertion
|
|
461
|
+
# convention also supported by ajv) alongside `format`.
|
|
462
|
+
opts[FORMAT_MINIMUM] = node.begin.iso8601 if node.begin
|
|
270
463
|
if node.end
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
464
|
+
if node.exclude_end?
|
|
465
|
+
opts[FORMAT_EXCLUSIVE_MAXIMUM] = node.end.iso8601
|
|
466
|
+
else
|
|
467
|
+
opts[FORMAT_MAXIMUM] = node.end.iso8601
|
|
468
|
+
end
|
|
274
469
|
end
|
|
275
470
|
end
|
|
276
471
|
props.merge(opts)
|
|
@@ -328,10 +523,6 @@ module Plumb
|
|
|
328
523
|
}
|
|
329
524
|
end
|
|
330
525
|
|
|
331
|
-
on(:build) do |node, props|
|
|
332
|
-
visit_children(node, props)
|
|
333
|
-
end
|
|
334
|
-
|
|
335
526
|
on(:array) do |node, _props|
|
|
336
527
|
items_props = visit_children(node)
|
|
337
528
|
{ TYPE => 'array', ITEMS => items_props }
|
|
@@ -347,6 +538,33 @@ module Plumb
|
|
|
347
538
|
{ TYPE => 'array', 'prefixItems' => items }
|
|
348
539
|
end
|
|
349
540
|
|
|
541
|
+
# A Types::Range whose member type pins the bounds (`Types::Range[0...100]`)
|
|
542
|
+
# maps to the member's scalar schema plus JSON Schema's native bound
|
|
543
|
+
# keywords. Unlike the range-*matcher* handler below (`on(::Range)`), this
|
|
544
|
+
# preserves an exclusive end as `exclusiveMaximum` rather than approximating
|
|
545
|
+
# it as `maximum - 1` (which also can't be computed for float ranges).
|
|
546
|
+
# A member with no bounds (`Types::Range[Integer]`) falls back to its own
|
|
547
|
+
# schema.
|
|
548
|
+
on(:range) do |node, _props|
|
|
549
|
+
member = node.children.first
|
|
550
|
+
matcher = member.respond_to?(:children) ? member.children.first : nil
|
|
551
|
+
next visit(member) unless matcher.is_a?(::Range)
|
|
552
|
+
|
|
553
|
+
element = matcher.begin || matcher.end
|
|
554
|
+
opts = visit(element.class)
|
|
555
|
+
case element
|
|
556
|
+
when ::Numeric
|
|
557
|
+
opts[MINIMUM] = matcher.begin if matcher.begin
|
|
558
|
+
opts[matcher.exclude_end? ? EXCLUSIVE_MAXIMUM : MAXIMUM] = matcher.end if matcher.end
|
|
559
|
+
when ::Date, ::Time # also covers ::DateTime
|
|
560
|
+
opts[FORMAT_MINIMUM] = matcher.begin.iso8601 if matcher.begin
|
|
561
|
+
if matcher.end
|
|
562
|
+
opts[matcher.exclude_end? ? FORMAT_EXCLUSIVE_MAXIMUM : FORMAT_MAXIMUM] = matcher.end.iso8601
|
|
563
|
+
end
|
|
564
|
+
end
|
|
565
|
+
opts
|
|
566
|
+
end
|
|
567
|
+
|
|
350
568
|
on(:tagged_hash) do |node, _props|
|
|
351
569
|
required = Set.new
|
|
352
570
|
result = {
|
|
@@ -373,5 +591,6 @@ module Plumb
|
|
|
373
591
|
|
|
374
592
|
result.merge(REQUIRED => required.to_a)
|
|
375
593
|
end
|
|
594
|
+
|
|
376
595
|
end
|
|
377
596
|
end
|
data/lib/plumb/key.rb
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'plumb/composable'
|
|
4
|
+
|
|
3
5
|
module Plumb
|
|
6
|
+
# A hash-schema key. Two flavours:
|
|
7
|
+
#
|
|
8
|
+
# * a LITERAL key — a Symbol or String name (`name:`, `'name' =>`). It is
|
|
9
|
+
# matched by exact lookup and carries `#to_key`/`#to_sym`. A trailing `?`
|
|
10
|
+
# (`name?:`) marks it optional. This is the common case and behaves exactly
|
|
11
|
+
# as before.
|
|
12
|
+
# * a MATCHER key — any Plumb type / `#===` object used as a key
|
|
13
|
+
# (`Types::String[/^id_/] => ...`, or the `_` catch-all which wraps
|
|
14
|
+
# `Types::Any`). It matches other keys via `matcher === other_key`, has no
|
|
15
|
+
# single `#to_key`, and is inherently optional (it imposes no specific
|
|
16
|
+
# required key).
|
|
4
17
|
class Key
|
|
5
18
|
# OPTIONAL_EXP = /(\w+)(\?)?$/
|
|
6
19
|
OPTIONAL_EXP = /(?<word>[A-Za-z0-9_$]+)(?<qmark>\?)?/
|
|
@@ -9,27 +22,58 @@ module Plumb
|
|
|
9
22
|
key.is_a?(Key) ? key : new(key, symbolize:)
|
|
10
23
|
end
|
|
11
24
|
|
|
12
|
-
attr_reader :to_key, :to_sym, :node_name
|
|
25
|
+
attr_reader :to_key, :to_sym, :node_name, :matcher
|
|
13
26
|
|
|
14
27
|
def initialize(key, optional: false, symbolize: false)
|
|
15
|
-
key_type = symbolize ? Symbol : key.class
|
|
16
|
-
match = OPTIONAL_EXP.match(key.to_s)
|
|
17
|
-
key = match[:word]
|
|
18
|
-
@to_key = key_type == Symbol ? key.to_sym : key
|
|
19
|
-
@to_sym = @to_key.to_sym
|
|
20
|
-
@optional = !match[:qmark].nil? ? true : optional
|
|
21
28
|
@node_name = :key
|
|
29
|
+
if key.is_a?(::Symbol) || key.is_a?(::String)
|
|
30
|
+
key_type = symbolize ? Symbol : key.class
|
|
31
|
+
match = OPTIONAL_EXP.match(key.to_s)
|
|
32
|
+
name = match[:word]
|
|
33
|
+
@to_key = key_type == Symbol ? name.to_sym : name
|
|
34
|
+
@to_sym = @to_key.to_sym
|
|
35
|
+
@optional = !match[:qmark].nil? ? true : optional
|
|
36
|
+
@matcher = @to_key
|
|
37
|
+
@literal = true
|
|
38
|
+
else
|
|
39
|
+
# A type/matcher key. It matches other keys structurally, so it has no
|
|
40
|
+
# concrete #to_key and never imposes a required key.
|
|
41
|
+
@matcher = Composable.wrap(key)
|
|
42
|
+
@to_key = nil
|
|
43
|
+
@to_sym = nil
|
|
44
|
+
@optional = true
|
|
45
|
+
@literal = false
|
|
46
|
+
end
|
|
22
47
|
freeze
|
|
23
48
|
end
|
|
24
49
|
|
|
25
|
-
|
|
50
|
+
# A concrete Symbol/String key (exact lookup) vs a type/matcher key.
|
|
51
|
+
def literal? = @literal
|
|
26
52
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
53
|
+
# The `_` catch-all: a matcher key over the Any top, so it matches every key.
|
|
54
|
+
def catch_all? = !@literal && @matcher.is_a?(AnyClass)
|
|
55
|
+
|
|
56
|
+
# Does this key match `other` (a raw hash key)? Literal keys match by `==`
|
|
57
|
+
# (`Symbol#===`/`String#===`); matcher keys by `matcher === other`.
|
|
58
|
+
def match?(other) = @matcher === other
|
|
59
|
+
|
|
60
|
+
def to_s = (@to_key || @matcher).to_s
|
|
61
|
+
|
|
62
|
+
# Dedupe/lookup identity. Literal keys hash by name (unchanged); matcher keys
|
|
63
|
+
# hash by matcher class (structural equality disambiguates via #eql?), so two
|
|
64
|
+
# `_` catch-alls collapse. Two keys are eql? when both literal with the same
|
|
65
|
+
# name, or both matchers with equal matchers — optionality is deliberately
|
|
66
|
+
# ignored (so `name?`/`name` collapse), and HashClass#== compares it separately.
|
|
67
|
+
def hash = @literal ? @to_key.hash : @matcher.class.hash
|
|
30
68
|
|
|
31
69
|
def eql?(other)
|
|
32
|
-
other.
|
|
70
|
+
return false unless other.is_a?(Key)
|
|
71
|
+
|
|
72
|
+
if @literal
|
|
73
|
+
other.literal? && @to_key == other.to_key
|
|
74
|
+
else
|
|
75
|
+
!other.literal? && @matcher == other.matcher
|
|
76
|
+
end
|
|
33
77
|
end
|
|
34
78
|
|
|
35
79
|
def optional?
|
|
@@ -37,7 +81,13 @@ module Plumb
|
|
|
37
81
|
end
|
|
38
82
|
|
|
39
83
|
def inspect
|
|
40
|
-
|
|
84
|
+
if @literal
|
|
85
|
+
"#{@to_key}#{'?' if @optional}"
|
|
86
|
+
elsif catch_all?
|
|
87
|
+
'_'
|
|
88
|
+
else
|
|
89
|
+
@matcher.inspect
|
|
90
|
+
end
|
|
41
91
|
end
|
|
42
92
|
end
|
|
43
93
|
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'plumb/visitor_handlers'
|
|
4
|
+
|
|
5
|
+
module Plumb
|
|
6
|
+
# Renders a type composition as a Mermaid `flowchart`, so the flow-control
|
|
7
|
+
# algebra can be visualised. `>>` (And) becomes sequential arrows; `|` (Or)
|
|
8
|
+
# becomes a fork — the predecessor fans out to each alternative.
|
|
9
|
+
#
|
|
10
|
+
# Every `visit` returns `{ entries:, exits: }` — the node ids an incoming
|
|
11
|
+
# arrow should point INTO, and the ids outgoing arrows leave FROM — and
|
|
12
|
+
# accumulates node/edge lines as a side effect (the same stateful pattern
|
|
13
|
+
# JSONSchemaVisitor uses for `$defs`). And connects `left.exits -> right.entries`
|
|
14
|
+
# (a join over a preceding Or's branches); Or unions both branches' entries and
|
|
15
|
+
# exits without a node of its own, so whatever precedes it forks to both.
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# ((A >> B) | (C >> (D | B))).to_mermaid
|
|
19
|
+
class MermaidVisitor
|
|
20
|
+
include VisitorHandlers
|
|
21
|
+
|
|
22
|
+
def self.call(node, direction: 'LR')
|
|
23
|
+
new.render(node, direction:)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize
|
|
27
|
+
@nodes = []
|
|
28
|
+
@edges = []
|
|
29
|
+
@counter = 0
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def render(node, direction: 'LR')
|
|
33
|
+
root = visit(node)
|
|
34
|
+
# A top-level Or has more than one entry; anchor them to a synthetic start
|
|
35
|
+
# node so the diagram reads as a single connected fork.
|
|
36
|
+
entries = root[:entries]
|
|
37
|
+
if entries.size > 1
|
|
38
|
+
@nodes.unshift('start(( ))')
|
|
39
|
+
@edges = entries.map { |e| "start --> #{e}" } + @edges
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
lines = ["flowchart #{direction}"]
|
|
43
|
+
(@nodes + @edges).each { |line| lines << " #{line}" }
|
|
44
|
+
lines.join("\n")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Unknown nodes render as a single labeled box rather than raising, so any
|
|
48
|
+
# leaf type (Step, Transform, Constraint, Hash, Array, Boolean, …) degrades
|
|
49
|
+
# gracefully — mirrors MetadataVisitor's recursing override.
|
|
50
|
+
def on_missing_handler(node, _props, _method_name)
|
|
51
|
+
box(node)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# `>>` — sequence: connect the left's exits to the right's entries.
|
|
55
|
+
on(:and) do |node, _props|
|
|
56
|
+
left = visit(node.children[0])
|
|
57
|
+
right = visit(node.children[1])
|
|
58
|
+
connect(left[:exits], right[:entries])
|
|
59
|
+
{ entries: left[:entries], exits: right[:exits] }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# `|` — fork: no node of its own; union both branches so a preceding step
|
|
63
|
+
# forks to both entries and a following step joins both exits.
|
|
64
|
+
on(:or) do |node, _props|
|
|
65
|
+
left = visit(node.children[0])
|
|
66
|
+
right = visit(node.children[1])
|
|
67
|
+
{ entries: left[:entries] + right[:entries], exits: left[:exits] + right[:exits] }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Transparent wrappers — recurse into the single inner composition.
|
|
71
|
+
on(:pipeline) do |node, _props|
|
|
72
|
+
visit(node.children[0])
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
on(:policy) do |node, _props|
|
|
76
|
+
visit(node.children[0])
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A Metadata node is a box; its label comes from its own metadata (see #label_for).
|
|
80
|
+
on(:metadata) do |node, _props|
|
|
81
|
+
box(node)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# A Deferred is (possibly) recursive, so render it as an opaque box and do
|
|
85
|
+
# NOT recurse — mirrors why JSONSchemaVisitor special-cases Deferred.
|
|
86
|
+
on(:deferred) do |node, _props|
|
|
87
|
+
box(node)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def box(node)
|
|
93
|
+
id = next_id
|
|
94
|
+
@nodes << %(#{id}["#{escape(label_for(node))}"])
|
|
95
|
+
{ entries: [id], exits: [id] }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def connect(from_ids, to_ids)
|
|
99
|
+
from_ids.each do |from|
|
|
100
|
+
to_ids.each { |to| @edges << "#{from} --> #{to}" }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def next_id
|
|
105
|
+
@counter += 1
|
|
106
|
+
"n#{@counter}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Prefer a node's OWN metadata title/label, else fall back to #inspect. Only
|
|
110
|
+
# nodes that carry metadata directly are consulted — calling #metadata on an
|
|
111
|
+
# And/Or would merge the whole subtree's metadata (wrong for a single box).
|
|
112
|
+
def label_for(node)
|
|
113
|
+
md = own_metadata(node)
|
|
114
|
+
md[:title] || md[:label] || node.inspect
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def own_metadata(node)
|
|
118
|
+
case node.node_name
|
|
119
|
+
when :metadata then node.metadata
|
|
120
|
+
when :step then node._metadata
|
|
121
|
+
else BLANK_HASH
|
|
122
|
+
end
|
|
123
|
+
rescue StandardError
|
|
124
|
+
BLANK_HASH
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def escape(label) = label.to_s.gsub('"', '#quot;')
|
|
128
|
+
end
|
|
129
|
+
end
|
data/lib/plumb/metadata.rb
CHANGED
|
@@ -12,8 +12,12 @@ module Plumb
|
|
|
12
12
|
freeze
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
+
# Metadata nodes include their wrapped type in equality; otherwise unrelated
|
|
16
|
+
# types carrying the same metadata could collapse during subtype reductions.
|
|
17
|
+
# @param other [Object]
|
|
18
|
+
# @return [Boolean]
|
|
15
19
|
def ==(other)
|
|
16
|
-
other.
|
|
20
|
+
other.instance_of?(self.class) && type == other.type && @metadata == other.metadata
|
|
17
21
|
end
|
|
18
22
|
|
|
19
23
|
def metadata(data = Undefined)
|
|
@@ -24,6 +28,11 @@ module Plumb
|
|
|
24
28
|
end
|
|
25
29
|
end
|
|
26
30
|
|
|
31
|
+
# Metadata is a transparent wrapper: it delegates type-flow to the wrapped
|
|
32
|
+
# type.
|
|
33
|
+
def input_type = type.input_type
|
|
34
|
+
def output_type = type.output_type
|
|
35
|
+
|
|
27
36
|
def call(result) = type.call(result)
|
|
28
37
|
|
|
29
38
|
private def _inspect = "Metadata[#{type}, #{@metadata.inspect}]"
|