equitable 1.0.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 +7 -0
- data/.mutant.yml +23 -0
- data/.rubocop.yml +28 -0
- data/.yardopts +4 -0
- data/.yardstick.yml +34 -0
- data/LICENSE +21 -0
- data/README.md +296 -0
- data/Rakefile +46 -0
- data/Steepfile +10 -0
- data/lib/equitable.rb +151 -0
- data/sig/equitable.rbs +57 -0
- metadata +57 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e534220f01acc864d7c2207df2357e64e1629bf52ed12f1819710cd6aebfffa2
|
|
4
|
+
data.tar.gz: '079708acf5302fffd47cf41ad4a546fc71e84b73d16229c0001faa8fb4b1c66b'
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 22271dd2594bcaa8083313171f5aa3cd190131a9f3b8632360e25069ea490cec7578e4dfd03ae558a63e2f5d34af9034b0280229543853da2295a0d54a73f256
|
|
7
|
+
data.tar.gz: 9017cba0ec0cd5aef2b9f04e4277df95cef3c714abf5d4d4f200ee4f5048bda0de58108d999d93a2c2fb8dee26534bbd7826926df1bcddbc430ecfee26d54521
|
data/.mutant.yml
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
usage: opensource
|
|
2
|
+
integration:
|
|
3
|
+
name: minitest
|
|
4
|
+
includes:
|
|
5
|
+
- lib
|
|
6
|
+
- test
|
|
7
|
+
requires:
|
|
8
|
+
- equitable
|
|
9
|
+
- test_helper
|
|
10
|
+
- equitable_test
|
|
11
|
+
matcher:
|
|
12
|
+
subjects:
|
|
13
|
+
- "Equitable*"
|
|
14
|
+
mutation:
|
|
15
|
+
timeout: 10.0
|
|
16
|
+
ignore_patterns:
|
|
17
|
+
# module_eval's __LINE__ + 1 argument only affects debugging metadata
|
|
18
|
+
- send{selector=module_eval}
|
|
19
|
+
coverage_criteria:
|
|
20
|
+
test_result: true
|
|
21
|
+
process_abort: true
|
|
22
|
+
environment_variables:
|
|
23
|
+
MUTANT: "true"
|
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
inherit_gem:
|
|
2
|
+
standard: config/base.yml
|
|
3
|
+
standard-performance: config/base.yml
|
|
4
|
+
|
|
5
|
+
plugins:
|
|
6
|
+
- rubocop-minitest
|
|
7
|
+
- rubocop-performance
|
|
8
|
+
- rubocop-rake
|
|
9
|
+
|
|
10
|
+
AllCops:
|
|
11
|
+
TargetRubyVersion: 3.3
|
|
12
|
+
NewCops: enable
|
|
13
|
+
|
|
14
|
+
Style/StringLiterals:
|
|
15
|
+
EnforcedStyle: double_quotes
|
|
16
|
+
|
|
17
|
+
Style/StringLiteralsInInterpolation:
|
|
18
|
+
EnforcedStyle: double_quotes
|
|
19
|
+
|
|
20
|
+
Style/Documentation:
|
|
21
|
+
Enabled: true
|
|
22
|
+
|
|
23
|
+
Minitest/MultipleAssertions:
|
|
24
|
+
Max: 4
|
|
25
|
+
|
|
26
|
+
Naming/MethodParameterName:
|
|
27
|
+
Exclude:
|
|
28
|
+
- "test/**/*"
|
data/.yardopts
ADDED
data/.yardstick.yml
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
threshold: 100
|
|
3
|
+
require_exact_threshold: false
|
|
4
|
+
rules:
|
|
5
|
+
ApiTag::Presence:
|
|
6
|
+
enabled: true
|
|
7
|
+
exclude: []
|
|
8
|
+
ApiTag::Inclusion:
|
|
9
|
+
enabled: true
|
|
10
|
+
exclude: []
|
|
11
|
+
ApiTag::ProtectedMethod:
|
|
12
|
+
enabled: true
|
|
13
|
+
exclude: []
|
|
14
|
+
ApiTag::PrivateMethod:
|
|
15
|
+
enabled: true
|
|
16
|
+
exclude: []
|
|
17
|
+
ExampleTag:
|
|
18
|
+
enabled: false
|
|
19
|
+
exclude: []
|
|
20
|
+
ReturnTag:
|
|
21
|
+
enabled: true
|
|
22
|
+
exclude: []
|
|
23
|
+
Summary::Presence:
|
|
24
|
+
enabled: true
|
|
25
|
+
exclude: []
|
|
26
|
+
Summary::Length:
|
|
27
|
+
enabled: true
|
|
28
|
+
exclude: []
|
|
29
|
+
Summary::Delimiter:
|
|
30
|
+
enabled: true
|
|
31
|
+
exclude: []
|
|
32
|
+
Summary::SingleLine:
|
|
33
|
+
enabled: true
|
|
34
|
+
exclude: []
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Erik Berlin
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
# Equitable
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/equitable)
|
|
4
|
+
[](https://github.com/sferik/equitable/actions/workflows/test.yml)
|
|
5
|
+
[](https://github.com/sferik/equitable/actions/workflows/quality.yml)
|
|
6
|
+
[](https://github.com/sferik/equitable/actions/workflows/yard.yml)
|
|
7
|
+
[](https://github.com/sferik/equitable/actions/workflows/mutant.yml)
|
|
8
|
+
|
|
9
|
+
Equitable provides equality, equivalence, hashing, pattern matching, and
|
|
10
|
+
inspection methods for Ruby objects based on explicitly specified attributes.
|
|
11
|
+
|
|
12
|
+
Unlike approaches that automatically use all `attr_reader` attributes,
|
|
13
|
+
Equitable requires explicit specification of which attributes affect equality,
|
|
14
|
+
giving you full control over comparison behavior.
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
Add this line to your application's Gemfile:
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
gem "equitable"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or install it directly:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
gem install equitable
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quick Start
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
class Point
|
|
34
|
+
include Equitable.new(:x, :y)
|
|
35
|
+
|
|
36
|
+
attr_reader :x, :y
|
|
37
|
+
|
|
38
|
+
def initialize(x, y)
|
|
39
|
+
@x = x
|
|
40
|
+
@y = y
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
p1 = Point.new(1, 2)
|
|
45
|
+
p2 = Point.new(1, 2)
|
|
46
|
+
|
|
47
|
+
p1 == p2 # => true
|
|
48
|
+
p1.eql?(p2) # => true
|
|
49
|
+
p1.hash == p2.hash # => true
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Features
|
|
53
|
+
|
|
54
|
+
### Selective Attribute Comparison
|
|
55
|
+
|
|
56
|
+
Only the attributes you specify are used for equality. Other instance variables
|
|
57
|
+
are ignored:
|
|
58
|
+
|
|
59
|
+
> [!TIP]
|
|
60
|
+
> This is useful when you have attributes that shouldn't affect equality, like
|
|
61
|
+
> timestamps, cached values, or display names.
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
class GeoLocation
|
|
65
|
+
include Equitable.new(:latitude, :longitude)
|
|
66
|
+
|
|
67
|
+
attr_reader :latitude, :longitude, :name
|
|
68
|
+
|
|
69
|
+
def initialize(latitude, longitude, name = nil)
|
|
70
|
+
@latitude = latitude
|
|
71
|
+
@longitude = longitude
|
|
72
|
+
@name = name
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
home = GeoLocation.new(37.7786, -122.4407, "Home")
|
|
77
|
+
work = GeoLocation.new(37.7786, -122.4407, "Work")
|
|
78
|
+
|
|
79
|
+
home == work # => true (name is not part of equality)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Equality vs Equivalence
|
|
83
|
+
|
|
84
|
+
Equitable provides two comparison methods with different semantics:
|
|
85
|
+
|
|
86
|
+
#### `==` (Equality)
|
|
87
|
+
|
|
88
|
+
Returns `true` if the other object is an instance of the same class **or a
|
|
89
|
+
subclass**, and all specified attributes are equal using `==`:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
class ColoredPoint < Point
|
|
93
|
+
attr_reader :color
|
|
94
|
+
|
|
95
|
+
def initialize(x, y, color)
|
|
96
|
+
super(x, y)
|
|
97
|
+
@color = color
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
point = Point.new(1, 2)
|
|
102
|
+
colored = ColoredPoint.new(1, 2, "red")
|
|
103
|
+
|
|
104
|
+
point == colored # => true (ColoredPoint is a subclass of Point)
|
|
105
|
+
colored == point # => false (Point is not a subclass of ColoredPoint)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
> [!IMPORTANT]
|
|
109
|
+
> In Ruby, the `==` operator is asymmetric when comparing across class
|
|
110
|
+
> hierarchies. A parent class instance can equal a subclass instance, but not
|
|
111
|
+
> vice versa.
|
|
112
|
+
|
|
113
|
+
#### `eql?` (Equivalence)
|
|
114
|
+
|
|
115
|
+
Returns `true` only if both objects are instances of the **exact same class**,
|
|
116
|
+
and all specified attributes are equal using `eql?`:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
point = Point.new(1, 2)
|
|
120
|
+
colored = ColoredPoint.new(1, 2, "red")
|
|
121
|
+
|
|
122
|
+
point.eql?(colored) # => false (different classes)
|
|
123
|
+
colored.eql?(point) # => false (different classes)
|
|
124
|
+
|
|
125
|
+
point.eql?(Point.new(1, 2)) # => true (same class, same values)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Hashing
|
|
129
|
+
|
|
130
|
+
Objects that are `eql?` will have the same hash code, making them safe for use
|
|
131
|
+
as Hash keys and in Sets:
|
|
132
|
+
|
|
133
|
+
> [!NOTE]
|
|
134
|
+
> Ruby's `Hash` and `Set` use `eql?` and `hash` together. Equitable ensures
|
|
135
|
+
> these methods stay consistent—objects that are `eql?` always have matching
|
|
136
|
+
> hash codes.
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
require "set"
|
|
140
|
+
|
|
141
|
+
p1 = Point.new(1, 2)
|
|
142
|
+
p2 = Point.new(1, 2)
|
|
143
|
+
|
|
144
|
+
# As Hash keys
|
|
145
|
+
locations = {}
|
|
146
|
+
locations[p1] = "first"
|
|
147
|
+
locations[p2] = "second"
|
|
148
|
+
locations.size # => 1 (p1 and p2 are the same key)
|
|
149
|
+
|
|
150
|
+
# In Sets
|
|
151
|
+
set = Set.new
|
|
152
|
+
set << p1
|
|
153
|
+
set << p2
|
|
154
|
+
set.size # => 1
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Pattern Matching
|
|
158
|
+
|
|
159
|
+
Equitable provides full support for Ruby's pattern matching syntax.
|
|
160
|
+
|
|
161
|
+
> [!TIP]
|
|
162
|
+
> Use array patterns `[x, y]` for positional matching when attribute order
|
|
163
|
+
> matters. Use hash patterns `{x:, y:}` for named matching when you want
|
|
164
|
+
> clarity or only need specific attributes.
|
|
165
|
+
|
|
166
|
+
#### Array Patterns
|
|
167
|
+
|
|
168
|
+
Use `deconstruct` for array-style pattern matching:
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
point = Point.new(3, 4)
|
|
172
|
+
|
|
173
|
+
case point
|
|
174
|
+
in [0, 0]
|
|
175
|
+
puts "origin"
|
|
176
|
+
in [x, 0]
|
|
177
|
+
puts "on x-axis at #{x}"
|
|
178
|
+
in [0, y]
|
|
179
|
+
puts "on y-axis at #{y}"
|
|
180
|
+
in [x, y]
|
|
181
|
+
puts "at (#{x}, #{y})"
|
|
182
|
+
end
|
|
183
|
+
# => "at (3, 4)"
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
#### Hash Patterns
|
|
187
|
+
|
|
188
|
+
Use `deconstruct_keys` for hash-style pattern matching:
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
point = Point.new(3, 4)
|
|
192
|
+
|
|
193
|
+
case point
|
|
194
|
+
in { x: 0, y: 0 }
|
|
195
|
+
puts "origin"
|
|
196
|
+
in { x:, y: } if x == y
|
|
197
|
+
puts "on diagonal at #{x}"
|
|
198
|
+
in { x:, y: }
|
|
199
|
+
puts "at (#{x}, #{y})"
|
|
200
|
+
end
|
|
201
|
+
# => "at (3, 4)"
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
#### Class Patterns
|
|
205
|
+
|
|
206
|
+
Combine with class checks:
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
case point
|
|
210
|
+
in Point(x: 0, y: 0)
|
|
211
|
+
puts "origin point"
|
|
212
|
+
in Point(x:, y:)
|
|
213
|
+
puts "point at (#{x}, #{y})"
|
|
214
|
+
end
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Clean Inspect Output
|
|
218
|
+
|
|
219
|
+
Equitable customizes `inspect` to show only the attributes used for equality:
|
|
220
|
+
|
|
221
|
+
```ruby
|
|
222
|
+
class User
|
|
223
|
+
include Equitable.new(:id)
|
|
224
|
+
|
|
225
|
+
attr_reader :id, :name, :email, :created_at
|
|
226
|
+
|
|
227
|
+
def initialize(id, name, email)
|
|
228
|
+
@id = id
|
|
229
|
+
@name = name
|
|
230
|
+
@email = email
|
|
231
|
+
@created_at = Time.now
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
user = User.new(42, "Alice", "alice@example.com")
|
|
236
|
+
user.inspect
|
|
237
|
+
# => "#<User:0x00007f... @id=42>"
|
|
238
|
+
# Note: name, email, and created_at are not shown
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
> [!NOTE]
|
|
242
|
+
> When debugging, remember that `inspect` only shows equality attributes. Use
|
|
243
|
+
> `instance_variables` to see all instance variables if needed.
|
|
244
|
+
|
|
245
|
+
### Clean Ancestor Chain
|
|
246
|
+
|
|
247
|
+
The included module has a descriptive name in the ancestor chain:
|
|
248
|
+
|
|
249
|
+
```ruby
|
|
250
|
+
Point.ancestors
|
|
251
|
+
# => [Point, Equitable(x, y), Object, Kernel, BasicObject]
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## Nested Equitable Objects
|
|
255
|
+
|
|
256
|
+
Equitable objects can be nested and will compare correctly:
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
class Line
|
|
260
|
+
include Equitable.new(:start_point, :end_point)
|
|
261
|
+
|
|
262
|
+
attr_reader :start_point, :end_point
|
|
263
|
+
|
|
264
|
+
def initialize(start_point, end_point)
|
|
265
|
+
@start_point = start_point
|
|
266
|
+
@end_point = end_point
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
line1 = Line.new(Point.new(0, 0), Point.new(1, 1))
|
|
271
|
+
line2 = Line.new(Point.new(0, 0), Point.new(1, 1))
|
|
272
|
+
|
|
273
|
+
line1 == line2 # => true
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
## Error Handling
|
|
277
|
+
|
|
278
|
+
> [!CAUTION]
|
|
279
|
+
> Equitable validates arguments at include time. Errors will be raised
|
|
280
|
+
> immediately if you pass invalid arguments.
|
|
281
|
+
|
|
282
|
+
Equitable validates its arguments:
|
|
283
|
+
|
|
284
|
+
```ruby
|
|
285
|
+
# At least one attribute is required
|
|
286
|
+
Equitable.new()
|
|
287
|
+
# => ArgumentError: at least one attribute is required
|
|
288
|
+
|
|
289
|
+
# Attributes must be Symbols
|
|
290
|
+
Equitable.new("name")
|
|
291
|
+
# => ArgumentError: attribute must be a Symbol, got String
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
## License
|
|
295
|
+
|
|
296
|
+
The gem is available as open source under the terms of the [MIT License](LICENSE).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rake/testtask"
|
|
5
|
+
require "standard/rake"
|
|
6
|
+
require "rubocop/rake_task"
|
|
7
|
+
require "yard"
|
|
8
|
+
require "yardstick/rake/verify"
|
|
9
|
+
|
|
10
|
+
Rake::TestTask.new(:test) do |t|
|
|
11
|
+
t.libs << "test"
|
|
12
|
+
t.libs << "lib"
|
|
13
|
+
t.test_files = FileList["test/**/*_test.rb"]
|
|
14
|
+
t.warning = true
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
RuboCop::RakeTask.new(:rubocop) do |task|
|
|
18
|
+
task.options = %w[--display-cop-names]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
YARD::Rake::YardocTask.new(:yard) do |t|
|
|
22
|
+
t.files = ["lib/**/*.rb"]
|
|
23
|
+
t.options = ["--no-private", "--markup", "markdown"]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
Yardstick::Rake::Verify.new(:yardstick) do |verify|
|
|
27
|
+
verify.threshold = 100
|
|
28
|
+
verify.require_exact_threshold = false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
desc "Run Steep type checker"
|
|
32
|
+
task :steep do
|
|
33
|
+
sh "steep check"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
desc "Run mutation testing"
|
|
37
|
+
task :mutant do
|
|
38
|
+
ENV["MUTANT"] = "true"
|
|
39
|
+
sh "bundle exec mutant run"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
desc "Run all quality checks"
|
|
43
|
+
task quality: %i[rubocop steep yardstick]
|
|
44
|
+
|
|
45
|
+
desc "Run all checks (tests, quality, mutation)"
|
|
46
|
+
task default: %i[test quality mutant]
|
data/Steepfile
ADDED
data/lib/equitable.rb
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Equitable provides equality, equivalence, hashing, pattern matching, and
|
|
4
|
+
# inspection methods for Ruby objects based on specified attributes.
|
|
5
|
+
#
|
|
6
|
+
# @example Basic usage
|
|
7
|
+
# class GeoLocation
|
|
8
|
+
# include Equitable.new(:latitude, :longitude)
|
|
9
|
+
#
|
|
10
|
+
# attr_reader :latitude, :longitude, :name
|
|
11
|
+
#
|
|
12
|
+
# def initialize(latitude, longitude, name = nil)
|
|
13
|
+
# @latitude = latitude
|
|
14
|
+
# @longitude = longitude
|
|
15
|
+
# @name = name
|
|
16
|
+
# end
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# loc1 = GeoLocation.new(1.0, 2.0, "Home")
|
|
20
|
+
# loc2 = GeoLocation.new(1.0, 2.0, "Work")
|
|
21
|
+
# loc1 == loc2 # => true (name is not part of equality)
|
|
22
|
+
#
|
|
23
|
+
# @example Pattern matching
|
|
24
|
+
# case location
|
|
25
|
+
# in GeoLocation(latitude:, longitude:) then "#{latitude}, #{longitude}"
|
|
26
|
+
# in [lat, lon] then "coords: #{lat}, #{lon}"
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# @api public
|
|
30
|
+
module Equitable
|
|
31
|
+
# The current version of the Equitable gem
|
|
32
|
+
VERSION = "1.0.0"
|
|
33
|
+
|
|
34
|
+
# Creates a module providing equality methods based on the given attributes
|
|
35
|
+
#
|
|
36
|
+
# @example Basic usage
|
|
37
|
+
# class Point
|
|
38
|
+
# include Equitable.new(:x, :y)
|
|
39
|
+
# attr_reader :x, :y
|
|
40
|
+
# end
|
|
41
|
+
#
|
|
42
|
+
# @param keys [Array<Symbol>] attribute names to use for equality
|
|
43
|
+
# @return [Module] a module to include in your class
|
|
44
|
+
# @raise [ArgumentError] if keys is empty or contains non-Symbols
|
|
45
|
+
#
|
|
46
|
+
# @api public
|
|
47
|
+
def self.new(*keys)
|
|
48
|
+
validate_keys!(keys)
|
|
49
|
+
build_module(keys.freeze)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Validates that keys are non-empty and all Symbols
|
|
53
|
+
#
|
|
54
|
+
# @param keys [Array<Object>] the keys to validate
|
|
55
|
+
# @return [void]
|
|
56
|
+
# @raise [ArgumentError] if keys is empty or contains non-Symbols
|
|
57
|
+
#
|
|
58
|
+
# @api private
|
|
59
|
+
def self.validate_keys!(keys)
|
|
60
|
+
raise ArgumentError, "at least one attribute is required" if keys.empty?
|
|
61
|
+
|
|
62
|
+
invalid = keys.find { |key| !key.is_a?(Symbol) }
|
|
63
|
+
raise ArgumentError, "attribute must be a Symbol, got #{invalid.class}" if invalid
|
|
64
|
+
end
|
|
65
|
+
private_class_method :validate_keys!
|
|
66
|
+
|
|
67
|
+
# Instance methods mixed into classes that include an Equitable module
|
|
68
|
+
#
|
|
69
|
+
# @api private
|
|
70
|
+
module InstanceMethods
|
|
71
|
+
# Equality comparison allowing subclasses
|
|
72
|
+
#
|
|
73
|
+
# @param other [Object] object to compare
|
|
74
|
+
# @return [Boolean] true if other is_a? same class with equal attributes
|
|
75
|
+
def ==(other)
|
|
76
|
+
other.is_a?(self.class) && equitable_keys.all? { |key| public_send(key) == other.public_send(key) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Strict equality requiring exact class match
|
|
80
|
+
#
|
|
81
|
+
# @param other [Object] object to compare
|
|
82
|
+
# @return [Boolean] true if other is exact same class with eql? attributes
|
|
83
|
+
def eql?(other)
|
|
84
|
+
other.instance_of?(self.class) && equitable_keys.all? { |key| public_send(key).eql?(other.public_send(key)) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Hash code based on class and attribute values
|
|
88
|
+
#
|
|
89
|
+
# @return [Integer] hash code
|
|
90
|
+
def hash
|
|
91
|
+
[self.class, *deconstruct].hash
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Array deconstruction for pattern matching
|
|
95
|
+
#
|
|
96
|
+
# @return [Array] attribute values in order
|
|
97
|
+
def deconstruct
|
|
98
|
+
equitable_keys.map { |key| public_send(key) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Hash deconstruction for pattern matching
|
|
102
|
+
#
|
|
103
|
+
# @param requested [Array<Symbol>, nil] keys to include, or nil for all
|
|
104
|
+
# @return [Hash{Symbol => Object}] requested attribute key-value pairs
|
|
105
|
+
def deconstruct_keys(requested)
|
|
106
|
+
subset = requested.nil? ? equitable_keys : equitable_keys & requested
|
|
107
|
+
subset.to_h { |key| [key, public_send(key)] }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# String representation showing only equitable attributes
|
|
111
|
+
#
|
|
112
|
+
# @return [String] inspect output
|
|
113
|
+
def inspect
|
|
114
|
+
attrs = equitable_keys.map { |key| "@#{key}=#{public_send(key).inspect}" }.join(", ")
|
|
115
|
+
Object.instance_method(:to_s).bind_call(self).sub(/>\z/, " #{attrs}>")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Pretty print output using PP's object formatting
|
|
119
|
+
#
|
|
120
|
+
# @param q [PP] pretty printer
|
|
121
|
+
# @return [void]
|
|
122
|
+
def pretty_print(q)
|
|
123
|
+
q.pp_object(self)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Instance variables to display in pretty print output
|
|
127
|
+
#
|
|
128
|
+
# @return [Array<Symbol>] instance variable names
|
|
129
|
+
def pretty_print_instance_variables
|
|
130
|
+
equitable_keys.map { |key| :"@#{key}" }
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Builds the module with equality methods for the given keys
|
|
135
|
+
#
|
|
136
|
+
# @param keys [Array<Symbol>] attribute names (frozen)
|
|
137
|
+
# @return [Module] the configured module
|
|
138
|
+
#
|
|
139
|
+
# @api private
|
|
140
|
+
def self.build_module(keys)
|
|
141
|
+
Module.new do
|
|
142
|
+
include InstanceMethods
|
|
143
|
+
|
|
144
|
+
set_temporary_name("Equitable(#{keys.join(", ")})")
|
|
145
|
+
|
|
146
|
+
define_method(:equitable_keys) { keys }
|
|
147
|
+
private :equitable_keys
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
private_class_method :build_module
|
|
151
|
+
end
|
data/sig/equitable.rbs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Type signatures for the Equitable gem
|
|
2
|
+
#
|
|
3
|
+
# Equitable provides equality, equivalence, hashing, and inspection methods
|
|
4
|
+
# for Ruby objects based on specified attributes.
|
|
5
|
+
|
|
6
|
+
module Equitable
|
|
7
|
+
# The current version of the Equitable gem.
|
|
8
|
+
VERSION: String
|
|
9
|
+
|
|
10
|
+
# Define equality methods for the given attributes.
|
|
11
|
+
#
|
|
12
|
+
# Creates a module that, when included, adds equality methods
|
|
13
|
+
# based on the specified attributes.
|
|
14
|
+
def self.new: (*Symbol keys) -> Module
|
|
15
|
+
|
|
16
|
+
# Private method to validate keys
|
|
17
|
+
def self.validate_keys!: (Array[untyped] keys) -> void
|
|
18
|
+
|
|
19
|
+
# Private method to build the module
|
|
20
|
+
def self.build_module: (Array[Symbol] keys) -> Module
|
|
21
|
+
|
|
22
|
+
# Instance methods mixed into classes that include an Equitable module.
|
|
23
|
+
# Assumes equitable_keys is provided by the dynamic module.
|
|
24
|
+
module InstanceMethods : _Equitable
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Interface for classes that include an Equitable module
|
|
29
|
+
interface _Equitable
|
|
30
|
+
# Returns the attribute keys used for equality comparison (private).
|
|
31
|
+
def equitable_keys: () -> Array[Symbol]
|
|
32
|
+
|
|
33
|
+
# Equivalence check - allows subclass instances.
|
|
34
|
+
def ==: (untyped other) -> bool
|
|
35
|
+
|
|
36
|
+
# Strict equality check - requires exact same class.
|
|
37
|
+
def eql?: (untyped other) -> bool
|
|
38
|
+
|
|
39
|
+
# Generate a hash code based on class and attribute values.
|
|
40
|
+
def hash: () -> Integer
|
|
41
|
+
|
|
42
|
+
# Array deconstruction for pattern matching.
|
|
43
|
+
def deconstruct: () -> Array[untyped]
|
|
44
|
+
|
|
45
|
+
# Hash deconstruction for pattern matching.
|
|
46
|
+
def deconstruct_keys: (Array[Symbol]? requested) -> Hash[Symbol, untyped]
|
|
47
|
+
|
|
48
|
+
# Custom inspect output showing equitable attributes.
|
|
49
|
+
def inspect: () -> String
|
|
50
|
+
|
|
51
|
+
# Pretty print output using PP's object formatting.
|
|
52
|
+
def pretty_print: (untyped q) -> void
|
|
53
|
+
|
|
54
|
+
# Returns instance variable names for PP (pretty print).
|
|
55
|
+
def pretty_print_instance_variables: () -> Array[Symbol]
|
|
56
|
+
end
|
|
57
|
+
|
metadata
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: equitable
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Erik Berlin
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: |
|
|
13
|
+
Equitable provides a simple way to define equality (==), equivalence (eql?),
|
|
14
|
+
and hashing (hash) methods for Ruby objects based on specified attributes.
|
|
15
|
+
Includes pattern matching support and clean inspect output across Ruby versions.
|
|
16
|
+
email:
|
|
17
|
+
- sferik@gmail.com
|
|
18
|
+
executables: []
|
|
19
|
+
extensions: []
|
|
20
|
+
extra_rdoc_files: []
|
|
21
|
+
files:
|
|
22
|
+
- ".mutant.yml"
|
|
23
|
+
- ".rubocop.yml"
|
|
24
|
+
- ".yardopts"
|
|
25
|
+
- ".yardstick.yml"
|
|
26
|
+
- LICENSE
|
|
27
|
+
- README.md
|
|
28
|
+
- Rakefile
|
|
29
|
+
- Steepfile
|
|
30
|
+
- lib/equitable.rb
|
|
31
|
+
- sig/equitable.rbs
|
|
32
|
+
homepage: https://github.com/sferik/equitable
|
|
33
|
+
licenses:
|
|
34
|
+
- MIT
|
|
35
|
+
metadata:
|
|
36
|
+
homepage_uri: https://github.com/sferik/equitable
|
|
37
|
+
source_code_uri: https://github.com/sferik/equitable
|
|
38
|
+
changelog_uri: https://github.com/sferik/equitable/blob/main/CHANGELOG.md
|
|
39
|
+
rubygems_mfa_required: 'true'
|
|
40
|
+
rdoc_options: []
|
|
41
|
+
require_paths:
|
|
42
|
+
- lib
|
|
43
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - ">="
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '3.3'
|
|
48
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - ">="
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '0'
|
|
53
|
+
requirements: []
|
|
54
|
+
rubygems_version: 4.0.4
|
|
55
|
+
specification_version: 4
|
|
56
|
+
summary: Define equality, equivalence, and hashing methods for Ruby objects
|
|
57
|
+
test_files: []
|