optionoids 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 000744ac00ebed3c85f06c226f38081e42a8a9c5f4bfb8954eea52b6da6c1a90
4
+ data.tar.gz: 771c45f7822b2c74ade3fc89ce8467a910dee5e64736bd2eaad33ca31fe64cef
5
+ SHA512:
6
+ metadata.gz: 46d34d1c17906cb257b207394611ecff3bb37756e30f554c301b6cffb4b603d0cfde79e24299fbe5d68e90dd41060540133c9c27f75121bc14adac885a3f1a86
7
+ data.tar.gz: f300123a79410fca5c3e0fa24db59828604980a9f1087bc181c063fd37996873d0b2655786c7c8fbee8327c53336c3e49767c64a564d145d63b9a23e21c37e76
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,34 @@
1
+ plugins:
2
+ - rubocop-rake
3
+ - rubocop-rspec
4
+
5
+ AllCops:
6
+ TargetRubyVersion: 3.0
7
+ SuggestExtensions: false
8
+ NewCops: enable
9
+ Exclude:
10
+ - "bin/**/*"
11
+
12
+ Style/StringLiteralsInInterpolation:
13
+ EnforcedStyle: double_quotes
14
+
15
+ Metrics/ClassLength:
16
+ Max: 150
17
+
18
+ Metrics/BlockLength:
19
+ Enabled: false
20
+
21
+ Metrics/AbcSize:
22
+ Exclude:
23
+ - "spec/**/*"
24
+
25
+ Metrics/CyclomaticComplexity:
26
+ Exclude:
27
+ - "spec/**/*"
28
+
29
+ Metrics/MethodLength:
30
+ Exclude:
31
+ - "spec/**/*"
32
+
33
+ RSpec/MultipleExpectations:
34
+ Max: 2
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2025-06-28
4
+
5
+ - Initial release
@@ -0,0 +1,132 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We as members, contributors, and leaders pledge to make participation in our
6
+ community a harassment-free experience for everyone, regardless of age, body
7
+ size, visible or invisible disability, ethnicity, sex characteristics, gender
8
+ identity and expression, level of experience, education, socio-economic status,
9
+ nationality, personal appearance, race, caste, color, religion, or sexual
10
+ identity and orientation.
11
+
12
+ We pledge to act and interact in ways that contribute to an open, welcoming,
13
+ diverse, inclusive, and healthy community.
14
+
15
+ ## Our Standards
16
+
17
+ Examples of behavior that contributes to a positive environment for our
18
+ community include:
19
+
20
+ * Demonstrating empathy and kindness toward other people
21
+ * Being respectful of differing opinions, viewpoints, and experiences
22
+ * Giving and gracefully accepting constructive feedback
23
+ * Accepting responsibility and apologizing to those affected by our mistakes,
24
+ and learning from the experience
25
+ * Focusing on what is best not just for us as individuals, but for the overall
26
+ community
27
+
28
+ Examples of unacceptable behavior include:
29
+
30
+ * The use of sexualized language or imagery, and sexual attention or advances of
31
+ any kind
32
+ * Trolling, insulting or derogatory comments, and personal or political attacks
33
+ * Public or private harassment
34
+ * Publishing others' private information, such as a physical or email address,
35
+ without their explicit permission
36
+ * Other conduct which could reasonably be considered inappropriate in a
37
+ professional setting
38
+
39
+ ## Enforcement Responsibilities
40
+
41
+ Community leaders are responsible for clarifying and enforcing our standards of
42
+ acceptable behavior and will take appropriate and fair corrective action in
43
+ response to any behavior that they deem inappropriate, threatening, offensive,
44
+ or harmful.
45
+
46
+ Community leaders have the right and responsibility to remove, edit, or reject
47
+ comments, commits, code, wiki edits, issues, and other contributions that are
48
+ not aligned to this Code of Conduct, and will communicate reasons for moderation
49
+ decisions when appropriate.
50
+
51
+ ## Scope
52
+
53
+ This Code of Conduct applies within all community spaces, and also applies when
54
+ an individual is officially representing the community in public spaces.
55
+ Examples of representing our community include using an official email address,
56
+ posting via an official social media account, or acting as an appointed
57
+ representative at an online or offline event.
58
+
59
+ ## Enforcement
60
+
61
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
62
+ reported to the community leaders responsible for enforcement at
63
+ [INSERT CONTACT METHOD].
64
+ All complaints will be reviewed and investigated promptly and fairly.
65
+
66
+ All community leaders are obligated to respect the privacy and security of the
67
+ reporter of any incident.
68
+
69
+ ## Enforcement Guidelines
70
+
71
+ Community leaders will follow these Community Impact Guidelines in determining
72
+ the consequences for any action they deem in violation of this Code of Conduct:
73
+
74
+ ### 1. Correction
75
+
76
+ **Community Impact**: Use of inappropriate language or other behavior deemed
77
+ unprofessional or unwelcome in the community.
78
+
79
+ **Consequence**: A private, written warning from community leaders, providing
80
+ clarity around the nature of the violation and an explanation of why the
81
+ behavior was inappropriate. A public apology may be requested.
82
+
83
+ ### 2. Warning
84
+
85
+ **Community Impact**: A violation through a single incident or series of
86
+ actions.
87
+
88
+ **Consequence**: A warning with consequences for continued behavior. No
89
+ interaction with the people involved, including unsolicited interaction with
90
+ those enforcing the Code of Conduct, for a specified period of time. This
91
+ includes avoiding interactions in community spaces as well as external channels
92
+ like social media. Violating these terms may lead to a temporary or permanent
93
+ ban.
94
+
95
+ ### 3. Temporary Ban
96
+
97
+ **Community Impact**: A serious violation of community standards, including
98
+ sustained inappropriate behavior.
99
+
100
+ **Consequence**: A temporary ban from any sort of interaction or public
101
+ communication with the community for a specified period of time. No public or
102
+ private interaction with the people involved, including unsolicited interaction
103
+ with those enforcing the Code of Conduct, is allowed during this period.
104
+ Violating these terms may lead to a permanent ban.
105
+
106
+ ### 4. Permanent Ban
107
+
108
+ **Community Impact**: Demonstrating a pattern of violation of community
109
+ standards, including sustained inappropriate behavior, harassment of an
110
+ individual, or aggression toward or disparagement of classes of individuals.
111
+
112
+ **Consequence**: A permanent ban from any sort of public interaction within the
113
+ community.
114
+
115
+ ## Attribution
116
+
117
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118
+ version 2.1, available at
119
+ [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
120
+
121
+ Community Impact Guidelines were inspired by
122
+ [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
123
+
124
+ For answers to common questions about this code of conduct, see the FAQ at
125
+ [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
126
+ [https://www.contributor-covenant.org/translations][translations].
127
+
128
+ [homepage]: https://www.contributor-covenant.org
129
+ [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
130
+ [Mozilla CoC]: https://github.com/mozilla/diversity
131
+ [FAQ]: https://www.contributor-covenant.org/faq
132
+ [translations]: https://www.contributor-covenant.org/translations
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 drewthorp
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,335 @@
1
+ # Optionoids
2
+ _(Terrible name, I know)_
3
+
4
+ Optionoids provides a simple, flexible, and concise method of validating option hashes passed to methods. Failures of validation can either raise an error or return an array of the errors. Checks can be chained together to create complex validations, and can be used to validate presence, population, types, and counts.
5
+
6
+ ## Installation
7
+
8
+ Install the gem and add to the application's Gemfile by executing:
9
+
10
+ $ bundle add optionoids
11
+
12
+ If bundler is not being used to manage dependencies, install the gem by executing:
13
+
14
+ $ gem install optionoids
15
+
16
+ ## Usage
17
+
18
+ Optionoids provides two methods on the Hash class. The `expecting` method is used for hard validations that will raise an error if the validation fails, while the `checking` method is used for soft validations that will provide an array of errors if the validation fails.
19
+
20
+ ```ruby
21
+ require 'optionoids'
22
+
23
+ class MyClass
24
+ def my_method(name, options = {})
25
+ expecting = options.expecting.with_params(name: name)
26
+ .only_these(%i[name address age])
27
+ expecting.that(%i[name address]).required.of_type(String)
28
+ .and.that(:age).of_type(Integer)
29
+ end
30
+ end
31
+ ```
32
+
33
+ ## Initialization
34
+
35
+ To start a hard validation, call `expecting` on the hash you want to validate. This will return an instance of `Optionoids::Checker`.
36
+
37
+ ```ruby
38
+ options = { name: 'John', age: 30 }
39
+ checker = options.expecting
40
+ ```
41
+
42
+ To start a soft validation, call `checking` on the hash you want to validate. This will return an instance of `Optionoids::Checker`.
43
+
44
+ ```ruby
45
+ options = { name: 'John', age: 30 }
46
+ checker = options.checking
47
+ ```
48
+
49
+ Both methods accept a `keys:` argument to specify an initial key filtering state (see below). The keys argument can be a single key or an array of keys.
50
+
51
+ ```ruby
52
+ options = { name: 'John', age: 30, email: 'bob@foo.com' }
53
+ checker = options.expecting(keys: :name)
54
+ # or
55
+ checker = options.checking(keys: [:name, :age])
56
+ ```
57
+
58
+ ### Additional Parameters
59
+
60
+ In addition to the pairs in the hash, additional pairs can be added for validation with the `with_params` method.
61
+
62
+ ```ruby
63
+ options.expecting.with_params(name: 'John', age: 30)
64
+ ```
65
+
66
+ ## Filtering
67
+
68
+ The key/value pairs that a check will be performed on can be filtered to only include certain keys. Once a filter is set, all subsequent checks will only be performed on the keys that are in the filter until the filter is altered or cleared. By default, there is no filter set, and all keys in the hash will be checked. An initial filter can be set when the checker is created by passing a `keys:` argument to the `expecting` or `checking` methods.
69
+
70
+ ### `that(keys)` Method
71
+
72
+ Sets a set of keys that subsequent checks will be performed on.
73
+
74
+ ```ruby
75
+ checker = options.expecting.that(%i[name address age])
76
+ checker.keys # => [:name, :address, :age]
77
+ ```
78
+
79
+ ### `plus(keys)` Method
80
+
81
+ Adds keys to the current filter.
82
+
83
+ ```ruby
84
+ checker = options.expecting.that(%i[name address]).plus(:age)
85
+ checker.keys # => [:name, :address, :age]
86
+ ```
87
+
88
+ ### `minus(keys)` Method
89
+
90
+ Removes keys from the current filter.
91
+
92
+ ```ruby
93
+ checker = options.expecting.that(%i[name address age]).minus(:address)
94
+ checker.keys # => [:name, :age]
95
+ ```
96
+
97
+ ### `and` Method
98
+
99
+ Clears the current filter.
100
+
101
+ ```ruby
102
+ expecting = options.expecting.that(%i[name address age])
103
+ expecting.that(:name).required.and.that(:age).populated
104
+ ```
105
+
106
+ Alias: _`all`_
107
+
108
+ ```ruby
109
+ expecting = options.expecting.that(%i[name address age])
110
+ expecting.that(:name).required
111
+ expecting.all.populated
112
+ ```
113
+
114
+ ## Checks
115
+
116
+ Checks are methods that can be chained together to perform validations on the keys and values in the options hash. The checks can be used to validate presence, population, types, and counts. Depending on how the checker was initialized, the checks will either raise an error or add to an array of errors (See `#errors` for accessing the array).
117
+
118
+ ### `only_these(keys)` Method
119
+
120
+ Checks that only the keys provided are present in the options hash. If any other keys are present, an error will be raised or added to the errors array.
121
+
122
+ ```ruby
123
+ expecting = options.expecting.only_these(%i[name address age])
124
+ ```
125
+
126
+ Error raised/logged: _Optionoids::Errors::UnexpectedKeys_
127
+
128
+ ### `exist` Method
129
+
130
+ Checks that all the currently set filter keys are present in the current option Hash. If there are no entries in the current option Hash, an error is raised. If any of the keys are missing, an error is raised.
131
+
132
+ ```ruby
133
+ expecting = options.expecting.that(:name).exist
134
+ ```
135
+
136
+ Errors raised/logged:
137
+ - Empty hash - _Optionoids::Errors::RequiredDataUnavailable_
138
+ - Missing keys - _Optionoids::Errors::MissingKeys_
139
+
140
+ ### `populated` Method
141
+
142
+ Checks that all the filter keys in the hash are not nil or empty. If any of the keys are nil or empty, an error is raised or added to the errors array. It does not error if the keys do not exist in the hash.
143
+
144
+ ```ruby
145
+ expecting = options.expecting.that(:name).populated
146
+ ```
147
+
148
+ Error raised/logged: _Optionoids::Errors::UnexpectedBlankValue_
149
+
150
+ Alias: _`all_populated`_
151
+
152
+ ### `blank` Method
153
+
154
+ Checks that all the filter keys in the hash are nil or empty. If any of the keys are not nil or empty, an error is raised or added to the errors array. It does not error if the keys do not exist in the hash.
155
+
156
+ ```ruby
157
+ expecting = options.expecting.that(:name).blank
158
+ ```
159
+
160
+ Error raised/logged: _Optionoids::Errors::UnexpectedPopulatedValue_
161
+
162
+ Alias: _`all_blank`_
163
+
164
+ ### `not_nil_values` Method
165
+
166
+ Checks that all the filter keys in the hash are not nil. If any of the keys are nil, an error is raised or added to the errors array. It does not error if the keys do not exist in the hash.
167
+
168
+ ```ruby
169
+ expecting = options.expecting.that(:name).not_nil_values
170
+ ```
171
+
172
+ Error raised/logged: _Optionoids::Errors::UnexpectedNilValue_
173
+
174
+ ### `nil_values` Method
175
+
176
+ Checks that all the filter keys in the hash are nil. If any of the keys are not nil, an error is raised or added to the errors array. It does not error if the keys do not exist in the hash.
177
+
178
+ ```ruby
179
+ expecting = options.expecting.that(:name).nil_values
180
+ ```
181
+
182
+ Error raised/logged: _Optionoids::Errors::UnexpectedNotNilValue_
183
+
184
+ ### `one_or_none` Method
185
+
186
+ Checks that at most one of the filter keys in the hash are present. If more than one of the keys are present, an error is raised or added to the errors array. It does not consider values, only the presence of the keys.
187
+
188
+ ```ruby
189
+ expecting = options.expecting.that(%i[name age]).one_or_none
190
+ ```
191
+
192
+ Error raised/logged: _Optionoids::Errors::UnexpectedMultipleKeys_
193
+
194
+ ### `just_one` Method
195
+
196
+ Checks that exactly one of the filter keys in the hash are present. If none or more than one of the keys are present, an error is raised or added to the errors array. It does not consider values, only the presence of the keys.
197
+
198
+ ```ruby
199
+ expecting = options.expecting.that(%i[name age]).just_one
200
+ ```
201
+
202
+ Errors raised/logged:
203
+ - Empty hash - _Optionoids::Errors::RequiredDataUnavailable_
204
+ - None or more than one keys - _Optionoids::Errors::UnexpectedMultipleKeys_
205
+
206
+ ### `one_or_more` Method
207
+
208
+ Checks that at least one of the filter keys in the hash are present. If none of the keys are present, an error is raised or added to the errors array. It does not consider values, only the presence of the keys.
209
+
210
+ ```ruby
211
+ expecting = options.expecting.that(%i[name age]).one_or_more
212
+ ```
213
+
214
+ Error raised/logged: _Optionoids::Errors::ExpectedMultipleKeys_
215
+
216
+ ### `of_types(types)` Method
217
+
218
+ Checks that the values of the filter keys in the hash are of the types provided. If any of the values are not of the expected type, an error is raised or added to the errors array. It does not error if the keys do not exist in the hash or if the value is nil.
219
+
220
+ ```ruby
221
+ expecting = options.expecting.that(:name).of_types(String, Symbol)
222
+ ```
223
+
224
+ Error raised/logged: _Optionoids::Errors::UnexpectedValueType_
225
+
226
+ Alias: _`of_type`_, _`types`_, _`type`_
227
+
228
+ ### `possible_values(variants)` Method
229
+
230
+ Checks that the values of the filter keys in the hash are one of the possible values provided. If any of the values are not one of the possible values, an error is raised or added to the errors array. It does not error if the keys do not exist in the hash or if the value is nil.
231
+
232
+ ```ruby
233
+ expecting = options.expecting.that(:name).possible_values('John', 'Jane', 'Doe')
234
+ ```
235
+
236
+ Error raised/logged: _Optionoids::Errors::UnexpectedValueVariant_
237
+
238
+ ## Convenience Methods
239
+
240
+ The `Optionoids::Checker` class provides several convenience methods to make it easier to perform common checks. These methods are available on both hard and soft checkers.
241
+
242
+ ### `identifier` Method
243
+
244
+ Checks that the value of the filter key is a valid identifier. A valid identifier is a populated String or Symbol.
245
+
246
+ ```ruby
247
+ expecting = options.expecting.that(:name).identifier
248
+ ```
249
+
250
+ Errors raised/logged:
251
+ - If the wrong type: _Optionoids::Errors::UnexpectedValueType_
252
+ - If the value is nil or empty: _Optionoids::Errors::UnexpectedBlankValue_
253
+
254
+ ### `flag` Method
255
+
256
+ Checks that the value of the filter key is a populated boolean. A boolean is either `true` or `false`.
257
+
258
+ ```ruby
259
+ expecting = options.expecting.that(:active).flag
260
+ ```
261
+
262
+ Errors raised/logged:
263
+ - If the wrong type: _Optionoids::Errors::UnexpectedValueType_
264
+ - If the value is nil or empty: _Optionoids::Errors::UnexpectedBlankValue_
265
+
266
+ ### `required` Method
267
+
268
+ Checks that the filter key is present and populated in the options hash.
269
+
270
+ ```ruby
271
+ expecting = options.expecting.that(:name).required
272
+ ```
273
+
274
+ Errors raised/logged:
275
+ - If the hash is empty: _Optionoids::Errors::RequiredDataUnavailable_
276
+ - Not present: _Optionoids::Errors::MissingKeys_
277
+ - If the value is nil or empty: _Optionoids::Errors::UnexpectedBlankValue_
278
+
279
+ ## Soft Errors
280
+
281
+ If the checker was initialized with `checking`, the errors will be collected in an array. You can access the errors using the `errors` method. A predicate method `failed?` is also available to check if there are any errors.
282
+
283
+ ```ruby
284
+ checker = options.checking.that(:name).required
285
+ checker.errors # => ["Missing keys: name", "Unexpected blank value for key: name"]
286
+ checker.failed? # => true
287
+ ```
288
+
289
+ ## Data Access / Debugging
290
+
291
+ ### `current_options` Method
292
+
293
+ Returns the current filtered options Hash that is being checked.
294
+
295
+ ```ruby
296
+ checker = options.expecting.that(:name).current_options
297
+ # => { name: 'John' }
298
+ ```
299
+
300
+ ### `global_options` Method
301
+
302
+ Returns the original options Hash that was passed to the checker. Options added with `with_params` are included in this hash.
303
+
304
+ ```ruby
305
+ checker = options.expecting.with_params(name: 'John').global_options
306
+ # => { name: 'John', age: 30 }
307
+ ```
308
+
309
+ ### `keys` Method
310
+
311
+ Returns the keys that are currently being checked. This is useful to see which keys are in the current filter.
312
+
313
+ ```ruby
314
+ checker = options.expecting.that(:name, :age).keys
315
+ # => [:name, :age]
316
+ ```
317
+
318
+ ## Future Enhancements
319
+
320
+ - Add support for regex checks on values.
321
+ - Add common checks using regex (e.g., email, URL).
322
+ - Add support for range checks on numeric & date values.
323
+ - Implement a similar API for cleaning the options hash.
324
+
325
+ ## Contributing
326
+
327
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/optionoids. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/optionoids/blob/main/CODE_OF_CONDUCT.md).
328
+
329
+ ## License
330
+
331
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
332
+
333
+ ## Code of Conduct
334
+
335
+ Everyone interacting in the Optionoids project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/optionoids/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+ require 'rubocop/rake_task'
6
+
7
+ RSpec::Core::RakeTask.new(:spec)
8
+ RuboCop::RakeTask.new
9
+
10
+ task default: %i[spec rubocop]
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Extends the Hash class to support optionoid option parsing.
4
+ class Hash
5
+ # Perform hard checking on the Hash. Hard checking will raise an error if the Hash does not
6
+ # conform to the expectations.
7
+ def expecting(keys = nil)
8
+ Optionoids::Checker.new(self, keys: keys)
9
+ end
10
+
11
+ # Perform soft checking on the Hash. Soft checking will not raise an error. Errors are logged
12
+ # and can be checked with errors and failed? methods.
13
+ def checking(keys = nil)
14
+ Optionoids::Checker.new(self, keys: keys, hard: false)
15
+ end
16
+ end
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/object/blank'
4
+ require 'hash_expecting'
5
+ require 'optionoids/errors'
6
+
7
+ module Optionoids
8
+ # Class to wrap an options Hash and perform checks on the keys and values. All method return
9
+ # the same instance of the Checker, allowing for method chaining.
10
+ class Checker
11
+ attr_reader :hard, :keys
12
+
13
+ # @param options [Hash] The options Hash to check against.
14
+ # @param keys [Array<String, Symbol>] The keys to initially check in the options Hash. If nil,
15
+ # all keys are checked.
16
+ def initialize(options, keys: nil, hard: true)
17
+ @options = options
18
+ @keys = [keys].flatten.compact
19
+ @params = {}
20
+ @hard = hard
21
+ clip_options
22
+ end
23
+
24
+ # A set of additional options to check against. This is useful for checking other none optional
25
+ # parameters that are not part of the options Hash.
26
+ def with_params(params)
27
+ @params = params.is_a?(Hash) ? params : params.to_h
28
+ clip_options
29
+ self
30
+ end
31
+
32
+ # Returns the current 'filtered' option Hash.
33
+ def current_options
34
+ @clipped_options
35
+ end
36
+
37
+ # Returns the 'unfiltered' options Hash.
38
+ def global_options
39
+ @options.merge(@params)
40
+ end
41
+
42
+ ## FILTERING ##
43
+
44
+ # Removes all option Hash filtering.
45
+ def and
46
+ @keys = []
47
+ clip_options
48
+ self
49
+ end
50
+
51
+ alias all and
52
+
53
+ # Add a key set filter to the current option Hash. The keys provided do not have to exist in the
54
+ # option Hash, but only those that do will be checked. Filters are not cumulative, so calling
55
+ # this method will replace any previous key filters.
56
+ def that(*keys)
57
+ @keys = keys.flatten.compact
58
+ clip_options
59
+ self
60
+ end
61
+
62
+ # Removes the keys from the current option Hash filter. No error will be raised if the given
63
+ # keys are not present in the current option Hash.
64
+ def minus(*keys)
65
+ @keys -= keys
66
+ clip_options
67
+ self
68
+ end
69
+
70
+ # Adds the given keys to the current option Hash filter. No error will be raised if the given
71
+ # keys are not present in the current option Hash.
72
+ def plus(*keys)
73
+ @keys |= keys.flatten.compact.uniq
74
+ clip_options
75
+ self
76
+ end
77
+
78
+ ## KEY PRESENCE CHECKS ##
79
+
80
+ # Checks that the current option Hash contains only the given keys. If any unexpected keys are
81
+ # present, an error is raised. If no keys are given, the current option Hash is not checked.
82
+ # Error: Optionoids::Errors::UnexpectedKeys
83
+ def only_these(keys)
84
+ unexpected_keys = @clipped_options.keys - [keys].flatten
85
+ _error_or_log(Errors::UnexpectedKeys.new(nil, keys: unexpected_keys)) if unexpected_keys.any?
86
+
87
+ self
88
+ end
89
+
90
+ # Checks that all the currently set filter keys are present in the current option Hash. If there
91
+ # are no entries in the current option Hash an error (Optionoids::Errors::RequiredDataUnavailable)
92
+ # is raised. If any of the keys are missing, an error (Optionoids::Errors::MissingKeys) is raised.
93
+ def exist
94
+ return _error_or_log(Errors::RequiredDataUnavailable.new(nil, check: 'present')) if @keys.empty?
95
+
96
+ missing_keys = @keys - @clipped_options.keys
97
+ return self if missing_keys.empty?
98
+
99
+ _error_or_log(Errors::MissingKeys.new(nil, keys: missing_keys))
100
+ end
101
+
102
+ ## VALUE POPULATION CHECKS ##
103
+
104
+ # Checks that the current option Hash entries all have non-blank values. If any of the values
105
+ # are blank, an error (Optionoids::Errors::UnexpectedBlankValue) is raised.
106
+ def populated
107
+ _error_on_check(:blank?, Errors::UnexpectedBlankValue)
108
+ self
109
+ end
110
+
111
+ alias all_populated populated
112
+
113
+ # Checks that the current option Hash entries all have blank values. If any of the values are
114
+ # populated, an error (Optionoids::Errors::UnexpectedPopulatedValue) is raised.
115
+ def blank
116
+ _error_on_check(:present?, Errors::UnexpectedPopulatedValue)
117
+ self
118
+ end
119
+
120
+ alias all_blank blank
121
+
122
+ # Checks that the current option Hash entries all have non-nil values. If any of the values are
123
+ # nil, an error (Optionoids::Errors::UnexpectedNilValue) is raised.
124
+ def not_nil_values
125
+ _error_on_check(:nil?, Errors::UnexpectedNilValue)
126
+ self
127
+ end
128
+
129
+ # Checks that the current option Hash entries all have nil values. If any of the values are
130
+ # not nil, an error (Optionoids::Errors::UnexpectedNonNilValue) is raised.
131
+ def nil_values
132
+ failed_keys = @clipped_options.compact.keys
133
+ return self if failed_keys.empty?
134
+
135
+ _error_or_log(Errors::UnexpectedNonNilValue.new(nil, keys: failed_keys))
136
+ end
137
+
138
+ ## KEY COUNT CHECKS ##
139
+
140
+ # Checks that the current option Hash contains no more that one key. If more than one key id
141
+ # present, an error (Optionoids::Errors::UnexpectedMultipleKeys) is raised. If there are no keys
142
+ # present, no error is raised.
143
+ def one_or_none
144
+ msg = "Expected a maximum or one key, but found: #{@clipped_options.keys.to_sentence}"
145
+ _error_or_log(Errors::UnexpectedMultipleKeys.new(msg, keys: @clipped_options.keys)) if @clipped_options.count > 1
146
+
147
+ self
148
+ end
149
+
150
+ # Checks that the current option Hash contains exactly one key. If no keys are present, an error
151
+ # (Optionoids::Errors::RequiredDataUnavailable) is raised. If more than one key is present, an error
152
+ # (Optionoids::Errors::UnexpectedMultipleKeys) is raised.
153
+ def just_one
154
+ _error_or_log(Errors::RequiredDataUnavailable.new(nil, check: 'one_required')) if @clipped_options.empty?
155
+ return self if @clipped_options.one?
156
+
157
+ msg = "Expected exactly one key, but found: #{@clipped_options.keys.to_sentence}"
158
+ _error_or_log(Errors::UnexpectedMultipleKeys.new(msg, keys: @clipped_options.keys))
159
+ end
160
+
161
+ # Checks that the current option Hash contains one or more keys. If no keys are present, an error
162
+ # (Optionoids::Errors::ExpectedMultipleKeys).
163
+ def one_of_more
164
+ return self if @clipped_options.count >= 1
165
+
166
+ _error_or_log(Errors::ExpectedMultipleKeys.new)
167
+ end
168
+
169
+ ## TYPE CHECKS ##
170
+
171
+ # Checks that the current option Hash entries are of the given types. If any of the values are
172
+ # not of the given types, an error (Optionoids::Errors::UnexpectedValueType) is raised. 'nil'
173
+ # values are ignored in the type check.
174
+ def of_types(*types)
175
+ pairs = @clipped_options.compact.select { |_k, v| types.none? { |t| v.is_a?(t) } }
176
+ return self if pairs.empty?
177
+
178
+ _error_or_log(Errors::UnexpectedValueType.new(nil, keys: pairs.keys, types: types.map(&:name)))
179
+ end
180
+
181
+ alias of_type of_types
182
+ alias types of_types
183
+ alias type of_types
184
+
185
+ ## VALUE CHECKS ##
186
+
187
+ # Checks that the current option Hash entries are one of the given variants. If any of the
188
+ # values are not one of the given variants, an error (Optionoids::Errors::UnexpectedValueVariant).
189
+ # If a value is nil it is ignored in the check.
190
+ def possible_values(variants)
191
+ pairs = @clipped_options.compact.select { |_k, v| variants.none? { |variant| v == variant } }
192
+ return self if pairs.empty?
193
+
194
+ _error_or_log(Errors::UnexpectedValueVariant.new(nil, keys: pairs.keys, variants: variants))
195
+ end
196
+
197
+ ## COMPOSITE CHECKS ##
198
+
199
+ # Checks that the current option Hash entries are usable as identifiers. This means that the
200
+ # values are either Strings or Symbols and are not blank.
201
+ def identifier
202
+ of_type(String, Symbol).populated
203
+ end
204
+
205
+ # Checks that the current option Hash entries are usable as flags. This means that the values
206
+ # are either TrueClass or FalseClass and are not blank.
207
+ def flag
208
+ # Populated check mist use not nil because false is never 'present?'
209
+ of_type(TrueClass, FalseClass).not_nil_values
210
+ end
211
+
212
+ # Checks that the current option Hash entries ate both present and populated.
213
+ def required
214
+ exist.populated
215
+ end
216
+
217
+ ## SOFT ERROR HANDLING ##
218
+
219
+ def errors
220
+ @errors ||= []
221
+ end
222
+
223
+ def failed?
224
+ errors.any?
225
+ end
226
+
227
+ private
228
+
229
+ def _error_or_log(error)
230
+ raise error if @hard
231
+
232
+ errors << error
233
+ self
234
+ end
235
+
236
+ def _error_on_check(check, error_class)
237
+ failed_keys = _keys_for_check(check)
238
+ return if failed_keys.empty?
239
+
240
+ _error_or_log(error_class.new(nil, keys: failed_keys))
241
+ end
242
+
243
+ def _keys_for_check(check)
244
+ @clipped_options.select { |_k, v| v.send(check) }.to_h.keys
245
+ end
246
+
247
+ def clip_options
248
+ @clipped_options = @options.merge(@params)
249
+ return if @keys.empty?
250
+
251
+ @clipped_options = @clipped_options.slice(*@keys)
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/array/conversions'
4
+
5
+ module Optionoids
6
+ # Errors module contains custom error classes for the Optionoids library.
7
+ module Errors
8
+ # Custom error class to indicate that a checker requires keys in #keys or key/values in the
9
+ # current option Hash, but none were provided.
10
+ class RequiredDataUnavailable < StandardError
11
+ attr_reader :check
12
+
13
+ def initialize(message = nil, check: nil)
14
+ msg = message || "Required data is unavailable for the check '#{check}'"
15
+ @check = check
16
+ super(msg)
17
+ end
18
+ end
19
+
20
+ # Custom error class to indicate the expected keys are not present in the current option Hash
21
+ class MissingKeys < StandardError
22
+ attr_reader :keys
23
+
24
+ def initialize(message = nil, keys: [])
25
+ @keys = keys
26
+ msg = message || "Missing required keys: #{keys.to_sentence}"
27
+ super(msg)
28
+ end
29
+ end
30
+
31
+ # Custom error class to indicate that unexpected keys are in the current option Hash
32
+ class UnexpectedKeys < StandardError
33
+ attr_reader :keys
34
+
35
+ def initialize(message = nil, keys: [])
36
+ @keys = keys
37
+ msg = message || "Unexpected keys found: #{keys.to_sentence}"
38
+ super(msg)
39
+ end
40
+ end
41
+
42
+ # Custom error class to indicate that some values ate unexpectedly blank or nil
43
+ class UnexpectedBlankValue < StandardError
44
+ attr_reader :keys
45
+
46
+ def initialize(message = nil, keys: [])
47
+ @keys = keys
48
+ msg = message || "Unexpected blank values for keys: #{keys.to_sentence}"
49
+ super(msg)
50
+ end
51
+ end
52
+
53
+ # Custom error class to indicate that some values are unexpectedly populated (not blank)
54
+ class UnexpectedPopulatedValue < StandardError
55
+ attr_reader :keys
56
+
57
+ def initialize(message = nil, keys: [])
58
+ @keys = keys
59
+ msg = message || "Unexpected populated values for keys: #{keys.to_sentence}"
60
+ super(msg)
61
+ end
62
+ end
63
+
64
+ # Custom error class to indicate that some values are unexpectedly not nil
65
+ class UnexpectedNonNilValue < StandardError
66
+ attr_reader :keys
67
+
68
+ def initialize(message = nil, keys: [])
69
+ @keys = keys
70
+ msg = message || "Unexpected non-nil values for keys: #{keys.to_sentence}"
71
+ super(msg)
72
+ end
73
+ end
74
+
75
+ # Custom error class to indicate that some values are unexpectedly nil
76
+ class UnexpectedNilValue < StandardError
77
+ attr_reader :keys
78
+
79
+ def initialize(message = nil, keys: [])
80
+ @keys = keys
81
+ msg = message || "Unexpected nil values for keys: #{keys.to_sentence}"
82
+ super(msg)
83
+ end
84
+ end
85
+
86
+ # Custom error class to indicate that only one key is expected, but multiple keys are present
87
+ class UnexpectedMultipleKeys < StandardError
88
+ attr_reader :keys
89
+
90
+ def initialize(message = nil, keys: [])
91
+ @keys = keys
92
+ msg = message || "Multiple keys present when only one is expected: #{keys.to_sentence}"
93
+ super(msg)
94
+ end
95
+ end
96
+
97
+ # Custom error class to indicate that the values for the keys are not of the expected types
98
+ class UnexpectedValueType < StandardError
99
+ attr_reader :keys, :types
100
+
101
+ def initialize(message = nil, keys: [], types: [])
102
+ @keys = keys
103
+ @types = types
104
+ msg = message || "Unexpected value types for keys: #{keys.to_sentence}. " \
105
+ "Expected types: #{types.to_sentence}"
106
+ super(msg)
107
+ end
108
+ end
109
+
110
+ # Custom error class to indicate that a values in the current option Hash are not of the
111
+ # expected variants
112
+ class UnexpectedValueVariant < StandardError
113
+ attr_reader :keys, :variants
114
+
115
+ def initialize(message = nil, keys: [], variants: [])
116
+ @keys = keys
117
+ @variants = variants
118
+ msg = message || "Unexpected value variants for keys: #{keys.to_sentence}. " \
119
+ "Expected variants: #{variants.to_sentence}"
120
+ super(msg)
121
+ end
122
+ end
123
+
124
+ # Custom error class to indicate that a checker expected multiple keys but none were provided
125
+ class ExpectedMultipleKeys < StandardError
126
+ def initialize(message = nil)
127
+ msg = message || 'Expected multiple keys but none were provided'
128
+ super(msg)
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Optionoids
4
+ VERSION = '0.1.0'
5
+ end
data/lib/optionoids.rb ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'optionoids/version'
4
+ require_relative 'optionoids/checker'
@@ -0,0 +1,4 @@
1
+ module Optionoids
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,83 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: optionoids
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - drewthorp
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2025-07-06 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 7.1.0
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '9.0'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: 7.1.0
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '9.0'
33
+ description: Optionoids is a Ruby gem designed to provide a simple and flexible way
34
+ to validate and check the content of option hashes. It allows developers to define
35
+ checks for required keys, unexpected keys, and value conditions, making it easier
36
+ to work with configuration options in Ruby applications.
37
+ email:
38
+ - gems@fishfur.com
39
+ executables: []
40
+ extensions: []
41
+ extra_rdoc_files: []
42
+ files:
43
+ - ".rspec"
44
+ - ".rubocop.yml"
45
+ - CHANGELOG.md
46
+ - CODE_OF_CONDUCT.md
47
+ - LICENSE.txt
48
+ - README.md
49
+ - Rakefile
50
+ - lib/hash_expecting.rb
51
+ - lib/optionoids.rb
52
+ - lib/optionoids/checker.rb
53
+ - lib/optionoids/errors.rb
54
+ - lib/optionoids/version.rb
55
+ - sig/optionoids.rbs
56
+ homepage: https://github.com/Fish-Fur/optionoids
57
+ licenses:
58
+ - MIT
59
+ metadata:
60
+ homepage_uri: https://github.com/Fish-Fur/optionoids
61
+ source_code_uri: https://github.com/Fish-Fur/optionoids
62
+ changelog_uri: https://github.com/Fish-Fur/optionoids
63
+ rubygems_mfa_required: 'true'
64
+ post_install_message:
65
+ rdoc_options: []
66
+ require_paths:
67
+ - lib
68
+ required_ruby_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: 3.0.0
73
+ required_rubygems_version: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ requirements: []
79
+ rubygems_version: 3.5.11
80
+ signing_key:
81
+ specification_version: 4
82
+ summary: A Ruby gem for checking content of option hashes.
83
+ test_files: []