verquest 0.6.3 → 0.7.0
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/CHANGELOG.md +47 -0
- data/README.md +324 -51
- data/Rakefile +5 -0
- data/docs/rails.md +169 -0
- data/lib/verquest/base/condition_class_methods.rb +211 -0
- data/lib/verquest/base/private_class_methods.rb +34 -15
- data/lib/verquest/base/public_class_methods.rb +51 -1
- data/lib/verquest/base.rb +1 -0
- data/lib/verquest/conditions/branch.rb +111 -0
- data/lib/verquest/conditions/condition.rb +194 -0
- data/lib/verquest/conditions/conjunction.rb +39 -0
- data/lib/verquest/conditions/fragment_merge.rb +52 -0
- data/lib/verquest/conditions/property_merge.rb +47 -0
- data/lib/verquest/conditions/rule_set.rb +58 -0
- data/lib/verquest/conditions/rules/absent.rb +25 -0
- data/lib/verquest/conditions/rules/alternatives.rb +28 -0
- data/lib/verquest/conditions/rules/any.rb +27 -0
- data/lib/verquest/conditions/rules/equals.rb +22 -0
- data/lib/verquest/conditions/rules/names_rule.rb +23 -0
- data/lib/verquest/conditions/rules/none.rb +24 -0
- data/lib/verquest/conditions/rules/present.rb +22 -0
- data/lib/verquest/conditions/rules/property_rule.rb +48 -0
- data/lib/verquest/conditions/rules/satisfies.rb +22 -0
- data/lib/verquest/conditions/rules/value_in.rb +22 -0
- data/lib/verquest/conditions/rules.rb +17 -0
- data/lib/verquest/conditions.rb +36 -0
- data/lib/verquest/configuration.rb +19 -1
- data/lib/verquest/gem_version.rb +1 -1
- data/lib/verquest/helper_methods/conditional_properties.rb +126 -0
- data/lib/verquest/helper_methods/unnamed_one_of.rb +35 -0
- data/lib/verquest/properties/array.rb +15 -1
- data/lib/verquest/properties/base.rb +116 -10
- data/lib/verquest/properties/collection.rb +51 -18
- data/lib/verquest/properties/const.rb +5 -4
- data/lib/verquest/properties/enum.rb +1 -5
- data/lib/verquest/properties/field.rb +1 -0
- data/lib/verquest/properties/object.rb +38 -4
- data/lib/verquest/properties/one_of/discriminated_schema.rb +84 -0
- data/lib/verquest/properties/one_of.rb +86 -54
- data/lib/verquest/properties/reference.rb +49 -37
- data/lib/verquest/query/coercer.rb +165 -0
- data/lib/verquest/query/definition_check.rb +91 -0
- data/lib/verquest/query/parameters.rb +79 -0
- data/lib/verquest/query.rb +25 -0
- data/lib/verquest/transformer/collection.rb +32 -0
- data/lib/verquest/transformer/const_as_enum.rb +47 -0
- data/lib/verquest/transformer/layout.rb +83 -0
- data/lib/verquest/transformer/mapping_layout.rb +166 -0
- data/lib/verquest/transformer/one_of.rb +111 -0
- data/lib/verquest/transformer/path.rb +188 -0
- data/lib/verquest/transformer/plan.rb +183 -0
- data/lib/verquest/transformer/scope.rb +64 -0
- data/lib/verquest/transformer.rb +43 -880
- data/lib/verquest/version.rb +152 -79
- data/lib/verquest.rb +6 -0
- metadata +43 -8
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Verquest
|
|
4
|
+
class Transformer
|
|
5
|
+
# Turns a mapping hash into a Layout
|
|
6
|
+
#
|
|
7
|
+
# A mapping is flat, holds a single oneOf (variant mappings that repeat
|
|
8
|
+
# the other properties, plus metadata keys), or several oneOfs (`_oneOfs`).
|
|
9
|
+
# The oneOf paths and collection targets the hash doesn't hold are worked
|
|
10
|
+
# out from its paths. Only the deprecated `Transformer.new(mapping:)` uses
|
|
11
|
+
# it; versions build their layout from their properties.
|
|
12
|
+
#
|
|
13
|
+
# @deprecated Removed in 1.0.0 together with `Transformer.new(mapping:)`
|
|
14
|
+
#
|
|
15
|
+
# Internal to the transformer, not part of the public API.
|
|
16
|
+
class MappingLayout
|
|
17
|
+
# Builds the layout of a mapping
|
|
18
|
+
#
|
|
19
|
+
# @param mapping [Hash] The mapping in any of its formats
|
|
20
|
+
# @param discriminator [String, Symbol, nil] The discriminator of a root-level oneOf
|
|
21
|
+
# @return [Layout] The layout
|
|
22
|
+
def self.call(mapping, discriminator: nil)
|
|
23
|
+
new(mapping, discriminator&.to_s).layout
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private_class_method :new
|
|
27
|
+
|
|
28
|
+
# @!attribute [r] layout
|
|
29
|
+
# @return [Layout] The layout of the mapping
|
|
30
|
+
attr_reader :layout
|
|
31
|
+
|
|
32
|
+
# @param mapping [Hash] The mapping in any of its formats
|
|
33
|
+
# @param discriminator [String, nil] The discriminator of a root-level oneOf
|
|
34
|
+
def initialize(mapping, discriminator)
|
|
35
|
+
@layout = Layout.new
|
|
36
|
+
pairs, one_ofs = split(mapping, discriminator)
|
|
37
|
+
pairs.each { |source, target| layout.add_pair(source, target) }
|
|
38
|
+
one_ofs.each { |one_of| layout.add_one_of(one_of) }
|
|
39
|
+
one_ofs.each { |one_of| add_collections(one_of.path, one_ofs, pairs) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# Splits a mapping into its source and target pairs and its oneOfs
|
|
45
|
+
#
|
|
46
|
+
# @param mapping [Hash] The mapping in any of its formats
|
|
47
|
+
# @param discriminator [String, nil] The discriminator of a root-level oneOf
|
|
48
|
+
# @return [Array(Hash, Array<Layout::OneOfLayout>)] The pairs and the oneOfs
|
|
49
|
+
def split(mapping, discriminator)
|
|
50
|
+
pairs = mapping.reject { |key, value| METADATA_KEYS.include?(key) || value.is_a?(Hash) }
|
|
51
|
+
return [pairs, mapping["_oneOfs"].map { |one_of| one_of_layout(one_of) }] if mapping.key?("_oneOfs")
|
|
52
|
+
return [pairs, []] unless discriminator || mapping.key?("_discriminator") || mapping.key?("_variant_schemas")
|
|
53
|
+
|
|
54
|
+
single = mapping.except(*pairs.keys)
|
|
55
|
+
single = single.merge("_discriminator" => discriminator) if discriminator
|
|
56
|
+
one_of = one_of_layout(single)
|
|
57
|
+
[pairs.merge(shared_pairs(single, one_of.path)), [one_of]]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Builds the layout of a oneOf mapping
|
|
61
|
+
#
|
|
62
|
+
# @param mapping [Hash] The oneOf mapping, with metadata keys
|
|
63
|
+
# @return [Layout::OneOfLayout] The oneOf layout
|
|
64
|
+
def one_of_layout(mapping)
|
|
65
|
+
variants = variant_mappings(mapping)
|
|
66
|
+
path = one_of_path(mapping, variants)
|
|
67
|
+
discriminator = mapping["_discriminator"]
|
|
68
|
+
|
|
69
|
+
Layout::OneOfLayout.new(
|
|
70
|
+
path:,
|
|
71
|
+
discriminator:,
|
|
72
|
+
variants: variants.transform_values { |pairs| variant_layout(pairs, path) },
|
|
73
|
+
variant_schemas: (mapping["_variant_schemas"] unless discriminator),
|
|
74
|
+
null_target: mapping["_nullable_target_path"]
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Builds the layout of a variant from its pairs inside the oneOf
|
|
79
|
+
#
|
|
80
|
+
# @param pairs [Hash] The variant mapping
|
|
81
|
+
# @param path [String] The oneOf source path
|
|
82
|
+
# @return [Layout] The variant layout
|
|
83
|
+
def variant_layout(pairs, path)
|
|
84
|
+
layout.variant.tap do |variant|
|
|
85
|
+
# A Hash target is a oneOf nested in the variant, which the mapping formats can't express
|
|
86
|
+
pairs.each { |source, target| variant.add_pair(source, target) if target.is_a?(String) && inside?(source, path) }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Returns the variant mappings of a oneOf mapping
|
|
91
|
+
#
|
|
92
|
+
# @param mapping [Hash] The oneOf mapping
|
|
93
|
+
# @return [Hash<String, Hash>] Source and target pairs by variant name
|
|
94
|
+
def variant_mappings(mapping)
|
|
95
|
+
mapping.select { |key, value| !METADATA_KEYS.include?(key) && value.is_a?(Hash) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Returns the pairs a single-oneOf mapping repeats in every variant
|
|
99
|
+
#
|
|
100
|
+
# @param mapping [Hash] The oneOf mapping
|
|
101
|
+
# @param path [String] The oneOf source path
|
|
102
|
+
# @return [Hash] The pairs outside the oneOf property
|
|
103
|
+
def shared_pairs(mapping, path)
|
|
104
|
+
return {} if path.empty?
|
|
105
|
+
|
|
106
|
+
variant_mappings(mapping).values.first.reject { |source, _| inside?(source, path) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Returns the source path of a oneOf property
|
|
110
|
+
#
|
|
111
|
+
# Taken from the discriminator, the variant path or the nullable path.
|
|
112
|
+
# Otherwise, the oneOf is at the root, or at the items of the collection
|
|
113
|
+
# all its properties are in.
|
|
114
|
+
#
|
|
115
|
+
# @param mapping [Hash] The oneOf mapping
|
|
116
|
+
# @param variants [Hash<String, Hash>] Source and target pairs by variant name
|
|
117
|
+
# @return [String] The source path, empty for a root-level oneOf
|
|
118
|
+
def one_of_path(mapping, variants)
|
|
119
|
+
return mapping["_discriminator"].rpartition("/").first if mapping["_discriminator"]
|
|
120
|
+
return mapping["_variant_path"] || mapping["_nullable_path"] if mapping.key?("_variant_path") || mapping.key?("_nullable_path")
|
|
121
|
+
|
|
122
|
+
sources = variants.values.flat_map(&:keys)
|
|
123
|
+
collection = sources.first.include?("[]") ? "#{sources.first.split("[]").first}[]" : ""
|
|
124
|
+
(!collection.empty? && sources.all? { |source| inside?(source, collection) }) ? collection : ""
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Adds the collections a oneOf path enters, with their target paths
|
|
128
|
+
#
|
|
129
|
+
# @param path [String] The oneOf source path
|
|
130
|
+
# @param one_ofs [Array<Layout::OneOfLayout>] All oneOfs
|
|
131
|
+
# @param pairs [Hash] The source and target pairs outside the oneOfs
|
|
132
|
+
# @return [void]
|
|
133
|
+
def add_collections(path, one_ofs, pairs)
|
|
134
|
+
1.upto(path.scan("[]").size) do |depth|
|
|
135
|
+
prefix = "#{path.split("[]").first(depth).join("[]")}[]"
|
|
136
|
+
layout.add_collection(prefix, target_collection(prefix, one_ofs, pairs)) unless layout.collections.key?(prefix)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Returns the target path of a collection, which differs when it uses map:
|
|
141
|
+
#
|
|
142
|
+
# Found from a property mapped inside the collection, so a property moved
|
|
143
|
+
# out of it with map: "/..." doesn't count.
|
|
144
|
+
#
|
|
145
|
+
# @param prefix [String] The collection source path, ending with "[]"
|
|
146
|
+
# @param one_ofs [Array<Layout::OneOfLayout>] All oneOfs
|
|
147
|
+
# @param pairs [Hash] The source and target pairs outside the oneOfs
|
|
148
|
+
# @return [String] The collection target path, ending with "[]"
|
|
149
|
+
def target_collection(prefix, one_ofs, pairs)
|
|
150
|
+
depth = prefix.scan("[]").size
|
|
151
|
+
candidates = one_ofs.flat_map { |one_of| one_of.variants.values.flat_map { |variant| variant.pairs.to_a } } + pairs.to_a
|
|
152
|
+
_, target = candidates.find { |source, target_path| inside?(source, prefix) && target_path.scan("[]/").size >= depth }
|
|
153
|
+
target ? "#{target.split("[]/").first(depth).join("[]/")}[]" : prefix
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Checks whether a path is inside another one
|
|
157
|
+
#
|
|
158
|
+
# @param path [String] The path
|
|
159
|
+
# @param prefix [String] The containing path, empty for the root
|
|
160
|
+
# @return [Boolean] True if the path is below the prefix
|
|
161
|
+
def inside?(path, prefix)
|
|
162
|
+
prefix.empty? || path.start_with?("#{prefix}/")
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Verquest
|
|
4
|
+
class Transformer
|
|
5
|
+
# One oneOf in a mapping, with the variant mapping each input selects
|
|
6
|
+
#
|
|
7
|
+
# Paths are relative to the scope the oneOf is resolved in: the params, or
|
|
8
|
+
# a collection item.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# one_of = OneOf.new(
|
|
12
|
+
# path: Path.new("pet"),
|
|
13
|
+
# discriminator: Path.new("pet/type"),
|
|
14
|
+
# variants: {"dog" => Scope.new(pairs: [[Path.new("pet/bark"), Path.new("pet/bark")]], one_ofs: [], collections: [])}
|
|
15
|
+
# )
|
|
16
|
+
# one_of.variant_for({"pet" => {"type" => "dog", "bark" => true}}) # => the "dog" scope
|
|
17
|
+
#
|
|
18
|
+
# Internal to the transformer, not part of the public API.
|
|
19
|
+
class OneOf
|
|
20
|
+
# An empty list of pairs, oneOfs or collections
|
|
21
|
+
NONE = [].freeze
|
|
22
|
+
|
|
23
|
+
# The variant of an absent oneOf
|
|
24
|
+
NO_VARIANT = Scope.new(pairs: NONE, one_ofs: NONE, collections: NONE)
|
|
25
|
+
|
|
26
|
+
# Initialize a new OneOf
|
|
27
|
+
#
|
|
28
|
+
# @param path [Path] The oneOf property, empty for a oneOf at the scope root
|
|
29
|
+
# @param variants [Hash<String, Scope>] The scope of each variant by name
|
|
30
|
+
# @param discriminator [Path, nil] The discriminator property
|
|
31
|
+
# @param variant_schemas [Hash<String, Hash>, nil] Validation schemas by variant name, without a discriminator
|
|
32
|
+
# @param null_target [Path, nil] Where a null oneOf goes, defaults to the oneOf path
|
|
33
|
+
def initialize(path:, variants:, discriminator: nil, variant_schemas: nil, null_target: nil)
|
|
34
|
+
@path = path
|
|
35
|
+
@variants = variants
|
|
36
|
+
@discriminator = discriminator
|
|
37
|
+
@schemers = variant_schemas&.transform_values { |schema| schemer(schema) }
|
|
38
|
+
@null_variant = path.empty? ? NO_VARIANT : Scope.new(pairs: [[path, null_target || path].freeze].freeze, one_ofs: NONE, collections: NONE)
|
|
39
|
+
freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Returns the scope of the variant the data selects
|
|
43
|
+
#
|
|
44
|
+
# An absent oneOf maps nothing, and a null one maps only the null.
|
|
45
|
+
#
|
|
46
|
+
# @param data [Object] The scope data holding the oneOf
|
|
47
|
+
# @return [Scope] The variant scope
|
|
48
|
+
# @raise [Verquest::MappingError] If no variant or more than one variant matches
|
|
49
|
+
def variant_for(data)
|
|
50
|
+
value = path.extract(data)
|
|
51
|
+
return NO_VARIANT if value.equal?(Path::NOT_FOUND)
|
|
52
|
+
return null_variant if value.nil?
|
|
53
|
+
|
|
54
|
+
variants.fetch(discriminator ? discriminated_variant(data) : inferred_variant(value))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# @!attribute [r] path
|
|
60
|
+
# @return [Path] The oneOf property
|
|
61
|
+
# @!attribute [r] variants
|
|
62
|
+
# @return [Hash<String, Scope>] The scope of each variant by name
|
|
63
|
+
# @!attribute [r] discriminator
|
|
64
|
+
# @return [Path, nil] The discriminator property
|
|
65
|
+
# @!attribute [r] schemers
|
|
66
|
+
# @return [Hash<String, JSONSchemer::Schema>, nil] Schemers by variant name
|
|
67
|
+
# @!attribute [r] null_variant
|
|
68
|
+
# @return [Scope] The variant of a null oneOf
|
|
69
|
+
attr_reader :path, :variants, :discriminator, :schemers, :null_variant
|
|
70
|
+
|
|
71
|
+
# Compiles a variant schema in the configured JSON Schema version, like the validation schema
|
|
72
|
+
#
|
|
73
|
+
# @param schema [Hash] The variant validation schema
|
|
74
|
+
# @return [JSONSchemer::Schema] The schemer
|
|
75
|
+
def schemer(schema)
|
|
76
|
+
configuration = Verquest.configuration
|
|
77
|
+
schema = ConstAsEnum.call(schema) unless configuration.const_keyword?
|
|
78
|
+
JSONSchemer.schema(schema, meta_schema: configuration.json_schema_uri)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @param data [Object] The scope data holding the oneOf
|
|
82
|
+
# @return [String] The variant named by the discriminator value
|
|
83
|
+
# @raise [Verquest::MappingError] If the value is missing or unknown
|
|
84
|
+
def discriminated_variant(data)
|
|
85
|
+
value = discriminator.extract(data)
|
|
86
|
+
raise Verquest::MappingError, "Missing discriminator \"#{discriminator}\" for oneOf." if value.nil? || value.equal?(Path::NOT_FOUND)
|
|
87
|
+
|
|
88
|
+
name = value.to_s
|
|
89
|
+
return name if variants.key?(name)
|
|
90
|
+
|
|
91
|
+
raise Verquest::MappingError, "Unknown discriminator value \"#{value}\" for oneOf. No matching variant found."
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @param value [Object] The oneOf value
|
|
95
|
+
# @return [String] The only variant whose schema the value matches
|
|
96
|
+
# @raise [Verquest::MappingError] If no variant or more than one variant matches
|
|
97
|
+
def inferred_variant(value)
|
|
98
|
+
matches = []
|
|
99
|
+
schemers.each do |name, schemer|
|
|
100
|
+
matches << name if schemer.valid?(value)
|
|
101
|
+
break if matches.size == 2 # Two matches are enough to know the input is ambiguous
|
|
102
|
+
end
|
|
103
|
+
return matches.first if matches.one?
|
|
104
|
+
raise Verquest::MappingError, "No matching schema found for oneOf. Input does not match any of the defined schemas." if matches.empty?
|
|
105
|
+
|
|
106
|
+
raise Verquest::MappingError, "Ambiguous oneOf match. Input matches multiple schemas: #{matches.join(", ")}. " \
|
|
107
|
+
"Consider adding a discriminator or making schemas mutually exclusive."
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Verquest
|
|
4
|
+
class Transformer
|
|
5
|
+
# A parsed slash-separated path, like "user/addresses[]/zip"
|
|
6
|
+
#
|
|
7
|
+
# A part ending with "[]" is an array: reading it reads every item, and
|
|
8
|
+
# writing it writes every item at its position. Paths are parsed once,
|
|
9
|
+
# when the transformer is built, and reading or writing allocates only
|
|
10
|
+
# the values and hashes it returns.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# path = Path.new("users[]/name")
|
|
14
|
+
# path.extract({"users" => [{"name" => "A"}, {}]}) # => ["A", Path::NOT_FOUND]
|
|
15
|
+
# path.set({}, ["A", Path::NOT_FOUND]) # => {"users" => [{"name" => "A"}, {}]}
|
|
16
|
+
#
|
|
17
|
+
# Internal to the transformer, not part of the public API.
|
|
18
|
+
class Path
|
|
19
|
+
# Returned when a key is not in the data, to tell it apart from a null value
|
|
20
|
+
NOT_FOUND = Object.new.freeze
|
|
21
|
+
|
|
22
|
+
# A value below an explicit null in the data
|
|
23
|
+
#
|
|
24
|
+
# Returned by #extract with `nulls: true` when a parent of the path is
|
|
25
|
+
# null, so the matching part of the target path can be set to null too.
|
|
26
|
+
#
|
|
27
|
+
# @!attribute [r] depth_below
|
|
28
|
+
# @return [Integer] How many path parts are below the null parent
|
|
29
|
+
NullParent = Data.define(:depth_below)
|
|
30
|
+
|
|
31
|
+
# One part of a path
|
|
32
|
+
#
|
|
33
|
+
# @!attribute [r] key
|
|
34
|
+
# @return [String] The hash key
|
|
35
|
+
# @!attribute [r] array
|
|
36
|
+
# @return [Boolean] Whether the key holds an array
|
|
37
|
+
Part = Data.define(:key, :array)
|
|
38
|
+
|
|
39
|
+
# @!attribute [r] to_s
|
|
40
|
+
# @return [String] The path as given
|
|
41
|
+
# @!attribute [r] parts
|
|
42
|
+
# @return [Array<Part>] The parsed parts
|
|
43
|
+
attr_reader :to_s, :parts
|
|
44
|
+
|
|
45
|
+
# @param path [String] The slash-separated path
|
|
46
|
+
def initialize(path)
|
|
47
|
+
@to_s = path.dup.freeze
|
|
48
|
+
@parts = path.split("/").map { |part| Part.new(key: part.delete_suffix("[]"), array: part.end_with?("[]")) }.freeze
|
|
49
|
+
# One NullParent per part, so reading a null allocates nothing
|
|
50
|
+
@null_parents = Array.new(parts.length) { |index| NullParent.new(depth_below: parts.length - index - 1) }.freeze
|
|
51
|
+
@last = parts.length - 1
|
|
52
|
+
freeze
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Boolean] True for the empty path, which reads the data itself
|
|
56
|
+
def empty?
|
|
57
|
+
parts.empty?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Reads the value at this path
|
|
61
|
+
#
|
|
62
|
+
# @param data [Object] The data to read from
|
|
63
|
+
# @param nulls [Boolean] Whether to return a NullParent for a value below an explicit null
|
|
64
|
+
# @return [Object, NullParent, NOT_FOUND] The value, an array of values for array parts, or NOT_FOUND
|
|
65
|
+
def extract(data, nulls: false)
|
|
66
|
+
extract_at(data, 0, nulls)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Writes a value at this path
|
|
70
|
+
#
|
|
71
|
+
# Every item of an array keeps its position, even when none of its
|
|
72
|
+
# values are present, so empty items stay empty hashes. A NullParent
|
|
73
|
+
# sets the matching part of this path to null, unless it already has a value.
|
|
74
|
+
#
|
|
75
|
+
# @param result [Hash] The hash to write to
|
|
76
|
+
# @param value [Object, NullParent] The value, or an array of values for array parts
|
|
77
|
+
# @return [Hash] The result
|
|
78
|
+
def set(result, value)
|
|
79
|
+
set_at(result, value, 0)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# @!attribute [r] null_parents
|
|
85
|
+
# @return [Array<NullParent>] The NullParent for a null at each part
|
|
86
|
+
# @!attribute [r] last
|
|
87
|
+
# @return [Integer] The index of the last part
|
|
88
|
+
attr_reader :null_parents, :last
|
|
89
|
+
|
|
90
|
+
# @param data [Object] The data to read from
|
|
91
|
+
# @param index [Integer] The current part
|
|
92
|
+
# @param nulls [Boolean] Whether to return a NullParent below an explicit null
|
|
93
|
+
# @return [Object, NullParent, NOT_FOUND] The value
|
|
94
|
+
def extract_at(data, index, nulls)
|
|
95
|
+
return data if index > last
|
|
96
|
+
|
|
97
|
+
case data
|
|
98
|
+
when Hash then extract_from_hash(data, index, nulls)
|
|
99
|
+
# An array reached by a part without "[]" is read item by item
|
|
100
|
+
when Array then data.map { |item| extract_at(item, index, nulls) }
|
|
101
|
+
else NOT_FOUND
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @param data [Hash] The hash to read from
|
|
106
|
+
# @param index [Integer] The current part
|
|
107
|
+
# @param nulls [Boolean] Whether to return a NullParent below an explicit null
|
|
108
|
+
# @return [Object, NullParent, NOT_FOUND] The value
|
|
109
|
+
def extract_from_hash(data, index, nulls)
|
|
110
|
+
part = parts[index]
|
|
111
|
+
value = data.fetch(part.key) { return NOT_FOUND }
|
|
112
|
+
return null_parents[index] if value.nil? && nulls && index < last
|
|
113
|
+
return value.map { |item| extract_at(item, index + 1, nulls) } if part.array && value.is_a?(Array)
|
|
114
|
+
|
|
115
|
+
extract_at(value, index + 1, nulls)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @param result [Hash] The hash to write to
|
|
119
|
+
# @param value [Object, NullParent] The value
|
|
120
|
+
# @param index [Integer] The current part
|
|
121
|
+
# @return [Hash] The result
|
|
122
|
+
def set_at(result, value, index)
|
|
123
|
+
return set_null(result, index, [last - value.depth_below, index].max) if value.is_a?(NullParent)
|
|
124
|
+
|
|
125
|
+
part = parts[index]
|
|
126
|
+
if index == last
|
|
127
|
+
result[part.key] = value.is_a?(Array) ? plain(value) : value
|
|
128
|
+
elsif part.array && value.is_a?(Array)
|
|
129
|
+
set_items(result[part.key] ||= [], value, index + 1)
|
|
130
|
+
else
|
|
131
|
+
set_at(result[part.key] ||= {}, value, index + 1)
|
|
132
|
+
end
|
|
133
|
+
result
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# @param items [Array<Hash>] The array to write to
|
|
137
|
+
# @param values [Array<Object>] The values, one per item
|
|
138
|
+
# @param index [Integer] The part below the array
|
|
139
|
+
# @return [void]
|
|
140
|
+
def set_items(items, values, index)
|
|
141
|
+
values.each_with_index do |value, position|
|
|
142
|
+
item = (items[position] ||= {})
|
|
143
|
+
set_at(item, value, index) unless value.equal?(NOT_FOUND)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Turns the markers in an array of values into nulls
|
|
148
|
+
#
|
|
149
|
+
# An array of values reaches the last part when a property is moved out
|
|
150
|
+
# of collection items with map: "/...". Items without the value keep
|
|
151
|
+
# their position as null.
|
|
152
|
+
#
|
|
153
|
+
# @param values [Array<Object>] The values, possibly nested
|
|
154
|
+
# @return [Array<Object>] The values with NOT_FOUND and NullParent as nil
|
|
155
|
+
def plain(values)
|
|
156
|
+
# Most arrays are array fields without markers, which are kept as they are
|
|
157
|
+
return values unless values.any? { |value| value.is_a?(Array) || value.is_a?(NullParent) || value.equal?(NOT_FOUND) }
|
|
158
|
+
|
|
159
|
+
values.map do |value|
|
|
160
|
+
case value
|
|
161
|
+
when Array then plain(value)
|
|
162
|
+
when NullParent, NOT_FOUND then nil
|
|
163
|
+
else value
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Sets the part at null_index to null, unless it already has a value
|
|
169
|
+
#
|
|
170
|
+
# The null goes as many parts above the end of this path as it is above
|
|
171
|
+
# the end of the source path, but never above the current part.
|
|
172
|
+
#
|
|
173
|
+
# @param result [Hash] The hash to write to
|
|
174
|
+
# @param index [Integer] The current part
|
|
175
|
+
# @param null_index [Integer] The part to set to null
|
|
176
|
+
# @return [Hash] The result
|
|
177
|
+
def set_null(result, index, null_index)
|
|
178
|
+
node = result
|
|
179
|
+
index.upto(null_index - 1) do |position|
|
|
180
|
+
node = (node[parts[position].key] ||= {})
|
|
181
|
+
return result unless node.is_a?(Hash)
|
|
182
|
+
end
|
|
183
|
+
node[parts[null_index].key] = nil unless node.key?(parts[null_index].key)
|
|
184
|
+
result
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Verquest
|
|
4
|
+
class Transformer
|
|
5
|
+
# Groups a Layout into the scopes the transformer applies
|
|
6
|
+
#
|
|
7
|
+
# The plan is a scope for the params, with a nested scope for the items
|
|
8
|
+
# of each collection that holds a oneOf, since every item can select a
|
|
9
|
+
# different variant. Paths in a collection scope are relative to the item.
|
|
10
|
+
# A oneOf variant is a scope with the same prefixes as the scope holding
|
|
11
|
+
# the oneOf.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# plan = Plan.new(layout)
|
|
15
|
+
# plan.root # => Scope with the params pairs, oneOfs and collections
|
|
16
|
+
#
|
|
17
|
+
# Internal to the transformer, not part of the public API.
|
|
18
|
+
class Plan
|
|
19
|
+
# The collection prefixes the paths of a scope are relative to
|
|
20
|
+
#
|
|
21
|
+
# @!attribute [r] source
|
|
22
|
+
# @return [String] The source collection path ending with "[]", empty for the params
|
|
23
|
+
# @!attribute [r] target
|
|
24
|
+
# @return [String] The target collection path ending with "[]", empty for the params
|
|
25
|
+
Prefix = Data.define(:source, :target)
|
|
26
|
+
|
|
27
|
+
# The prefix of the params scope
|
|
28
|
+
ROOT = Prefix.new(source: "", target: "")
|
|
29
|
+
|
|
30
|
+
private_constant :Prefix, :ROOT
|
|
31
|
+
|
|
32
|
+
# @!attribute [r] root
|
|
33
|
+
# @return [Scope] The mapping of the params
|
|
34
|
+
attr_reader :root
|
|
35
|
+
|
|
36
|
+
# Builds the plan
|
|
37
|
+
#
|
|
38
|
+
# @param layout [Layout] The full paths of the request. Each layout must list every collection a oneOf path in it enters.
|
|
39
|
+
# @raise [Verquest::MappingError] If map: moves a oneOf variant property out of its collection item
|
|
40
|
+
def initialize(layout)
|
|
41
|
+
@paths = {}
|
|
42
|
+
@root = build_scope(layout.pairs, layout.one_ofs, ROOT, layout.collections)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Returns the target path of a collection
|
|
48
|
+
#
|
|
49
|
+
# @param collections [Hash<String, String>] Target path by source path of each collection
|
|
50
|
+
# @param source [String] The collection source path, ending with "[]"
|
|
51
|
+
# @return [String] The collection target path, ending with "[]"
|
|
52
|
+
# @raise [ArgumentError] If the layout doesn't list the collection
|
|
53
|
+
def collection_target(collections, source)
|
|
54
|
+
collections.fetch(source) { raise ArgumentError, "The layout doesn't list the collection #{source}" }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Builds a scope, with paths relative to the prefix
|
|
58
|
+
#
|
|
59
|
+
# The oneOfs inside a collection of this scope go to a nested scope for
|
|
60
|
+
# that collection's items, with the pairs mapped into those items. A pair
|
|
61
|
+
# moved out of the items with map: "/..." stays here.
|
|
62
|
+
#
|
|
63
|
+
# @param pairs [Hash] The source and target pairs in this scope
|
|
64
|
+
# @param one_ofs [Array<Layout::OneOfLayout>] The oneOfs in this scope
|
|
65
|
+
# @param prefix [Prefix] The collection prefixes, ROOT for the params
|
|
66
|
+
# @param collections [Hash<String, String>] Target path by source path of the collections this scope sees
|
|
67
|
+
# @return [Scope] The scope
|
|
68
|
+
def build_scope(pairs, one_ofs, prefix, collections)
|
|
69
|
+
by_collection = one_ofs.group_by { |one_of| collection_of(one_of.path, prefix.source) }
|
|
70
|
+
item_prefixes = (by_collection.keys - [""]).map { |source| Prefix.new(source:, target: collection_target(collections, source)) }
|
|
71
|
+
item_pairs = item_prefixes.to_h do |item_prefix|
|
|
72
|
+
[item_prefix, pairs.select { |source, target| inside?(source, item_prefix.source) && inside?(target, item_prefix.target) }]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
Scope.new(
|
|
76
|
+
pairs: relative_pairs(pairs.reject { |source, _| item_pairs.each_value.any? { |inner| inner.key?(source) } }, prefix),
|
|
77
|
+
one_ofs: by_collection.fetch("", []).map { |one_of| build_one_of(one_of, prefix) }.freeze,
|
|
78
|
+
collections: item_prefixes.map { |item_prefix| build_collection(item_prefix, by_collection[item_prefix.source], item_pairs[item_prefix], prefix, collections) }.freeze
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Builds a collection whose items hold a oneOf
|
|
83
|
+
#
|
|
84
|
+
# @param item_prefix [Prefix] The collection prefixes, ending with "[]"
|
|
85
|
+
# @param one_ofs [Array<Layout::OneOfLayout>] The oneOfs in the collection items
|
|
86
|
+
# @param pairs [Hash] The source and target pairs mapped into the items
|
|
87
|
+
# @param prefix [Prefix] The prefixes of the parent scope
|
|
88
|
+
# @param collections [Hash<String, String>] Target path by source path of the collections the parent scope sees
|
|
89
|
+
# @return [Collection] The collection
|
|
90
|
+
def build_collection(item_prefix, one_ofs, pairs, prefix, collections)
|
|
91
|
+
Collection.new(
|
|
92
|
+
source: path(relative(item_prefix.source.delete_suffix("[]"), prefix.source)),
|
|
93
|
+
target: path(relative(item_prefix.target.delete_suffix("[]"), prefix.target)),
|
|
94
|
+
scope: build_scope(pairs, one_ofs, item_prefix, collections)
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Builds a oneOf, with paths relative to the prefix
|
|
99
|
+
#
|
|
100
|
+
# @param one_of [Layout::OneOfLayout] The oneOf layout
|
|
101
|
+
# @param prefix [Prefix] The collection prefixes, ROOT for the params
|
|
102
|
+
# @return [OneOf] The oneOf
|
|
103
|
+
# @raise [Verquest::MappingError] If map: moves a variant property out of the collection item
|
|
104
|
+
def build_one_of(one_of, prefix)
|
|
105
|
+
check_variant_targets(one_of, prefix)
|
|
106
|
+
|
|
107
|
+
OneOf.new(
|
|
108
|
+
path: path(relative(one_of.path, prefix.source)),
|
|
109
|
+
variants: one_of.variants.transform_values { |variant| build_scope(variant.pairs, variant.one_ofs, prefix, variant.collections) }.freeze,
|
|
110
|
+
discriminator: one_of.discriminator && path(relative(one_of.discriminator, prefix.source)),
|
|
111
|
+
variant_schemas: one_of.variant_schemas,
|
|
112
|
+
null_target: one_of.null_target && path(relative(one_of.null_target, prefix.target))
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Rejects variant properties moved out of the collection item
|
|
117
|
+
#
|
|
118
|
+
# Each item selects its own variant, so its properties can only be
|
|
119
|
+
# mapped inside the item.
|
|
120
|
+
#
|
|
121
|
+
# @param one_of [Layout::OneOfLayout] The oneOf layout
|
|
122
|
+
# @param prefix [Prefix] The collection prefixes, ROOT for the params
|
|
123
|
+
# @return [void]
|
|
124
|
+
# @raise [Verquest::MappingError] If a variant property is mapped out of the item
|
|
125
|
+
def check_variant_targets(one_of, prefix)
|
|
126
|
+
one_of.variants.each_value do |variant|
|
|
127
|
+
source, target = variant.pairs.find { |_, target_path| !inside?(target_path, prefix.target) }
|
|
128
|
+
raise MappingError, "map: can't move a oneOf variant property out of its collection item (#{source} => #{target})" if source
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Returns the first collection a path enters below a scope
|
|
133
|
+
#
|
|
134
|
+
# @param path [String] The source path
|
|
135
|
+
# @param scope_source [String] The source prefix of the scope, empty for the params
|
|
136
|
+
# @return [String] The collection path ending with "[]", empty when the path enters none
|
|
137
|
+
def collection_of(path, scope_source)
|
|
138
|
+
below = relative(path, scope_source)
|
|
139
|
+
return "" unless below.include?("[]")
|
|
140
|
+
|
|
141
|
+
collection = "#{below.split("[]").first}[]"
|
|
142
|
+
scope_source.empty? ? collection : "#{scope_source}/#{collection}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Converts source and target pairs to paths relative to the prefix
|
|
146
|
+
#
|
|
147
|
+
# @param pairs [Hash] Source and target pairs
|
|
148
|
+
# @param prefix [Prefix] The collection prefixes, ROOT for the params
|
|
149
|
+
# @return [Array<Array(Path, Path)>] The relative pairs
|
|
150
|
+
def relative_pairs(pairs, prefix)
|
|
151
|
+
pairs.map { |source, target| [path(relative(source, prefix.source)), path(relative(target, prefix.target))].freeze }.freeze
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Checks whether a path is inside another one
|
|
155
|
+
#
|
|
156
|
+
# @param path [String] The path
|
|
157
|
+
# @param prefix [String] The containing path, empty for the root
|
|
158
|
+
# @return [Boolean] True if the path is below the prefix
|
|
159
|
+
def inside?(path, prefix)
|
|
160
|
+
prefix.empty? || path.start_with?("#{prefix}/")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Removes a prefix from a path
|
|
164
|
+
#
|
|
165
|
+
# @param path [String] The path
|
|
166
|
+
# @param prefix [String] The prefix, empty for none
|
|
167
|
+
# @return [String] The path below the prefix, empty for the prefix itself
|
|
168
|
+
def relative(path, prefix)
|
|
169
|
+
return "" if path == prefix
|
|
170
|
+
|
|
171
|
+
prefix.empty? ? path : path.delete_prefix("#{prefix}/")
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Returns a parsed path, shared between the pairs that use it
|
|
175
|
+
#
|
|
176
|
+
# @param path [String] The slash-separated path
|
|
177
|
+
# @return [Path] The parsed path
|
|
178
|
+
def path(path)
|
|
179
|
+
@paths[path.to_s] ||= Path.new(path.to_s)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|