exhaustive_case 1.0.0 → 1.0.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -1
- data/README.md +102 -28
- data/lib/exhaustive_case/case_builder.rb +22 -4
- data/lib/exhaustive_case/version.rb +1 -1
- data/lib/exhaustive_case.rb +3 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 432d6c2b8f4efcd0eacd5b70b49851a73c6e9f5ba46ef7b5ae8c1f56ac542d4d
|
|
4
|
+
data.tar.gz: 6ecfe98e2467fa36012721d1d637a19e454c97e929eff39056502125343a8597
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2338d003b512ecf30157dc6f5a5c773e6f5a94ef12100a61af12ae47405465fbdd9c3306709a68307d08d31b3a54e3a3c9ac037304d71348385a09b804003924
|
|
7
|
+
data.tar.gz: f18a5b6ddf5b400c2d5bdb6e0554abe7d1c17e4b681c445299f09eee1f49cc52631488f5e7f99a152e7512a628908711c00523d8b5af3be22c5d413756348324
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.0.1] - 2025-08-03
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `DuplicateCaseError` - raised when the same case value is declared more than once
|
|
14
|
+
- Duplicate case detection prevents declaring the same value across multiple `on` clauses
|
|
15
|
+
- Comprehensive test coverage for duplicate case scenarios
|
|
16
|
+
- Documentation for `DuplicateCaseError` in README with examples
|
|
17
|
+
|
|
18
|
+
### Technical
|
|
19
|
+
- Enhanced case validation with duplicate detection logic
|
|
20
|
+
- Maintained 100% test coverage with new duplicate case test suite
|
|
21
|
+
|
|
10
22
|
## [1.0.0] - 2025-08-02
|
|
11
23
|
|
|
12
24
|
### Added
|
|
@@ -37,6 +49,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
37
49
|
- RuboCop linting configuration
|
|
38
50
|
- Development scripts (`bin/setup`, `bin/console`)
|
|
39
51
|
|
|
40
|
-
[Unreleased]: https://github.com/yourusername/exhaustive_case/compare/v1.0.
|
|
52
|
+
[Unreleased]: https://github.com/yourusername/exhaustive_case/compare/v1.0.1...HEAD
|
|
53
|
+
[1.0.1]: https://github.com/yourusername/exhaustive_case/releases/tag/v1.0.1
|
|
41
54
|
[1.0.0]: https://github.com/yourusername/exhaustive_case/releases/tag/v1.0.0
|
|
42
55
|
[0.1.0]: https://github.com/yourusername/exhaustive_case/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -1,9 +1,36 @@
|
|
|
1
1
|
# Exclusive Case
|
|
2
2
|
|
|
3
|
+
Exhaustive case statements for Ruby to prevent bugs from unhandled cases when enumerated values are added to or removed a system.
|
|
4
|
+
|
|
3
5
|
[](https://badge.fury.io/rb/exhaustive_case)
|
|
4
6
|
[](https://github.com/ajsharma/exhaustive_case/actions/workflows/ci.yml)
|
|
5
7
|
|
|
6
|
-
|
|
8
|
+
|
|
9
|
+
## Table of Contents
|
|
10
|
+
|
|
11
|
+
- [Quick Start](#quick-start)
|
|
12
|
+
- [Installation](#installation)
|
|
13
|
+
- [Usage](#usage)
|
|
14
|
+
- [The Problem](#the-problem)
|
|
15
|
+
- [The Solution](#the-solution)
|
|
16
|
+
- [Enhanced Validation with `of:` Parameter](#enhanced-validation-with-of-parameter)
|
|
17
|
+
- [Error Handling](#error-handling)
|
|
18
|
+
- [Contributing](#contributing)
|
|
19
|
+
- [License](#license)
|
|
20
|
+
|
|
21
|
+
## Quick Start
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
require 'exhaustive_case'
|
|
25
|
+
include ExhaustiveCase
|
|
26
|
+
|
|
27
|
+
# Simple exhaustive case - will raise error if user_status doesn't match any case
|
|
28
|
+
result = exhaustive_case user_status do
|
|
29
|
+
on(:active) { "User is active" }
|
|
30
|
+
on(:inactive) { "User is inactive" }
|
|
31
|
+
on(:pending) { "User is pending approval" }
|
|
32
|
+
end
|
|
33
|
+
```
|
|
7
34
|
|
|
8
35
|
## Installation
|
|
9
36
|
|
|
@@ -25,6 +52,10 @@ Or install it yourself as:
|
|
|
25
52
|
$ gem install exhaustive_case
|
|
26
53
|
```
|
|
27
54
|
|
|
55
|
+
## Requirements
|
|
56
|
+
|
|
57
|
+
- Ruby 2.7 or higher
|
|
58
|
+
|
|
28
59
|
## Usage
|
|
29
60
|
|
|
30
61
|
Include the module to get access to the `exhaustive_case` method:
|
|
@@ -60,7 +91,7 @@ class UserRenderer
|
|
|
60
91
|
end
|
|
61
92
|
```
|
|
62
93
|
|
|
63
|
-
## The
|
|
94
|
+
## The Problem
|
|
64
95
|
|
|
65
96
|
If/else statements can easily lead to mistake flows when introducing new cases across the systems.
|
|
66
97
|
|
|
@@ -68,11 +99,11 @@ For instance, with a constant set of inputs `['A', 'B', 'C']`:
|
|
|
68
99
|
|
|
69
100
|
```ruby
|
|
70
101
|
if letter == 'A'
|
|
71
|
-
|
|
102
|
+
# handle a
|
|
72
103
|
elsif letter == 'B'
|
|
73
|
-
|
|
74
|
-
else
|
|
75
|
-
|
|
104
|
+
# handle b
|
|
105
|
+
else # implicit c
|
|
106
|
+
# handle c
|
|
76
107
|
end
|
|
77
108
|
```
|
|
78
109
|
|
|
@@ -80,11 +111,11 @@ However, if a new entry `D` is introduced to the system, now:
|
|
|
80
111
|
|
|
81
112
|
```ruby
|
|
82
113
|
if letter == 'A'
|
|
83
|
-
|
|
114
|
+
# code to handle a (whoops!)
|
|
84
115
|
elsif letter == 'B'
|
|
85
|
-
|
|
86
|
-
else
|
|
87
|
-
|
|
116
|
+
# code to handle b (whoops!)
|
|
117
|
+
else # implicit c and d
|
|
118
|
+
# code to handle c (whoops!)
|
|
88
119
|
end
|
|
89
120
|
```
|
|
90
121
|
|
|
@@ -94,11 +125,11 @@ We can address this with a more explicit initial switch:
|
|
|
94
125
|
|
|
95
126
|
```ruby
|
|
96
127
|
if letter == 'A'
|
|
97
|
-
|
|
128
|
+
# handle a
|
|
98
129
|
elsif letter == 'B'
|
|
99
|
-
|
|
130
|
+
# handle b
|
|
100
131
|
elsif letter == 'C'
|
|
101
|
-
|
|
132
|
+
# handle c
|
|
102
133
|
else
|
|
103
134
|
raise "Unknown letter #{letter}"
|
|
104
135
|
end
|
|
@@ -111,11 +142,11 @@ Note: for these examples, we're using an if/elsif/else chain, but the same conce
|
|
|
111
142
|
```ruby
|
|
112
143
|
case letter
|
|
113
144
|
when 'A'
|
|
114
|
-
|
|
145
|
+
# handle a
|
|
115
146
|
when 'B'
|
|
116
|
-
|
|
147
|
+
# handle b
|
|
117
148
|
when 'C'
|
|
118
|
-
|
|
149
|
+
# handle c
|
|
119
150
|
else
|
|
120
151
|
raise "Unknown letter #{letter}"
|
|
121
152
|
end
|
|
@@ -126,7 +157,7 @@ This new syntax is better, but leaves some gaps:
|
|
|
126
157
|
1. Engineers taught to use the class `if/else` structure constantly introduce these problems.
|
|
127
158
|
2. The final raise statement is often "impossible" to access via testing, it's nature preventing access without mocking (especially when these types of clauses are part of private functions).
|
|
128
159
|
|
|
129
|
-
## The
|
|
160
|
+
## The Solution
|
|
130
161
|
|
|
131
162
|
But what if had a new type of case statement that could both ensure that all cases are correctly implemented and provide feedback if a new case has been introduced but not implemented?
|
|
132
163
|
|
|
@@ -134,9 +165,9 @@ Enter exhaustive case:
|
|
|
134
165
|
|
|
135
166
|
```ruby
|
|
136
167
|
exhaustive_case letter do
|
|
137
|
-
on('A') {
|
|
138
|
-
on('B') {
|
|
139
|
-
on('C') {
|
|
168
|
+
on('A') { # handle A }
|
|
169
|
+
on('B') { # handle B }
|
|
170
|
+
on('C') { # handle C }
|
|
140
171
|
end
|
|
141
172
|
```
|
|
142
173
|
|
|
@@ -147,8 +178,8 @@ with the new syntax, `exhaustive_case` handles the final else statement and rais
|
|
|
147
178
|
|
|
148
179
|
```ruby
|
|
149
180
|
exhaustive_case letter do
|
|
150
|
-
on('A') {
|
|
151
|
-
on('B', 'C') {
|
|
181
|
+
on('A') { # handle A }
|
|
182
|
+
on('B', 'C') { # handle B or C }
|
|
152
183
|
end
|
|
153
184
|
```
|
|
154
185
|
|
|
@@ -168,9 +199,9 @@ For even stronger guarantees, you can specify the complete list of acceptable in
|
|
|
168
199
|
VALID_LETTERS = ['A', 'B', 'C'].freeze
|
|
169
200
|
|
|
170
201
|
exhaustive_case letter, of: VALID_LETTERS do
|
|
171
|
-
on('A') {
|
|
172
|
-
on('B') {
|
|
173
|
-
on('C') {
|
|
202
|
+
on('A') { # handle A }
|
|
203
|
+
on('B') { # handle B }
|
|
204
|
+
on('C') { # handle C }
|
|
174
205
|
end
|
|
175
206
|
```
|
|
176
207
|
|
|
@@ -208,9 +239,52 @@ if a new status is added to `STATUSES` a test suite should reveal that a case is
|
|
|
208
239
|
|
|
209
240
|
The following are the expected errors when using the gem:
|
|
210
241
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
242
|
+
### `UnhandledCaseError`
|
|
243
|
+
Raised when the input value doesn't match any `on` clause:
|
|
244
|
+
```ruby
|
|
245
|
+
exhaustive_case :unknown_status do
|
|
246
|
+
on(:active) { "active" }
|
|
247
|
+
on(:inactive) { "inactive" }
|
|
248
|
+
end
|
|
249
|
+
# => ExhaustiveCase::UnhandledCaseError: No case matched for value: :unknown_status
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
### `InvalidCaseError`
|
|
253
|
+
Raised when using `of:` and an `on` clause contains a value not in the allowed list:
|
|
254
|
+
```ruby
|
|
255
|
+
exhaustive_case status, of: [:active, :inactive] do
|
|
256
|
+
on(:active) { "active" }
|
|
257
|
+
on(:pending) { "pending" } # :pending not in allowed list
|
|
258
|
+
end
|
|
259
|
+
# => ExhaustiveCase::InvalidCaseError: Case :pending is not in the allowed list: [:active, :inactive]
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### `MissingCaseError`
|
|
263
|
+
Raised when using `of:` and not all allowed values have corresponding `on` clauses:
|
|
264
|
+
```ruby
|
|
265
|
+
exhaustive_case status, of: [:active, :inactive, :pending] do
|
|
266
|
+
on(:active) { "active" }
|
|
267
|
+
# Missing :inactive and :pending cases
|
|
268
|
+
end
|
|
269
|
+
# => ExhaustiveCase::MissingCaseError: Missing cases for: [:inactive, :pending]
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### `DuplicateCaseError`
|
|
273
|
+
Raised when the same case value is declared more than once:
|
|
274
|
+
```ruby
|
|
275
|
+
exhaustive_case status do
|
|
276
|
+
on(:active) { "first active" }
|
|
277
|
+
on(:active) { "second active" } # Duplicate case
|
|
278
|
+
end
|
|
279
|
+
# => ExhaustiveCase::DuplicateCaseError: Duplicate case(s): :active. Each case value can only be declared once.
|
|
280
|
+
|
|
281
|
+
# Also applies when values appear across multiple on clauses
|
|
282
|
+
exhaustive_case status do
|
|
283
|
+
on(:active, :pending) { "first group" }
|
|
284
|
+
on(:pending, :inactive) { "second group" } # :pending is duplicated
|
|
285
|
+
end
|
|
286
|
+
# => ExhaustiveCase::DuplicateCaseError: Duplicate case(s): :pending. Each case value can only be declared once.
|
|
287
|
+
```
|
|
214
288
|
|
|
215
289
|
## Contributing
|
|
216
290
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
3
5
|
module ExhaustiveCase
|
|
4
6
|
# CaseBuilder handles the construction and execution of exhaustive case statements.
|
|
5
7
|
# It validates cases against an optional 'of' list and ensures all required cases are handled.
|
|
@@ -11,15 +13,20 @@ module ExhaustiveCase
|
|
|
11
13
|
@of = of
|
|
12
14
|
@cases = []
|
|
13
15
|
@matched = false
|
|
16
|
+
@declared_matchers = Set.new
|
|
14
17
|
end
|
|
15
18
|
|
|
16
19
|
def on(*matchers, &block)
|
|
17
|
-
|
|
20
|
+
ensure_only_of_matchers(matchers) if @of
|
|
21
|
+
ensure_unique_matchers(matchers)
|
|
18
22
|
@cases << { matchers: matchers, block: block }
|
|
19
23
|
end
|
|
20
24
|
|
|
21
25
|
def execute
|
|
22
|
-
|
|
26
|
+
# Important! Make sure this is run before executing a matcher, we need to validate
|
|
27
|
+
# that the case structure is complete so we don't execute branches for an invalid
|
|
28
|
+
# exhaustive case
|
|
29
|
+
ensure_completeness if @of
|
|
23
30
|
|
|
24
31
|
@cases.each do |case_def|
|
|
25
32
|
if case_def[:matchers].any? { |matcher| matcher == @value }
|
|
@@ -33,7 +40,7 @@ module ExhaustiveCase
|
|
|
33
40
|
|
|
34
41
|
private
|
|
35
42
|
|
|
36
|
-
def
|
|
43
|
+
def ensure_only_of_matchers(matchers)
|
|
37
44
|
invalid_matchers = matchers.reject { |matcher| @of.include?(matcher) }
|
|
38
45
|
return if invalid_matchers.empty?
|
|
39
46
|
|
|
@@ -41,7 +48,18 @@ module ExhaustiveCase
|
|
|
41
48
|
"Must be one of: #{@of.map(&:inspect).join(", ")}"
|
|
42
49
|
end
|
|
43
50
|
|
|
44
|
-
def
|
|
51
|
+
def ensure_unique_matchers(matchers)
|
|
52
|
+
duplicate_matchers = matchers.select { |matcher| @declared_matchers.include?(matcher) }
|
|
53
|
+
|
|
54
|
+
unless duplicate_matchers.empty?
|
|
55
|
+
raise DuplicateCaseError, "Duplicate case(s): #{duplicate_matchers.map(&:inspect).join(", ")}. " \
|
|
56
|
+
"Each case value can only be declared once."
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
@declared_matchers.merge(matchers)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def ensure_completeness
|
|
45
63
|
declared_cases = @cases.flat_map { |case_def| case_def[:matchers] }.uniq
|
|
46
64
|
missing_cases = @of - declared_cases
|
|
47
65
|
return if missing_cases.empty?
|
data/lib/exhaustive_case.rb
CHANGED
|
@@ -35,6 +35,9 @@ module ExhaustiveCase
|
|
|
35
35
|
# implemented
|
|
36
36
|
class MissingCaseError < Error; end
|
|
37
37
|
|
|
38
|
+
# Indicates that a case value was declared more than once
|
|
39
|
+
class DuplicateCaseError < Error; end
|
|
40
|
+
|
|
38
41
|
def exhaustive_case(value, of: nil, &block)
|
|
39
42
|
builder = CaseBuilder.new(value, of)
|
|
40
43
|
builder.instance_eval(&block)
|