katachi 0.0.0.1 → 0.0.1.1

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.
@@ -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
+ ```
@@ -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