sortsmith 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 16b369873631fe1de4c59f32bbbf5fbdb383ee1b3924a864ccd11a2ac6c4f1d5
4
- data.tar.gz: cb329eecad62b7cf05e13624258fc09dd4a21e0aab192e50774a8e2a6c6beed3
3
+ metadata.gz: 594500201df14a274a22465d71f25e9471d67a9f6154047d73826974bdec2d62
4
+ data.tar.gz: cbf4a37cfeb106380825ea34aa9963c9342b2c2c8776149df7e1a1f2c9b60582
5
5
  SHA512:
6
- metadata.gz: e3ada898d22d88aabe5c314dc67ac35dced713ec5f7bced55ec4ca87d12f10cd62b9fe218d6acb45831d9ec62734917d72c5343dce56b15bace088b23188bc95
7
- data.tar.gz: e362605cde0d51b9eb63ef435e69cf13d91651ff2a9ba22dfe07a8cdd1e33c9c8bb0ce695c0914bfb3579226a3306c2d4d03f029752c25f8b17dbff22133fb1d
6
+ metadata.gz: 1dba596a763ca288fa3bd45bc5d5f8f4dd7cbd5683e886bd4e765957d1ae4225605454af524825073a006cd63fe5c183063b613aa4317abb6ad3b2ffcf00739b
7
+ data.tar.gz: aa1d3317d8486227a8e33da7633101afa356fdf2a640ff678d1f8d1b140bd734abce58fc3767899f352609574c00400e02106e47fd1e404540ef9087490da9c6
data/CHANGELOG.md CHANGED
@@ -15,6 +15,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
15
15
  ### Removed
16
16
  -->
17
17
 
18
+ ## [1.0.1] - 12026-02-15
19
+
20
+ ### Added
21
+
22
+ #### Nil Handling Control
23
+
24
+ - **`nil_first`** - Position nil values at the beginning of sort results
25
+ - **`nil_last`** - Explicitly position nil values at the end (default behavior)
26
+ - Nil positioning is independent of `asc`/`desc` modifiers for predictable behavior
27
+ - Graceful handling of nil values during comparison (should be no more comparison errors)
28
+
29
+ ### Changed
30
+
31
+ #### Internal Implementation
32
+
33
+ - **Major performance improvement**: Refactored sorting to use Schwartzian Transform (extract once, sort, map back) instead of comparison sort
34
+ - Reduced overhead from ~65x slower to ~5.9x slower compared to native Ruby sort (measured on Ruby 3.2.9, AMD Ryzen 7 3700X, 32GB RAM)
35
+ - ~11x speedup for typical use cases
36
+ - Tests now run 4.4x faster
37
+ - Refactored `asc`/`desc` from array ordering to comparison-time flag for better performance and consistency
38
+ - Improved nil comparison logic to handle edge cases consistently
39
+
40
+ ### Fixed
41
+
42
+ - Fixed comparison errors when sorting collections with nil values
43
+ - Fixed `NoMethodError` when trying to negate nil result in descending sorts
44
+
45
+
18
46
  ## [1.0.0] - 12025-08-03
19
47
 
20
48
  ### 🎉 API Stability Milestone
@@ -185,7 +213,8 @@ objects.sort_by.dig(:calculate_score).sort
185
213
  - Type checking with Steep/RBS
186
214
  - GitHub Actions workflow for automated testing and type checking
187
215
 
188
- [unreleased]: https://github.com/itsthedevman/sortsmith/compare/v1.0.0...HEAD
216
+ [unreleased]: https://github.com/itsthedevman/sortsmith/compare/v1.0.1...HEAD
217
+ [1.0.1]: https://github.com/itsthedevman/sortsmith/compare/v1.0.0...v1.0.1
189
218
  [1.0.0]: https://github.com/itsthedevman/sortsmith/compare/v0.9.0...v1.0.0
190
219
  [0.9.0]: https://github.com/itsthedevman/sortsmith/compare/v0.2.0...v0.9.0
191
220
  [0.2.0]: https://github.com/itsthedevman/sortsmith/compare/v0.1.1...v0.2.0
data/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  users.sort_by { |user| user[:name].downcase }.reverse
12
12
 
13
13
  # Write this!
14
- users.sort_by.dig(:name).downcase.reverse
14
+ users.sort_by(:name).downcase.reverse
15
15
  ```
16
16
 
17
17
  Sortsmith extends Ruby's built-in `sort_by` method with a fluent, chainable API that handles the messy details so you can focus on expressing _what_ you want sorted, not _how_ to sort it.
@@ -46,12 +46,19 @@ users.sort_by { |u| (u[:name] || "").downcase }.reverse
46
46
 
47
47
  # What about mixed string/symbol keys?
48
48
  users.sort_by { |u| (u[:name] || u["name"] || "").downcase }.reverse
49
+
50
+ # Need nils first instead of last?
51
+ nils, non_nils = users.partition { |u| u[:name].nil? }
52
+ nils + non_nils.sort_by { |u| u[:name].downcase }
49
53
  ```
50
54
 
51
55
  Sortsmith handles all the edge cases and gives you a clean, readable API:
52
56
 
53
57
  ```ruby
54
- users.sort_by.dig(:name, indifferent: true).insensitive.desc.sort
58
+ users.sort_by.dig(:name).insensitive.desc.sort
59
+
60
+ # With nils first
61
+ users.sort_by.dig(:name).insensitive.nil_first.sort
55
62
  ```
56
63
 
57
64
  **Features:**
@@ -59,7 +66,7 @@ users.sort_by.dig(:name, indifferent: true).insensitive.desc.sort
59
66
  - **Fluent chaining** - Reads like English
60
67
  - **Universal extraction** - Works with hashes, objects, and nested data
61
68
  - **Indifferent key access** - Handles mixed symbol/string keys automatically
62
- - **Nil-safe** - Graceful handling of missing data
69
+ - **Nil-safe** - Graceful handling of missing data with control over nil positioning
63
70
  - **Minimal overhead** - Extends existing Ruby methods without breaking compatibility
64
71
 
65
72
  ## Installation
@@ -89,7 +96,7 @@ Sortsmith extends Ruby's `sort_by` method with a fluent, chainable API. Use it w
89
96
  ```ruby
90
97
  require "sortsmith"
91
98
 
92
- # Direct syntax for simple cases (NEW!)
99
+ # Direct syntax for simple cases
93
100
  names = ["charlie", "Alice", "bob"]
94
101
  names.sort_by(:name).insensitive.sort
95
102
  # => ["Alice", "bob", "charlie"]
@@ -104,7 +111,7 @@ users = [
104
111
  users.sort_by.dig(:name).sort
105
112
  # => [{ name: "Alice", age: 30 }, { name: "Bob", age: 20 }, { name: "Charlie", age: 25 }]
106
113
 
107
- # Seamless integration with enumerable methods (NEW!)
114
+ # Seamless integration with enumerable methods
108
115
  users.sort_by(:age).desc.first(2)
109
116
  # => [{ name: "Alice", age: 30 }, { name: "Charlie", age: 25 }]
110
117
 
@@ -267,24 +274,51 @@ api_response.sort_by.dig(:name, indifferent: true).sort
267
274
 
268
275
  ### Universal Extraction
269
276
 
270
- - **`sort_by(field, **opts)`\*\* - Direct field extraction (NEW!)
277
+ - **`sort_by(field, **opts)`** - Direct field extraction
271
278
  - Works with hashes, objects, and any method name
272
279
  - Supports all the same options as `dig` and `method`
273
280
 
274
281
  ### Extractors
275
282
 
276
283
  - **`dig(*identifiers, indifferent: false)`** - Extract values from hashes, objects, or nested structures
277
- - **`method(method_name, \*args, **kwargs)`\*\* - Call methods on objects with arguments (NEW!)
278
- - **`key(\*identifiers, **opts)`** - Alias for `dig` (semantic clarity for hash keys) (NEW!)
279
- - **`field(\*identifiers, **opts)`** - Alias for `dig` (semantic clarity for object fields) (NEW!)
280
- - **`attribute(method_name, \*args, **kwargs)`** - Alias for `method` (semantic clarity) (NEW!)
284
+ - **`method(method_name, *args, **kwargs)`** - Call methods on objects with arguments
285
+ - **`key(*identifiers, **opts)`** - Alias for `dig` (semantic clarity for hash keys)
286
+ - **`field(*identifiers, **opts)`** - Alias for `dig` (semantic clarity for object fields)
287
+ - **`attribute(method_name, *args, **kwargs)`** - Alias for `method` (semantic clarity)
281
288
 
282
289
  ### Modifiers
283
290
 
284
291
  - **`downcase`** - Convert extracted strings to lowercase for comparison
285
292
  - **`upcase`** - Convert extracted strings to uppercase for comparison
286
293
  - **`insensitive`** - Alias for `downcase` (semantic clarity)
287
- - **`case_insensitive`** - Alias for `downcase` (explicit case handling) (NEW!)
294
+ - **`case_insensitive`** - Alias for `downcase` (explicit case handling)
295
+
296
+ ### Nil Handling (NEW!)
297
+
298
+ - **`nil_first`** - Position nil values at the beginning of results
299
+ - **`nil_last`** - Position nil values at the end of results (default)
300
+
301
+ ```ruby
302
+ users = [
303
+ { name: "Bob" },
304
+ { name: nil },
305
+ { name: "Alice" }
306
+ ]
307
+
308
+ # Default: nils last
309
+ users.sort_by.dig(:name).sort
310
+ # => [{ name: "Alice" }, { name: "Bob" }, { name: nil }]
311
+
312
+ # Nils first
313
+ users.sort_by.dig(:name).nil_first.sort
314
+ # => [{ name: nil }, { name: "Alice" }, { name: "Bob" }]
315
+
316
+ # Nil positioning is independent of sort direction
317
+ users.sort_by.dig(:name).nil_first.desc.sort
318
+ # => [{ name: nil }, { name: "Bob" }, { name: "Alice" }]
319
+ ```
320
+
321
+ **Note**: Nil positioning is independent of `asc`/`desc` modifiers. `nil_first` always places nils at the beginning, and `nil_last` always places them at the end, regardless of whether the non-nil values are sorted ascending or descending.
288
322
 
289
323
  ### Ordering
290
324
 
@@ -296,11 +330,11 @@ api_response.sort_by.dig(:name, indifferent: true).sort
296
330
  - **`sort`** - Execute sort and return new array
297
331
  - **`sort!`** - Execute sort and mutate original array
298
332
  - **`to_a`** - Alias for `sort`
299
- - **`to_a!`** - Alias for `sort!` (NEW!)
333
+ - **`to_a!`** - Alias for `sort!`
300
334
  - **`reverse`** - Shorthand for `desc.sort`
301
335
  - **`reverse!`** - Shorthand for `desc.sort!`
302
336
 
303
- ### Delegated Enumerable Methods (NEW!)
337
+ ### Delegated Enumerable Methods
304
338
 
305
339
  The following methods execute the sort and delegate to the resulting array:
306
340
 
@@ -345,6 +379,31 @@ bundle install
345
379
  bundle exec rake test
346
380
  ```
347
381
 
382
+ ### Benchmarks
383
+
384
+ Run performance benchmarks to compare Sortsmith with native Ruby:
385
+
386
+ ```bash
387
+ # Quick benchmark
388
+ bundle exec rake benchmark
389
+
390
+ # Full benchmark suite
391
+ bundle exec rake benchmark:full
392
+ ```
393
+
394
+ #### Performance
395
+
396
+ Sortsmith prioritizes developer ergonomics and code readability over raw performance. Benchmarks show approximately **5.7x-6.5x slower** than native Ruby `sort_by` for typical use cases:
397
+
398
+ - Basic sorting: **5.75x slower**
399
+ - Case-insensitive + descending: **5.65x slower**
400
+ - With nil values: **6.06x slower**
401
+ - Top N selection: **6.50x slower**
402
+
403
+ *Benchmarked on Ruby 3.2.9, AMD Ryzen 7 3700X 8-Core Processor, 32GB RAM*
404
+
405
+ The performance overhead is typically measured in microseconds for small to medium datasets, making it negligible for most real-world applications where code clarity and maintainability are more important than micro-optimizations.
406
+
348
407
  ### Code Style
349
408
 
350
409
  This project uses StandardRB. To check your code:
data/Rakefile CHANGED
@@ -8,3 +8,18 @@ Minitest::TestTask.create
8
8
  require "standard/rake"
9
9
 
10
10
  task default: %i[test standard]
11
+
12
+ namespace :benchmark do
13
+ desc "Run quick benchmark"
14
+ task :quick do
15
+ ruby "benchmark/quick_benchmark.rb"
16
+ end
17
+
18
+ desc "Run full benchmark suite"
19
+ task :full do
20
+ ruby "benchmark/sorting_benchmark.rb"
21
+ end
22
+ end
23
+
24
+ desc "Run quick benchmark"
25
+ task benchmark: "benchmark:quick"
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "sortsmith"
6
+ require "benchmark/ips"
7
+
8
+ # Quick comparison of common scenarios
9
+ puts "Quick Performance Comparison"
10
+ puts "=" * 60
11
+
12
+ users = Array.new(1000) do |i|
13
+ {
14
+ name: ["Alice", "bob", "Charlie", "DIANA"].sample,
15
+ score: rand(0..100),
16
+ age: i.even? ? rand(18..65) : nil
17
+ }
18
+ end
19
+
20
+ puts "\n1. Basic sorting"
21
+ Benchmark.ips do |x|
22
+ x.report("native") { users.sort_by { |u| u[:name] } }
23
+ x.report("sortsmith") { users.sort_by.dig(:name).sort }
24
+ x.compare!
25
+ end
26
+
27
+ puts "\n2. Case-insensitive + descending"
28
+ Benchmark.ips do |x|
29
+ x.report("native") { users.sort_by { |u| u[:name].to_s.downcase }.reverse }
30
+ x.report("sortsmith") { users.sort_by.dig(:name).insensitive.desc.sort }
31
+ x.compare!
32
+ end
33
+
34
+ puts "\n3. With nil values"
35
+ Benchmark.ips do |x|
36
+ x.report("native") { users.sort_by { |u| u[:age] || 0 } }
37
+ x.report("sortsmith") { users.sort_by.dig(:age).sort }
38
+ x.compare!
39
+ end
40
+
41
+ puts "\n4. Top 5 by score"
42
+ Benchmark.ips do |x|
43
+ x.report("native") { users.sort_by { |u| u[:score] }.last(5).reverse }
44
+ x.report("sortsmith") { users.sort_by.dig(:score).desc.first(5) }
45
+ x.compare!
46
+ end
47
+
48
+ puts "\n" + "=" * 60
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "sortsmith"
6
+ require "benchmark/ips"
7
+
8
+ # Sample data generators
9
+ def generate_users(count)
10
+ Array.new(count) do |i|
11
+ {
12
+ name: ["Alice", "Bob", "Charlie", "Diana", "Eve", "Frank"].sample,
13
+ age: rand(18..65),
14
+ score: rand(0..100),
15
+ email: "user#{i}@example.com"
16
+ }
17
+ end
18
+ end
19
+
20
+ def generate_mixed_nil_users(count)
21
+ Array.new(count) do |i|
22
+ {
23
+ name: i.even? ? ["Alice", "Bob", "Charlie"].sample : nil,
24
+ age: rand(18..65),
25
+ score: i.odd? ? rand(0..100) : nil
26
+ }
27
+ end
28
+ end
29
+
30
+ def generate_mixed_case_names(count)
31
+ names = ["alice", "BOB", "Charlie", "DIANA", "eve", "Frank"]
32
+ Array.new(count) { names.sample }
33
+ end
34
+
35
+ puts "=" * 80
36
+ puts "Sortsmith Performance Benchmarks"
37
+ puts "=" * 80
38
+ puts
39
+
40
+ # Benchmark 1: Basic hash key sorting
41
+ puts "Benchmark 1: Basic hash key sorting (1000 items)"
42
+ puts "-" * 80
43
+ users = generate_users(1000)
44
+
45
+ Benchmark.ips do |x|
46
+ x.config(time: 5, warmup: 2)
47
+
48
+ x.report("native sort_by") do
49
+ users.sort_by { |u| u[:name] }
50
+ end
51
+
52
+ x.report("sortsmith .dig") do
53
+ users.sort_by.dig(:name).sort
54
+ end
55
+
56
+ x.report("sortsmith direct") do
57
+ users.sort_by(:name).sort
58
+ end
59
+
60
+ x.compare!
61
+ end
62
+
63
+ puts "\n"
64
+
65
+ # Benchmark 2: Case-insensitive sorting
66
+ puts "Benchmark 2: Case-insensitive sorting (1000 items)"
67
+ puts "-" * 80
68
+ names = generate_mixed_case_names(1000)
69
+
70
+ Benchmark.ips do |x|
71
+ x.config(time: 5, warmup: 2)
72
+
73
+ x.report("native downcase") do
74
+ names.sort_by { |n| n.downcase }
75
+ end
76
+
77
+ x.report("sortsmith .insensitive") do
78
+ names.sort_by.insensitive.sort
79
+ end
80
+
81
+ x.compare!
82
+ end
83
+
84
+ puts "\n"
85
+
86
+ # Benchmark 3: Descending order
87
+ puts "Benchmark 3: Descending order (1000 items)"
88
+ puts "-" * 80
89
+
90
+ Benchmark.ips do |x|
91
+ x.config(time: 5, warmup: 2)
92
+
93
+ x.report("native reverse") do
94
+ users.sort_by { |u| u[:score] }.reverse
95
+ end
96
+
97
+ x.report("sortsmith .desc") do
98
+ users.sort_by.dig(:score).desc.sort
99
+ end
100
+
101
+ x.report("sortsmith .reverse") do
102
+ users.sort_by.dig(:score).reverse
103
+ end
104
+
105
+ x.compare!
106
+ end
107
+
108
+ puts "\n"
109
+
110
+ # Benchmark 4: Complex chaining
111
+ puts "Benchmark 4: Complex chaining - case-insensitive descending (1000 items)"
112
+ puts "-" * 80
113
+
114
+ Benchmark.ips do |x|
115
+ x.config(time: 5, warmup: 2)
116
+
117
+ x.report("native") do
118
+ users.sort_by { |u| u[:name].to_s.downcase }.reverse
119
+ end
120
+
121
+ x.report("sortsmith") do
122
+ users.sort_by.dig(:name).insensitive.desc.sort
123
+ end
124
+
125
+ x.compare!
126
+ end
127
+
128
+ puts "\n"
129
+
130
+ # Benchmark 5: Nil handling
131
+ puts "Benchmark 5: Nil handling (1000 items, ~50% nils)"
132
+ puts "-" * 80
133
+ mixed_users = generate_mixed_nil_users(1000)
134
+
135
+ Benchmark.ips do |x|
136
+ x.config(time: 5, warmup: 2)
137
+
138
+ x.report("native with || fallback") do
139
+ mixed_users.sort_by { |u| u[:name] || "" }
140
+ end
141
+
142
+ x.report("sortsmith nil_last (default)") do
143
+ mixed_users.sort_by.dig(:name).sort
144
+ end
145
+
146
+ x.report("sortsmith nil_first") do
147
+ mixed_users.sort_by.dig(:name).nil_first.sort
148
+ end
149
+
150
+ x.compare!
151
+ end
152
+
153
+ puts "\n"
154
+
155
+ # Benchmark 6: Small vs Large datasets
156
+ puts "Benchmark 6: Scaling - Small vs Large datasets"
157
+ puts "-" * 80
158
+
159
+ [10, 100, 1_000, 10_000].each do |size|
160
+ puts "\n#{size} items:"
161
+ data = generate_users(size)
162
+
163
+ Benchmark.ips do |x|
164
+ x.config(time: 3, warmup: 1)
165
+
166
+ x.report("native") do
167
+ data.sort_by { |u| u[:name] }
168
+ end
169
+
170
+ x.report("sortsmith") do
171
+ data.sort_by.dig(:name).sort
172
+ end
173
+
174
+ x.compare!
175
+ end
176
+ end
177
+
178
+ puts "\n"
179
+
180
+ # Benchmark 7: Delegated methods
181
+ puts "Benchmark 7: Delegated enumerable methods (1000 items)"
182
+ puts "-" * 80
183
+
184
+ Benchmark.ips do |x|
185
+ x.config(time: 5, warmup: 2)
186
+
187
+ x.report("native .first(3)") do
188
+ users.sort_by { |u| u[:score] }.last(3).reverse
189
+ end
190
+
191
+ x.report("sortsmith .first(3)") do
192
+ users.sort_by.dig(:score).desc.first(3)
193
+ end
194
+
195
+ x.report("native .each") do
196
+ users.sort_by { |u| u[:name] }.each { |u| u[:name] }
197
+ end
198
+
199
+ x.report("sortsmith .each") do
200
+ users.sort_by.dig(:name).each { |u| u[:name] }
201
+ end
202
+
203
+ x.compare!
204
+ end
205
+
206
+ puts "\n" + "=" * 60
data/flake.lock CHANGED
@@ -1,5 +1,21 @@
1
1
  {
2
2
  "nodes": {
3
+ "flake-compat": {
4
+ "flake": false,
5
+ "locked": {
6
+ "lastModified": 1747046372,
7
+ "narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=",
8
+ "owner": "edolstra",
9
+ "repo": "flake-compat",
10
+ "rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
11
+ "type": "github"
12
+ },
13
+ "original": {
14
+ "owner": "edolstra",
15
+ "repo": "flake-compat",
16
+ "type": "github"
17
+ }
18
+ },
3
19
  "flake-utils": {
4
20
  "inputs": {
5
21
  "systems": "systems"
@@ -18,13 +34,31 @@
18
34
  "type": "github"
19
35
  }
20
36
  },
37
+ "flake-utils_2": {
38
+ "inputs": {
39
+ "systems": "systems_2"
40
+ },
41
+ "locked": {
42
+ "lastModified": 1731533236,
43
+ "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
44
+ "owner": "numtide",
45
+ "repo": "flake-utils",
46
+ "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
47
+ "type": "github"
48
+ },
49
+ "original": {
50
+ "owner": "numtide",
51
+ "repo": "flake-utils",
52
+ "type": "github"
53
+ }
54
+ },
21
55
  "nixpkgs": {
22
56
  "locked": {
23
- "lastModified": 1751792365,
24
- "narHash": "sha256-J1kI6oAj25IG4EdVlg2hQz8NZTBNYvIS0l4wpr9KcUo=",
57
+ "lastModified": 1771008912,
58
+ "narHash": "sha256-gf2AmWVTs8lEq7z/3ZAsgnZDhWIckkb+ZnAo5RzSxJg=",
25
59
  "owner": "NixOS",
26
60
  "repo": "nixpkgs",
27
- "rev": "1fd8bada0b6117e6c7eb54aad5813023eed37ccb",
61
+ "rev": "a82ccc39b39b621151d6732718e3e250109076fa",
28
62
  "type": "github"
29
63
  },
30
64
  "original": {
@@ -34,10 +68,33 @@
34
68
  "type": "github"
35
69
  }
36
70
  },
71
+ "nixpkgs-ruby": {
72
+ "inputs": {
73
+ "flake-compat": "flake-compat",
74
+ "flake-utils": "flake-utils_2",
75
+ "nixpkgs": [
76
+ "nixpkgs"
77
+ ]
78
+ },
79
+ "locked": {
80
+ "lastModified": 1770273217,
81
+ "narHash": "sha256-biMRh5KRKwtDbyHaBTmdrQxB0Ua2SlMEF+krGH1tPt0=",
82
+ "owner": "bobvanderlinden",
83
+ "repo": "nixpkgs-ruby",
84
+ "rev": "8b840328105a5d6d5c0aac9d3d12d7e2213aac33",
85
+ "type": "github"
86
+ },
87
+ "original": {
88
+ "owner": "bobvanderlinden",
89
+ "repo": "nixpkgs-ruby",
90
+ "type": "github"
91
+ }
92
+ },
37
93
  "root": {
38
94
  "inputs": {
39
95
  "flake-utils": "flake-utils",
40
- "nixpkgs": "nixpkgs"
96
+ "nixpkgs": "nixpkgs",
97
+ "nixpkgs-ruby": "nixpkgs-ruby"
41
98
  }
42
99
  },
43
100
  "systems": {
@@ -54,6 +111,21 @@
54
111
  "repo": "default",
55
112
  "type": "github"
56
113
  }
114
+ },
115
+ "systems_2": {
116
+ "locked": {
117
+ "lastModified": 1681028828,
118
+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
119
+ "owner": "nix-systems",
120
+ "repo": "default",
121
+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
122
+ "type": "github"
123
+ },
124
+ "original": {
125
+ "owner": "nix-systems",
126
+ "repo": "default",
127
+ "type": "github"
128
+ }
57
129
  }
58
130
  },
59
131
  "root": "root",
data/flake.nix CHANGED
@@ -4,6 +4,8 @@
4
4
  inputs = {
5
5
  nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
6
6
  flake-utils.url = "github:numtide/flake-utils";
7
+ nixpkgs-ruby.url = "github:bobvanderlinden/nixpkgs-ruby";
8
+ nixpkgs-ruby.inputs.nixpkgs.follows = "nixpkgs";
7
9
  };
8
10
 
9
11
  outputs =
@@ -11,19 +13,19 @@
11
13
  self,
12
14
  nixpkgs,
13
15
  flake-utils,
16
+ nixpkgs-ruby,
14
17
  }:
15
18
  flake-utils.lib.eachDefaultSystem (
16
19
  system:
17
20
  let
18
21
  pkgs = nixpkgs.legacyPackages.${system};
22
+ ruby = nixpkgs-ruby.packages.${system}."ruby-3.2.9";
19
23
  in
20
24
  {
21
25
  devShells.default = pkgs.mkShell {
22
- buildInputs = with pkgs; [
23
- (ruby_3_2.override {
24
- jemallocSupport = false;
25
- docSupport = false;
26
- })
26
+ buildInputs = [
27
+ ruby
28
+ ] ++ (with pkgs; [
27
29
 
28
30
  # Dependencies for native gems
29
31
  pkg-config
@@ -31,7 +33,7 @@
31
33
  readline
32
34
  zstd
33
35
  libyaml
34
- ];
36
+ ]);
35
37
 
36
38
  shellHook = ''
37
39
  export GEM_HOME="$PWD/vendor/bundle"
@@ -32,6 +32,14 @@ module Sortsmith
32
32
  # users.sort_by.method(:missing_email).sort
33
33
  # # => preserves original order when method doesn't exist
34
34
  #
35
+ # @example Nil value handling
36
+ # users = [{name: "Bob"}, {name: nil}, {name: "Alice"}]
37
+ # users.sort_by.dig(:name).nil_first.sort
38
+ # # => [{name: nil}, {name: "Alice"}, {name: "Bob"}]
39
+ #
40
+ # users.sort_by.dig(:name).nil_last.desc.sort
41
+ # # => [{name: "Bob"}, {name: "Alice"}, {name: nil}]
42
+ #
35
43
  # @see Enumerable#sort_by The enhanced sort_by method
36
44
  # @since 0.9.0
37
45
  #
@@ -91,7 +99,8 @@ module Sortsmith
91
99
  @input = input
92
100
  @extractors = []
93
101
  @modifiers = []
94
- @ordering = []
102
+ @descending = false
103
+ @nil_first = false
95
104
  end
96
105
 
97
106
  ############################################################################
@@ -393,7 +402,7 @@ module Sortsmith
393
402
  # users.sort_by.dig(:name).desc.asc.sort # ends up ascending
394
403
  #
395
404
  def asc
396
- @ordering << {method: :sort!}
405
+ @descending = false
397
406
  self
398
407
  end
399
408
 
@@ -414,7 +423,79 @@ module Sortsmith
414
423
  # # => Case-insensitive, reverse alphabetical
415
424
  #
416
425
  def desc
417
- @ordering << {method: :reverse!}
426
+ @descending = true
427
+ self
428
+ end
429
+
430
+ ##
431
+ # Position nil values at the beginning of sort results.
432
+ #
433
+ # By default, nil values sort last (matching SQL/database conventions).
434
+ # This modifier overrides that behavior to place nils first, regardless
435
+ # of sort direction (asc/desc).
436
+ #
437
+ # The nil positioning is independent of asc/desc modifiers, meaning
438
+ # nil_first with desc will show nils first, then non-nil values in
439
+ # descending order.
440
+ #
441
+ # @return [Sorter] Returns self for method chaining
442
+ #
443
+ # @example Basic nil_first usage
444
+ # users = [
445
+ # {name: "Bob"},
446
+ # {name: nil},
447
+ # {name: "Alice"}
448
+ # ]
449
+ # users.sort_by.dig(:name).nil_first.sort
450
+ # # => [{name: nil}, {name: "Alice"}, {name: "Bob"}]
451
+ #
452
+ # @example Combining with desc
453
+ # users.sort_by.dig(:name).nil_first.desc.sort
454
+ # # => [{name: nil}, {name: "Bob"}, {name: "Alice"}]
455
+ # # Nils first, then descending order
456
+ #
457
+ # @example With case modifiers
458
+ # users.sort_by.dig(:name).insensitive.nil_first.sort
459
+ # # => Case-insensitive sort with nils first
460
+ #
461
+ # @see #nil_last To explicitly position nils at the end
462
+ #
463
+ def nil_first
464
+ @nil_first = true
465
+ self
466
+ end
467
+
468
+ ##
469
+ # Position nil values at the end of sort results (explicit default).
470
+ #
471
+ # This is the default behavior, but can be used for explicitness or to
472
+ # override a previous nil_first call in a chain. Nil values will appear
473
+ # last regardless of sort direction (asc/desc).
474
+ #
475
+ # @return [Sorter] Returns self for method chaining
476
+ #
477
+ # @example Explicit nil_last
478
+ # users = [
479
+ # {name: "Bob"},
480
+ # {name: nil},
481
+ # {name: "Alice"}
482
+ # ]
483
+ # users.sort_by.dig(:name).nil_last.sort
484
+ # # => [{name: "Alice"}, {name: "Bob"}, {name: nil}]
485
+ #
486
+ # @example Overriding nil_first
487
+ # users.sort_by.dig(:name).nil_first.nil_last.sort
488
+ # # => Last setting wins: nils appear last
489
+ #
490
+ # @example With desc
491
+ # users.sort_by.dig(:name).nil_last.desc.sort
492
+ # # => [{name: "Bob"}, {name: "Alice"}, {name: nil}]
493
+ # # Descending order, then nils last
494
+ #
495
+ # @see #nil_first To position nils at the beginning
496
+ #
497
+ def nil_last
498
+ @nil_first = false
418
499
  self
419
500
  end
420
501
 
@@ -439,11 +520,53 @@ module Sortsmith
439
520
  # result = users.sort_by.dig(:name, indifferent: true).insensitive.desc.sort
440
521
  #
441
522
  def sort
442
- # Apply all extraction and transformation steps during comparison
443
- sorted = @input.sort { |a, b| apply_sorting(a, b) }
523
+ # Schwartzian Transform: extract once (O(n)), sort (O(n log n)), map back (O(n))
524
+
525
+ # Step 1: Pair each item with its extracted sort value
526
+ pairs = @input.map { |item| [extract_and_transform(item), item] }
527
+
528
+ # Step 2: Partition nils so we can use C-level sort_by on non-nil values
529
+ nil_pairs, non_nil_pairs = pairs.partition { |v, _| v.nil? }
530
+
531
+ # Step 3: Sort non-nil values using Ruby's native sort_by (runs in C)
532
+ begin
533
+ non_nil_pairs.sort_by! { |v, _| v }
534
+ rescue ArgumentError
535
+ # Re-raise with a more helpful error message
536
+ # Find the first pair of incomparable values for the message
537
+ non_nil_pairs.each_cons(2) do |(val_a, _), (val_b, _)|
538
+ result = val_a <=> val_b
539
+ next unless result.nil?
540
+
541
+ # <=> returned nil - incomparable types
542
+ raise ArgumentError, <<~ERROR
543
+ Cannot compare values during sort - the values are incomparable types.
544
+ This usually means your extraction returned mixed types or you're missing an extraction method.
545
+ Comparing:
546
+ #{val_a.inspect} (#{val_a.class})
547
+ <=>
548
+ #{val_b.inspect} (#{val_b.class})
549
+ ERROR
550
+ rescue ArgumentError
551
+ # <=> raised instead of returning nil
552
+ raise ArgumentError, <<~ERROR
553
+ Cannot compare values during sort - the <=> operator raised an exception.
554
+ This usually means the class doesn't implement <=>, has a buggy implementation, or you're missing an extraction method.
555
+ Comparing:
556
+ #{val_a.inspect} (#{val_a.class})
557
+ <=>
558
+ #{val_b.inspect} (#{val_b.class})
559
+ ERROR
560
+ end
561
+ end
562
+
563
+ non_nil_pairs.reverse! if @descending
564
+
565
+ # Step 4: Combine based on nil positioning
566
+ result = @nil_first ? nil_pairs.concat(non_nil_pairs) : non_nil_pairs.concat(nil_pairs)
444
567
 
445
- # Apply any ordering transformations (like desc)
446
- apply_ordering(sorted)
568
+ # Step 5: Strip the sort values, keeping only the original items
569
+ result.map! { |_, item| item }
447
570
  end
448
571
 
449
572
  ##
@@ -478,11 +601,8 @@ module Sortsmith
478
601
  # result = users.sort_by.dig(:name).sort!.first(10)
479
602
  #
480
603
  def sort!
481
- # Sort the original array in place
482
- @input.sort! { |a, b| apply_sorting(a, b) }
483
-
484
- # Apply any ordering transformations
485
- apply_ordering(@input)
604
+ sorted = sort
605
+ @input.replace(sorted)
486
606
  end
487
607
 
488
608
  ##
@@ -626,27 +746,48 @@ module Sortsmith
626
746
  private
627
747
 
628
748
  ##
629
- # Apply the complete pipeline of steps to two items for comparison.
749
+ # Extract and transform a single item through the full pipeline.
630
750
  #
631
- # Iterates through all extraction and transformation steps in order,
632
- # applying each one to both items in sequence. This creates the values
633
- # that will be compared using Ruby's spaceship operator.
751
+ # Applies all extractors and modifiers to produce the final value
752
+ # that will be used for sorting comparisons.
634
753
  #
635
- # @param item_a [Object] First item to compare
636
- # @param item_b [Object] Second item to compare
637
- # @return [Integer] Comparison result (-1, 0, 1)
754
+ # @param item [Object] The item to process
755
+ # @return [Object] The extracted and transformed value
638
756
  #
639
757
  # @api private
640
758
  #
641
- def apply_sorting(item_a, item_b)
759
+ def extract_and_transform(item)
760
+ value = item
761
+
642
762
  @extractors.each do |extractor|
643
- item_a, item_b = apply_extractor(extractor, item_a, item_b)
763
+ value = extract_value(value, **extractor)
644
764
  end
645
765
 
646
766
  @modifiers.each do |modifier|
647
- item_a, item_b = apply_modifier(modifier, item_a, item_b)
767
+ value = modify_value(value, **modifier)
648
768
  end
649
769
 
770
+ value
771
+ end
772
+
773
+ ##
774
+ # Compare two extracted values according to nil positioning and sort direction.
775
+ #
776
+ # Handles nil values specially and applies the @descending flag to non-nil comparisons.
777
+ #
778
+ # @param item_a [Object] First value to compare
779
+ # @param item_b [Object] Second value to compare
780
+ # @return [Integer] Comparison result (-1, 0, 1)
781
+ #
782
+ # @api private
783
+ #
784
+ def compare_values(item_a, item_b)
785
+ # Handle nil values specially before comparison
786
+ # nil_first/nil_last positioning is absolute and not affected by asc/desc
787
+ return 0 if item_a.nil? && item_b.nil?
788
+ return @nil_first ? -1 : 1 if item_a.nil?
789
+ return @nil_first ? 1 : -1 if item_b.nil?
790
+
650
791
  # Final comparison using Ruby's spaceship operator
651
792
  result = item_a <=> item_b
652
793
 
@@ -661,46 +802,7 @@ module Sortsmith
661
802
  ERROR
662
803
  end
663
804
 
664
- result
665
- end
666
-
667
- ##
668
- # Apply ordering transformations to the sorted array.
669
- #
670
- # Executes any ordering steps (like desc) that affect the final
671
- # arrangement of the sorted results. This happens after the sort
672
- # comparison is complete.
673
- #
674
- # @param sorted [Array] The array to apply ordering to
675
- # @return [Array] The array with ordering applied
676
- #
677
- # @api private
678
- #
679
- def apply_ordering(sorted)
680
- @ordering.each { |step| sorted.public_send(step[:method]) }
681
-
682
- sorted
683
- end
684
-
685
- ##
686
- # Apply an extraction step to both comparison items.
687
- #
688
- # Extraction steps pull values out of objects (like hash keys or method calls)
689
- # that will be used for comparison. When extraction fails, returns empty
690
- # strings to preserve original ordering.
691
- #
692
- # @param extractor [Hash] Extraction step configuration
693
- # @param item_a [Object] First item to extract from
694
- # @param item_b [Object] Second item to extract from
695
- # @return [Array<Object, Object>] Extracted values for comparison
696
- #
697
- # @api private
698
- #
699
- def apply_extractor(extractor, item_a, item_b)
700
- item_a = extract_value(item_a, **extractor)
701
- item_b = extract_value(item_b, **extractor)
702
-
703
- [item_a, item_b]
805
+ @descending ? -result : result
704
806
  end
705
807
 
706
808
  ##
@@ -720,31 +822,10 @@ module Sortsmith
720
822
  # @api private
721
823
  #
722
824
  def extract_value(item, method:, positional: [], keyword: {}, before_extract: nil)
723
- return "" unless item.respond_to?(method)
724
-
725
825
  item = before_extract.call(item) if before_extract
726
826
  item.public_send(method, *positional, **keyword)
727
- end
728
-
729
- ##
730
- # Apply a modification step to both comparison items.
731
- #
732
- # Modification steps transform values for comparison (like case changes).
733
- # Both items are processed with the same transformation to ensure
734
- # consistent comparison behavior.
735
- #
736
- # @param modifier [Hash] Modification step configuration
737
- # @param item_a [Object] First item to modify
738
- # @param item_b [Object] Second item to modify
739
- # @return [Array<Object, Object>] Modified values for comparison
740
- #
741
- # @api private
742
- #
743
- def apply_modifier(modifier, item_a, item_b)
744
- item_a = modify_value(item_a, **modifier)
745
- item_b = modify_value(item_b, **modifier)
746
-
747
- [item_a, item_b]
827
+ rescue NoMethodError
828
+ ""
748
829
  end
749
830
 
750
831
  ##
@@ -763,9 +844,9 @@ module Sortsmith
763
844
  # @api private
764
845
  #
765
846
  def modify_value(item, method:, positional: [], keyword: {})
766
- return item.to_s unless item.respond_to?(method)
767
-
768
847
  item.public_send(method, *positional, **keyword)
848
+ rescue NoMethodError
849
+ item.to_s
769
850
  end
770
851
  end
771
852
  end
@@ -4,5 +4,5 @@ module Sortsmith
4
4
  #
5
5
  # The current version of the Sortsmith gem
6
6
  #
7
- VERSION = "1.0.0"
7
+ VERSION = "1.0.1"
8
8
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sortsmith
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bryan
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-08-03 00:00:00.000000000 Z
11
+ date: 2026-02-16 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Sortsmith provides a flexible, chainable API for sorting Ruby collections.
14
14
  It supports sorting by object keys, methods, case sensitivity, and custom transformations.
@@ -25,6 +25,8 @@ files:
25
25
  - LICENSE.txt
26
26
  - README.md
27
27
  - Rakefile
28
+ - benchmark/quick_benchmark.rb
29
+ - benchmark/sorting_benchmark.rb
28
30
  - flake.lock
29
31
  - flake.nix
30
32
  - lib/sortsmith.rb