katachi 0.0.0.1 → 0.0.1.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/.rubocop.yml +35 -1
- data/.tool-versions +1 -1
- data/Guardfile +17 -0
- data/README.md +296 -28
- data/Rakefile +14 -0
- data/cspell.config.yaml +19 -0
- data/docs/CONTRIBUTING.md +35 -0
- data/docs/HASH_COMPARISON_DESIGN.md +244 -0
- data/docs/PHILOSOPHY.md +43 -0
- data/lib/katachi/any_of.rb +36 -0
- data/lib/katachi/comparator/compare_array.rb +65 -0
- data/lib/katachi/comparator/compare_hash.rb +174 -0
- data/lib/katachi/comparator/compare_kv.rb +56 -0
- data/lib/katachi/comparator.rb +43 -0
- data/lib/katachi/comparison_result.rb +99 -0
- data/lib/katachi/predefined_shapes.rb +5 -0
- data/lib/katachi/rspec.rb +22 -0
- data/lib/katachi/shapes.rb +24 -0
- data/lib/katachi/version.rb +1 -1
- data/lib/katachi.rb +11 -2
- data/renovate.json +6 -0
- metadata +41 -16
- data/sig/katachi.rbs +0 -4
- /data/{CODE_OF_CONDUCT.md → docs/CODE_OF_CONDUCT.md} +0 -0
- /data/{SECURITY.md → docs/SECURITY.md} +0 -0
@@ -0,0 +1,244 @@
|
|
1
|
+
# Hash Comparison Design
|
2
|
+
|
3
|
+
Katachi's hash comparison is inspired by OpenAPI (formerly Swagger) specs.
|
4
|
+
|
5
|
+
Specifically, it's inspired by all the ways that I've repeatedly made
|
6
|
+
goofy mistakes when writing them.
|
7
|
+
|
8
|
+
Here's the story of how they led to the design of Katachi's hash comparison:
|
9
|
+
|
10
|
+
## 3 Different Versions Of Nullable
|
11
|
+
|
12
|
+
OpenAPI has handled `null` values a few different ways over the years.
|
13
|
+
|
14
|
+
- OpenAPI 2.0 (Swagger) didn't support `null` values at all, so people used `x-nullable: true`
|
15
|
+
- OpenAPI 3.0 make this official by supporting `nullable: true`
|
16
|
+
- OpenAPI 3.1 found a much simpler way by treating `null` as a type: `type: ["string", "null"]`
|
17
|
+
|
18
|
+
I like the 3.1 approach of treating `null` as just another possible type.
|
19
|
+
I decided to take it further with Ruby's tools for inspecting types.
|
20
|
+
We don't need the `type` description for fields -- Ruby can just tell us what type it is!
|
21
|
+
|
22
|
+
We can just literally use `nil` as a possible value!
|
23
|
+
|
24
|
+
All it takes is supporting a hash value being multiple types (e.g. `nil` or `String`).
|
25
|
+
|
26
|
+
That led to the creation of `Katachi::AnyOf`.
|
27
|
+
|
28
|
+
```ruby
|
29
|
+
shape = { email: AnyOf[String, nil] }
|
30
|
+
```
|
31
|
+
|
32
|
+
## OpenAPI Keys Are Optional By Default
|
33
|
+
|
34
|
+
> In the following description, if a field is not explicitly REQUIRED
|
35
|
+
> or described with a MUST or SHALL, it can be considered OPTIONAL.
|
36
|
+
>
|
37
|
+
> \- [OpenAPI 3.1.1 Specification](https://spec.openapis.org/oas/v3.1.1)
|
38
|
+
|
39
|
+
OpenAPI's decision to make all object keys optional by default has
|
40
|
+
caught me multiple times.
|
41
|
+
|
42
|
+
> "What do you mean the API response is empty?!? I tested it against the spec!"
|
43
|
+
>
|
44
|
+
> \- Me, multiple times
|
45
|
+
|
46
|
+
I wanted to prevent people from falling into that trap, so Katachi has all the keys required by default. The comparison logic would be a simple set difference:
|
47
|
+
|
48
|
+
```ruby
|
49
|
+
missing_keys = shape.keys - value.keys
|
50
|
+
```
|
51
|
+
|
52
|
+
## OpenAPI Extra Keys Are Allowed By Default
|
53
|
+
|
54
|
+
> Additional properties are allowed by default in OpenAPI.
|
55
|
+
> To enforce maximum strictness use additionalProperties: false to block all arbitrary data.
|
56
|
+
>
|
57
|
+
> \- [ApiMatic/OpenAPI/additionalProperties](https://www.apimatic.io/openapi/additionalproperties)
|
58
|
+
|
59
|
+
On the flip side, OpenAPI's decision to allow extra keys in an object by default has also
|
60
|
+
caught me multiple times.
|
61
|
+
|
62
|
+
> "Why is the API response so big?!? It's nowhere near that bloated in the spec!"
|
63
|
+
>
|
64
|
+
> \- Me, multiple times
|
65
|
+
|
66
|
+
Again, my chosen solution is to disallow extra keys by default.The comparison logic would be a simple set difference:
|
67
|
+
|
68
|
+
```ruby
|
69
|
+
extra_keys = value.keys - shape.keys
|
70
|
+
```
|
71
|
+
|
72
|
+
... Right? (cue foreboding music)
|
73
|
+
|
74
|
+
## Sane Defaults, But Inflexible
|
75
|
+
|
76
|
+
With those decisions, the core design is starting to take shape:
|
77
|
+
|
78
|
+
- All keys are required
|
79
|
+
- No extra keys are allowed
|
80
|
+
- `nil` is just another possible value; no special syntax needed
|
81
|
+
|
82
|
+
That's a good set of defaults, but it's not flexible enough for most use cases.
|
83
|
+
|
84
|
+
- Keys can be optional sometimes.
|
85
|
+
- Extra keys can be allowed sometimes.
|
86
|
+
- Sometimes you only want to test a few keys.
|
87
|
+
|
88
|
+
I needed to add a way to make keys optional and a way to allow extra keys.
|
89
|
+
|
90
|
+
## Allowing Optional Keys
|
91
|
+
|
92
|
+
I wanted users to not have to look up a special syntax or use a proprietary class for when
|
93
|
+
they want a hash key to be optional.
|
94
|
+
|
95
|
+
Borrowing from OpenAPI 3.1's handling of `null`, I added a special value `:$undefined` to indicate that a key can be missing without the object being invalid.
|
96
|
+
|
97
|
+
It's really convenient for users, but it comes with a new issue. We can no longer blindly assume that every key in the shape is required.
|
98
|
+
|
99
|
+
```diff
|
100
|
+
missing_keys = shape.keys - value.keys
|
101
|
+
+ missing_keys -= optional_keys()
|
102
|
+
```
|
103
|
+
|
104
|
+
## Allowing Extra Keys
|
105
|
+
|
106
|
+
Again, I wanted to make this easy for users without having to look up a special syntax. I eventually stumbled upon the idea of letting users add `Object => Object` to match any key-value pair.
|
107
|
+
|
108
|
+
e.g. Checking just the email
|
109
|
+
|
110
|
+
```ruby
|
111
|
+
compare(
|
112
|
+
value: User.last.attributes,
|
113
|
+
shape: {
|
114
|
+
"email" => request.params[:email],
|
115
|
+
Object => Object,
|
116
|
+
},
|
117
|
+
)
|
118
|
+
```
|
119
|
+
|
120
|
+
It looks a bit weird to have `Object` as a hash key, but it's perfectly valid Ruby.
|
121
|
+
|
122
|
+
```diff
|
123
|
+
extra_keys = value.keys - shape.keys
|
124
|
+
+ extra_keys -= matching_keys()
|
125
|
+
```
|
126
|
+
|
127
|
+
## Matching Priority
|
128
|
+
|
129
|
+
The problem with `Object => Object` is that it will match <ins>**literally any key-value pair**</ins>.
|
130
|
+
|
131
|
+
That makes it impossible for the hash comparison to not find a valid match.
|
132
|
+
|
133
|
+
So I had to put in a way for specific key matches (e.g. `email`) to take priority
|
134
|
+
over more general matches. That led to a whole branch of code for checking for exact key matches
|
135
|
+
between the shape and the value.
|
136
|
+
|
137
|
+
## Non-Required Keys
|
138
|
+
|
139
|
+
Another problem with using `Object => Object` for extra keys is that it's means that a key defined in the shape isn't necessarily required in the value.
|
140
|
+
|
141
|
+
If the comparison threw a `:hash_mismatch` when the user's hash didn't literally have a key-value pair `Object => Object`, that'd ruin that whole feature.
|
142
|
+
|
143
|
+
The lazy solution was to just ignore `Object => Object`, but what if users wanted to be a bit stricter about their extra keys?
|
144
|
+
|
145
|
+
- `Symbol => String` is a normal data structure to enforce.
|
146
|
+
- `:$email => User` is an excellent description for a lookup hash.
|
147
|
+
|
148
|
+
We need to figure out a way to distinguish between shape keys that are required and which ones are more general matching rules.
|
149
|
+
|
150
|
+
```diff
|
151
|
+
missing_keys = shape.keys - value.keys
|
152
|
+
missing_keys -= optional_keys()
|
153
|
+
+ missing_keys -= matcher_keys()
|
154
|
+
```
|
155
|
+
|
156
|
+
To keep things consistent, the solution ended up being to use the same `compare` algorithm on the hash keys as we do on any other value.
|
157
|
+
|
158
|
+
## Diagnostic Labels
|
159
|
+
|
160
|
+
All of these changes made the comparison logic much more complex than I had anticipated.
|
161
|
+
What really brought it into a whole new level of complexity was the need to provide diagnostic labels for each comparison. Telling users "your hash isn't a match and we're not telling you why" is a frustrating user experience.
|
162
|
+
|
163
|
+
It needs to report:
|
164
|
+
|
165
|
+
- Which keys were missing
|
166
|
+
- Which keys were extra
|
167
|
+
- Which values didn't match
|
168
|
+
|
169
|
+
That's too much information to stuff into a flat return value - it needs to be a nested structure where each comparison reports all the factors that led to the match or mismatch.
|
170
|
+
|
171
|
+
## The Final Design
|
172
|
+
|
173
|
+
That all combines to the general flow of hash comparison in Katachi:
|
174
|
+
|
175
|
+
```yaml
|
176
|
+
Definitions:
|
177
|
+
VHash: Value Hash
|
178
|
+
SHash: Shape Hash
|
179
|
+
VKey: Value Key
|
180
|
+
SKey: Shape Key
|
181
|
+
VValue: Value Value
|
182
|
+
SValue: Shape Value
|
183
|
+
|
184
|
+
Katachi::Result: Did the VHash match the SHash?
|
185
|
+
missing_keys: Are all keys in the shape present in the value?
|
186
|
+
{each SKey comparisons}:
|
187
|
+
- Determine if the SKey is required or optional.
|
188
|
+
- Is the SKey a general matching rule?
|
189
|
+
- Yes: Consider it optional.
|
190
|
+
- No: It's a specific key. Does the corresponding SValue contain :$undefined?
|
191
|
+
- Yes: SKey is optional.
|
192
|
+
- No: SKey is required.
|
193
|
+
- Check if the SKey is present in the VHash.
|
194
|
+
- Identical: label as exact match.
|
195
|
+
- Match Any: label as match.
|
196
|
+
- Key Not required: label as optional.
|
197
|
+
- Else: label as missing key.
|
198
|
+
extra_keys: Are there any VKeys that aren't in the SHash?
|
199
|
+
{each VKey comparisons}:
|
200
|
+
- Is the VKey exactly in the SHash?
|
201
|
+
- Yes: label it as an exact match.
|
202
|
+
- No: Does it match any SKey matchers?
|
203
|
+
- Compare each SKey matcher to the VKey.
|
204
|
+
- Yes: label that comparison as a general match.
|
205
|
+
- No: label that comparison as a mismatch.
|
206
|
+
- Did any of them match?
|
207
|
+
- Yes: label it as a match.
|
208
|
+
- No: label it as an extra key.
|
209
|
+
values: Do the VValues match the corresponding SValues in the shape?
|
210
|
+
{each VKey comparisons}:
|
211
|
+
- Is the VKey exactly in the SHash?
|
212
|
+
- Yes: Compare the corresponding VValue to the SValue.
|
213
|
+
- Identical: label VValue as an exact match.
|
214
|
+
- Match: label VValue as a match.
|
215
|
+
- No Match: label VValue as a mismatch.
|
216
|
+
- No: Does the VKey match any SKey matching rules?
|
217
|
+
- Yes: Compare the corresponding VValue to the SValue.
|
218
|
+
- Identical: label as exact match.
|
219
|
+
- Match: label as match.
|
220
|
+
- No Match: label as mismatch.
|
221
|
+
- No: label as mismatch.
|
222
|
+
```
|
223
|
+
|
224
|
+
## Conclusion
|
225
|
+
|
226
|
+
Yeah...
|
227
|
+
|
228
|
+
It was rough to code...
|
229
|
+
|
230
|
+
But it makes for an awesome user experience :)
|
231
|
+
|
232
|
+
```ruby
|
233
|
+
shape = {
|
234
|
+
:$guid => {
|
235
|
+
email: :$email,
|
236
|
+
first_name: String,
|
237
|
+
last_name: String,
|
238
|
+
preferred_name: AnyOf[String, nil],
|
239
|
+
admin_only_information: AnyOf[Symbol => String, :$undefined],
|
240
|
+
Symbol => Object,
|
241
|
+
},
|
242
|
+
}
|
243
|
+
expect(value: api_response.body, shape:).to be_match
|
244
|
+
```
|
data/docs/PHILOSOPHY.md
ADDED
@@ -0,0 +1,43 @@
|
|
1
|
+
# Philosophy
|
2
|
+
|
3
|
+
> Every well-established project should have a philosophy that guides its development.
|
4
|
+
> Without a core philosophy, development can languish in endless decision-making and have weaker APIs as a result.
|
5
|
+
|
6
|
+
- [TanStack Form Philosophy](https://tanstack.com/form/latest/docs/philosophy)
|
7
|
+
|
8
|
+
## Be Intuitive
|
9
|
+
|
10
|
+
- When defining shapes for comparison, we want users to be able to guess the correct action.
|
11
|
+
> - "I want this to be a string" -> use `String`
|
12
|
+
> - "I want this text to look like "foo" -> use `/foo/`
|
13
|
+
- If the user has to reference our docs more than once, we should aim for better.
|
14
|
+
|
15
|
+
## Be Minimal
|
16
|
+
|
17
|
+
- The smaller the public API, the faster users can pick it up and be productive.
|
18
|
+
- Rely on existing Ruby (e.g. `===`, `in`, procs, etc...) so people can use the tool at the skill level they're comfortable with.
|
19
|
+
|
20
|
+
## Be Predictable
|
21
|
+
|
22
|
+
- Minimal state. At the time of writing this, the only mutable state in the entire project is the shape library.
|
23
|
+
- No hidden side effects. Nothing should be altered unless the user explicitly asks for it.
|
24
|
+
|
25
|
+
## Be Reliable
|
26
|
+
|
27
|
+
- Extensively test all code to ensure it works as expected.
|
28
|
+
- Use static analysis tools to catch bugs before they happen.
|
29
|
+
- Use CI to ensure that the code works on all supported platforms.
|
30
|
+
- Eliminate dependencies whenever possible so we're less vulnerable to outside influences.
|
31
|
+
|
32
|
+
## Be Ruthless To Systems. Be Kind To People
|
33
|
+
|
34
|
+
- ^ quote from Michael Brooks
|
35
|
+
|
36
|
+
People -- whether users or contributors -- are going to make mistakes.
|
37
|
+
We should be understanding and forgiving when they do.
|
38
|
+
|
39
|
+
That being said, users suffer the consequences every time we let something slip. A small fix in our code saves each user from having to fix it themselves.
|
40
|
+
We should be meticulous in our code to avoid giving users a bad experience.
|
41
|
+
|
42
|
+
To that end, we lean heavily on automated dev tooling to keep everything on track.
|
43
|
+
Linters, formatters, spellcheckers, scanners -- we'll use it all.
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "comparison_result"
|
4
|
+
require_relative "comparator"
|
5
|
+
|
6
|
+
# AnyOf is used for allowing multiple shapes to be matched a single value.
|
7
|
+
# If any of the shapes match the value, the value is considered a match.
|
8
|
+
# If none of the shapes match the value, the value is considered a mismatch.
|
9
|
+
# AnyOf is used in the following way:
|
10
|
+
# Katachi::Comparator.compare(value, Katachi::AnyOf[shape1, shape2, shape3])
|
11
|
+
class Katachi::AnyOf
|
12
|
+
include Enumerable
|
13
|
+
|
14
|
+
# AnyOf[shape1, shape2, shape3] is a shortcut for AnyOf.new(shape1, shape2, shape3)
|
15
|
+
def self.[](...) = new(...)
|
16
|
+
def initialize(*shapes) = (@shapes = shapes)
|
17
|
+
|
18
|
+
def each(&) = @shapes.each(&)
|
19
|
+
|
20
|
+
# AnyOf is considered a match if any of the shapes match the value
|
21
|
+
# If none of the shapes match the value, AnyOf is considered a mismatch
|
22
|
+
def kt_compare(value)
|
23
|
+
child_results = @shapes.to_h { |shape| [shape, Katachi::Comparator.compare(value:, shape:)] }
|
24
|
+
Katachi::ComparisonResult.new(
|
25
|
+
value:,
|
26
|
+
shape: @shapes,
|
27
|
+
code: child_results.values.any?(&:match?) ? :any_of_match : :any_of_mismatch,
|
28
|
+
child_results:,
|
29
|
+
)
|
30
|
+
end
|
31
|
+
|
32
|
+
# normally this `.inspect` redefinition would be for `.to_s` but Hash.to_s calls
|
33
|
+
# inspect on the keys and values, so we have to redefine inspect instead
|
34
|
+
# if we want a user-friendly string representation of complex objects
|
35
|
+
def inspect = "AnyOf[#{@shapes.map(&:inspect).join(", ")}]"
|
36
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Katachi::Comparator.compare_array is the main entry point for comparing array.
|
4
|
+
# It is called by Katachi::Comparator.compare and should not be called directly.
|
5
|
+
# It returns a Katachi::ComparisonResult object.
|
6
|
+
module Katachi::Comparator
|
7
|
+
# Compare a value that is an array against a shape
|
8
|
+
# This method is called by Katachi::Comparator.compare and should not be called directly.
|
9
|
+
# It is not private so that it can be tested directly.
|
10
|
+
#
|
11
|
+
# @param value [Array] the value to compare
|
12
|
+
# @param shape [Object] the shape to compare against
|
13
|
+
# @return [Katachi::ComparisonResult] the result of the comparison
|
14
|
+
def self.compare_array(value:, shape:)
|
15
|
+
failure = precompare_array(value:, shape:)
|
16
|
+
return failure if failure
|
17
|
+
|
18
|
+
child_results = compare_array_elements(array: value, shape:)
|
19
|
+
# All array elements must be valid against at least one sub_shape
|
20
|
+
is_match = child_results.values.all?(&:match?)
|
21
|
+
code = is_match ? :array_is_match : :array_is_mismatch
|
22
|
+
Katachi::ComparisonResult.new(value:, shape:, code:, child_results:)
|
23
|
+
end
|
24
|
+
|
25
|
+
# Check for early exit conditions that don't require iterating over the array
|
26
|
+
#
|
27
|
+
# @param value [Array] the value to compare
|
28
|
+
# @param shape [Object] the shape to compare against
|
29
|
+
# @return [Katachi::ComparisonResult, nil] the result of the comparison, or nil if no early exit condition is met
|
30
|
+
private_class_method def self.precompare_array(value:, shape:)
|
31
|
+
raise ArgumentError, "checked value must be an array" unless value.is_a?(Array)
|
32
|
+
|
33
|
+
early_exit_code = if shape == Array then :array_class_matches_any_array
|
34
|
+
elsif !shape.is_a?(Array) then :class_mismatch
|
35
|
+
elsif value == shape then :array_is_exact_match
|
36
|
+
elsif value.empty? then :array_is_empty
|
37
|
+
end
|
38
|
+
return unless early_exit_code
|
39
|
+
|
40
|
+
Katachi::ComparisonResult.new(value:, shape:, code: early_exit_code)
|
41
|
+
end
|
42
|
+
|
43
|
+
# Compare each element of the array against the shape
|
44
|
+
#
|
45
|
+
# @param array [Array] the array to compare
|
46
|
+
# @param shape [Object] the shape to compare against
|
47
|
+
# @return [Hash] a hash of the comparison results for each unique element
|
48
|
+
private_class_method def self.compare_array_elements(array:, shape:)
|
49
|
+
# Use uniq in this method so that
|
50
|
+
# a) we're not doing redundant checks, and
|
51
|
+
# b) the results are a readable length for large array with lots of overlap
|
52
|
+
array.uniq.to_h do |element|
|
53
|
+
element_checks = shape.to_h { |sub_shape| [sub_shape, compare(value: element, shape: sub_shape)] }
|
54
|
+
[
|
55
|
+
element,
|
56
|
+
Katachi::ComparisonResult.new(
|
57
|
+
value: element,
|
58
|
+
shape:,
|
59
|
+
code: element_checks.values.any?(&:match?) ? :array_element_match : :array_element_mismatch,
|
60
|
+
child_results: element_checks,
|
61
|
+
)
|
62
|
+
]
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,174 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "compare_kv"
|
4
|
+
|
5
|
+
# Katachi::Comparator.compare_hash is the main entry point for comparing hashes.
|
6
|
+
# It is called by Katachi::Comparator.compare and should not be called directly.
|
7
|
+
# It returns a Katachi::ComparisonResult object.
|
8
|
+
#
|
9
|
+
# WARNING: HERE BE DRAGONS
|
10
|
+
# The methods in this section of the module are gnarly and heavily recursive.
|
11
|
+
# For the story of why it was built this way, see `docs/HASH_COMPARISON_DESIGN.md`.
|
12
|
+
module Katachi::Comparator
|
13
|
+
# Compare a value that is a hash against a shape
|
14
|
+
# This method is called by Katachi::Comparator.compare and should not be called directly.
|
15
|
+
# It is not private so that it can be tested directly.
|
16
|
+
#
|
17
|
+
# @param value [Hash] the value to compare
|
18
|
+
# @param shape [Object] the shape to compare against
|
19
|
+
# @return [Katachi::ComparisonResult] the result of the comparison
|
20
|
+
def self.compare_hash(value:, shape:)
|
21
|
+
failure = precompare_hash(value:, shape:)
|
22
|
+
return failure if failure
|
23
|
+
|
24
|
+
child_results = {
|
25
|
+
"$required_keys": compare_hash_required_keys(value_keys: value.keys, shape:),
|
26
|
+
"$extra_keys": compare_hash_extra_keys(value_keys: value.keys, shape_keys: shape.keys),
|
27
|
+
"$values": compare_hash_values(value:, shape:),
|
28
|
+
}
|
29
|
+
# All categories of checks must pass for the hash to be a match
|
30
|
+
code = child_results.values.all?(&:match?) ? :hash_is_match : :hash_is_mismatch
|
31
|
+
Katachi::ComparisonResult.new(value:, shape:, code:, child_results:)
|
32
|
+
end
|
33
|
+
|
34
|
+
# Check for early exit conditions that don't require iterating over the hash
|
35
|
+
#
|
36
|
+
# @param value [Hash] the value to compare
|
37
|
+
# @param shape [Object] the shape to compare against
|
38
|
+
# @return [Katachi::ComparisonResult, nil] the result of the comparison, or nil if no early exit condition is met
|
39
|
+
private_class_method def self.precompare_hash(value:, shape:)
|
40
|
+
raise ArgumentError, "checked value must be a hash" unless value.is_a?(Hash)
|
41
|
+
|
42
|
+
early_exit_code = if shape == Hash then :hash_class_matches_any_hash
|
43
|
+
elsif !shape.is_a?(Hash) then :class_mismatch
|
44
|
+
elsif value == shape then :hash_is_exact_match
|
45
|
+
else
|
46
|
+
return
|
47
|
+
end
|
48
|
+
|
49
|
+
Katachi::ComparisonResult.new(value:, shape:, code: early_exit_code)
|
50
|
+
end
|
51
|
+
|
52
|
+
# Examines the keys of the shape to determine if any are required
|
53
|
+
# and then checks that the value has all required keys.
|
54
|
+
# It takes the full shape as an argument rather than just the keys
|
55
|
+
# because `:$undefined` in the `value` portion of a key-value pair
|
56
|
+
# affects whether a key is allowed to be omitted.
|
57
|
+
#
|
58
|
+
# @param value_keys [Array] the keys of the value
|
59
|
+
# @param shape [Hash] the shape to compare against
|
60
|
+
# @return [Katachi::ComparisonResult] the result of the comparison
|
61
|
+
private_class_method def self.compare_hash_required_keys(value_keys:, shape:)
|
62
|
+
checks = shape.keys.filter_map do |k|
|
63
|
+
check_key_requirement_status(value_keys:, shape:, shape_key: k) if required_key?(k)
|
64
|
+
end
|
65
|
+
Katachi::ComparisonResult.new(
|
66
|
+
value: value_keys,
|
67
|
+
shape:,
|
68
|
+
code: checks.all?(&:match?) ? :hash_has_no_missing_keys : :hash_has_missing_keys,
|
69
|
+
child_results: checks.to_h { |check| [check.shape, check] },
|
70
|
+
)
|
71
|
+
end
|
72
|
+
|
73
|
+
# Check if a key is present, optional, or missing
|
74
|
+
#
|
75
|
+
# @param value_keys [Array] the keys of the value
|
76
|
+
# @param shape [Hash] the shape to compare against
|
77
|
+
# @param shape_key [Object] the key from the shape to check the value_keys for
|
78
|
+
# @return [Katachi::ComparisonResult] the result of the comparison
|
79
|
+
private_class_method def self.check_key_requirement_status(value_keys:, shape:, shape_key:)
|
80
|
+
shared = {
|
81
|
+
shape: shape_key,
|
82
|
+
child_results: value_keys.to_h { |v_key| [v_key, compare(value: v_key, shape: shape_key)] },
|
83
|
+
}
|
84
|
+
if value_keys.include?(shape_key)
|
85
|
+
Katachi::ComparisonResult.new(value: shape_key, code: :hash_key_exact_match, **shared)
|
86
|
+
elsif shared[:child_results].values.any?(&:match?)
|
87
|
+
Katachi::ComparisonResult.new(value: value_keys, code: :hash_key_match, **shared)
|
88
|
+
elsif optional_key?(shape[shape_key])
|
89
|
+
Katachi::ComparisonResult.new(value: :$undefined, code: :hash_key_optional, **shared)
|
90
|
+
else
|
91
|
+
Katachi::ComparisonResult.new(value: :$undefined, code: :hash_key_missing, **shared)
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
# Determine if a key is required
|
96
|
+
# A key is not considered to be required if it implements a case
|
97
|
+
# equality operator (`===`) for loosely matching values.
|
98
|
+
# This is typically used for matching classes, ranges, regexes, etc.
|
99
|
+
# Arrays, hashes, and AnyOf are considered required if all of contents are required
|
100
|
+
#
|
101
|
+
# @param key [Object] a key in the shape that we want to know if it fits the criteria for being required
|
102
|
+
# @return [Boolean] true if the key is required, false
|
103
|
+
private_class_method def self.required_key?(key) # rubocop:disable Metrics/CyclomaticComplexity
|
104
|
+
case key
|
105
|
+
when Array, Katachi::AnyOf then key.all? { |k| required_key?(k) }
|
106
|
+
when Hash then key.all? { |(k, v)| required_key?(k) && required_key?(v) }
|
107
|
+
when ->(k) { Katachi::Shapes.valid_key?(k) } then required_key?(Katachi::Shapes[key])
|
108
|
+
when ->(k) { k.method(:===) != k.method(:==) } then false
|
109
|
+
else true
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
# Determine if a key is optional via the presence of the `$undefined` shape
|
114
|
+
# It uses `compare` instead of `.include?` because the shape may be a reference
|
115
|
+
# to a shape like `:$optional_string --> Katachi::AnyOf[String, :$undefined]`.
|
116
|
+
#
|
117
|
+
# @param shape_value [Object] the "value" portion of a key-value pair in the shape
|
118
|
+
# @return [Boolean] true if the key is optional, false otherwise
|
119
|
+
private_class_method def self.optional_key?(shape_value) = compare(value: :$undefined, shape: shape_value).match?
|
120
|
+
|
121
|
+
# Compare the keys of the value against the keys of the shape.
|
122
|
+
#
|
123
|
+
# @param value_keys [Array] the keys of the value
|
124
|
+
# @param shape_keys [Array] the keys of the shape
|
125
|
+
# @return [Katachi::ComparisonResult] the result of the comparison
|
126
|
+
private_class_method def self.compare_hash_extra_keys(value_keys:, shape_keys:)
|
127
|
+
checks = value_keys.map { |key| check_extra_key_status(shape_keys:, value_key: key) }
|
128
|
+
Katachi::ComparisonResult.new(
|
129
|
+
value: value_keys,
|
130
|
+
shape: shape_keys,
|
131
|
+
code: checks.all?(&:match?) ? :hash_has_no_extra_keys : :hash_has_extra_keys,
|
132
|
+
child_results: checks.to_h { |check| [check.value, check] },
|
133
|
+
)
|
134
|
+
end
|
135
|
+
|
136
|
+
# Check if a key is exactly equal (==) to a key in the shape
|
137
|
+
# or if it matches via our usual `compare` method.
|
138
|
+
#
|
139
|
+
# @param shape_keys [Array] the keys of the shape
|
140
|
+
# @param value_key [Object] the key to check if it matches any of the shape keys
|
141
|
+
# @return [Katachi::ComparisonResult] the result of the comparison
|
142
|
+
private_class_method def self.check_extra_key_status(shape_keys:, value_key:)
|
143
|
+
key_results = shape_keys.to_h { |s_key| [s_key, compare(value: value_key, shape: s_key)] }
|
144
|
+
code = if shape_keys.include?(value_key) then :hash_key_exactly_allowed
|
145
|
+
elsif key_results.values.any?(&:match?) then :hash_key_match_allowed
|
146
|
+
else
|
147
|
+
:hash_key_not_allowed
|
148
|
+
end
|
149
|
+
Katachi::ComparisonResult.new(value: value_key, shape: shape_keys, code:, child_results: key_results)
|
150
|
+
end
|
151
|
+
|
152
|
+
# Compare the values of the hash against the values of the shape.
|
153
|
+
# In the case where the exact same key is present in both the value and the shape,
|
154
|
+
# then it gets sent to be compared by `compare_specific_kv`.
|
155
|
+
# Otherwise, it gets sent to be compared by `compare_general_kv`.
|
156
|
+
# This is to support when users want to match a subset of the keys in a hash.
|
157
|
+
# e.g. `compare(value: User.last, shape: { email: request.params[:email], Symbol => Object })`
|
158
|
+
#
|
159
|
+
# @param value [Hash] the value to compare
|
160
|
+
# @param shape [Hash] the hash shape to compare against
|
161
|
+
# @return [Katachi::ComparisonResult] the result of the comparison
|
162
|
+
private_class_method def self.compare_hash_values(value:, shape:)
|
163
|
+
individual_checks = value.keys.to_h do |v_key|
|
164
|
+
value_kv = value.slice(v_key)
|
165
|
+
[value_kv, shape.key?(v_key) ? compare_specific_kv(value_kv:, shape:) : compare_general_kv(value_kv:, shape:)]
|
166
|
+
end
|
167
|
+
Katachi::ComparisonResult.new(
|
168
|
+
value:,
|
169
|
+
shape:,
|
170
|
+
code: individual_checks.values.all?(&:match?) ? :hash_values_are_match : :hash_values_are_mismatch,
|
171
|
+
child_results: individual_checks,
|
172
|
+
)
|
173
|
+
end
|
174
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# This section of the module contains the logic for comparing key-value pairs against shapes
|
4
|
+
# It is called by Katachi::Comparator.compare_hash and cannot be called directly
|
5
|
+
module Katachi::Comparator
|
6
|
+
# Compares a single key-value pair against a shape when the key exactly matches a shape key
|
7
|
+
# @param value_kv [Hash] A single key-value pair to compare
|
8
|
+
# @param shape [Hash] The shape to compare against
|
9
|
+
# @return [Katachi::ComparisonResult] The result of the comparison
|
10
|
+
private_class_method def self.compare_specific_kv(value_kv:, shape:)
|
11
|
+
key = value_kv.keys[0]
|
12
|
+
result = compare(value: value_kv[key], shape: shape[key])
|
13
|
+
Katachi::ComparisonResult.new(
|
14
|
+
value: value_kv,
|
15
|
+
shape: shape.slice(key),
|
16
|
+
code: result.match? ? :kv_specific_match : :kv_specific_mismatch,
|
17
|
+
child_results: { shape[key] => result },
|
18
|
+
)
|
19
|
+
end
|
20
|
+
|
21
|
+
# Compares a single key-value pair against a shape when the key does not exactly match a shape key
|
22
|
+
# @param value_kv [Hash] A single key-value pair to compare
|
23
|
+
# @param shape [Hash] The shape to compare against
|
24
|
+
# @return [Katachi::ComparisonResult] The result of the comparison
|
25
|
+
private_class_method def self.compare_general_kv(value_kv:, shape:)
|
26
|
+
value_kv_results = shape.keys.to_h do |s_key|
|
27
|
+
[shape.slice(s_key), compare_individual_kv(value_kv:, shape_kv: shape.slice(s_key))]
|
28
|
+
end
|
29
|
+
Katachi::ComparisonResult.new(
|
30
|
+
value: value_kv,
|
31
|
+
shape:,
|
32
|
+
code: value_kv_results.values.any?(&:match?) ? :kv_match : :kv_mismatch,
|
33
|
+
child_results: value_kv_results,
|
34
|
+
)
|
35
|
+
end
|
36
|
+
|
37
|
+
# Compares a single key-value pair against a single shape key-value pair
|
38
|
+
# @param value_kv [Hash] A single key-value pair to compare
|
39
|
+
# @param shape_kv [Hash] A single key-value pair from the shape to compare against
|
40
|
+
# @return [Katachi::ComparisonResult] The result of the comparison
|
41
|
+
private_class_method def self.compare_individual_kv(value_kv:, shape_kv:)
|
42
|
+
key_result = compare(value: value_kv.keys[0], shape: shape_kv.keys[0])
|
43
|
+
value_result = compare(value: value_kv.values[0], shape: shape_kv.values[0])
|
44
|
+
code = if !key_result.match? then :kv_key_mismatch
|
45
|
+
elsif !value_result.match? then :kv_value_mismatch
|
46
|
+
else
|
47
|
+
:kv_value_match
|
48
|
+
end
|
49
|
+
Katachi::ComparisonResult.new(
|
50
|
+
value: value_kv,
|
51
|
+
shape: shape_kv,
|
52
|
+
code:,
|
53
|
+
child_results: { "$kv_key": key_result, "$kv_value": value_result },
|
54
|
+
)
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "comparison_result"
|
4
|
+
require_relative "comparator/compare_array"
|
5
|
+
require_relative "comparator/compare_hash"
|
6
|
+
|
7
|
+
# Checks a given value against a shape; returns a Katachi::Result
|
8
|
+
module Katachi::Comparator
|
9
|
+
# The main method for comparing a value against a shape
|
10
|
+
# Most of the logic is delegated to the other methods within this module
|
11
|
+
# In order to handle nested arrays and hashes the comparison methods are
|
12
|
+
# often recursive.
|
13
|
+
#
|
14
|
+
# @param value [Object] The value to compare
|
15
|
+
# @param shape [Object] The shape to compare against
|
16
|
+
# @return [Katachi::ComparisonResult] The result of the comparison
|
17
|
+
def self.compare(value:, shape:)
|
18
|
+
retrieved_shape = Katachi::Shapes[shape]
|
19
|
+
return retrieved_shape.kt_compare(value) if retrieved_shape.respond_to?(:kt_compare)
|
20
|
+
return compare_equalities(value:, shape: retrieved_shape) if retrieved_shape.is_a?(Proc)
|
21
|
+
|
22
|
+
case value
|
23
|
+
when Array then compare_array(value:, shape: retrieved_shape)
|
24
|
+
when Hash then compare_hash(value:, shape: retrieved_shape)
|
25
|
+
else compare_equalities(value:, shape: retrieved_shape)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
# The method for comparing two values that are not arrays or hashes
|
30
|
+
# It relies on the case equality operator (===) to do the heavy lifting.
|
31
|
+
#
|
32
|
+
# @param value [Object] The value to compare
|
33
|
+
# @param shape [Object] The shape to compare against
|
34
|
+
# @return [Katachi::ComparisonResult] The result of the comparison
|
35
|
+
def self.compare_equalities(value:, shape:)
|
36
|
+
code = if shape == value then :exact_match
|
37
|
+
elsif shape === value then :match # rubocop:disable Style/CaseEquality
|
38
|
+
else
|
39
|
+
:mismatch
|
40
|
+
end
|
41
|
+
Katachi::ComparisonResult.new(value:, shape:, code:)
|
42
|
+
end
|
43
|
+
end
|