sortsmith 0.9.0 → 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.
@@ -6,6 +6,7 @@ module Sortsmith
6
6
  #
7
7
  # The Sorter class allows you to build sorting pipelines by chaining extractors,
8
8
  # modifiers, and ordering methods before executing the sort with a terminator method.
9
+ # This creates readable, expressive sorting code that handles edge cases gracefully.
9
10
  #
10
11
  # @example Basic usage
11
12
  # users.sort_by.dig(:name).sort
@@ -15,6 +16,10 @@ module Sortsmith
15
16
  # users.sort_by.dig(:name, indifferent: true).downcase.desc.sort
16
17
  # # => sorted by name (case-insensitive, descending, with indifferent key access)
17
18
  #
19
+ # @example Method extraction
20
+ # users.sort_by.method(:full_name).insensitive.sort
21
+ # # => sorted by calling full_name method on each user
22
+ #
18
23
  # @example Mixed key types
19
24
  # mixed_data = [
20
25
  # {name: "Bob"}, # symbol key
@@ -23,11 +28,65 @@ module Sortsmith
23
28
  # mixed_data.sort_by.dig(:name, indifferent: true).sort
24
29
  # # => handles both key types gracefully
25
30
  #
31
+ # @example Handling missing methods gracefully
32
+ # users.sort_by.method(:missing_email).sort
33
+ # # => preserves original order when method doesn't exist
34
+ #
35
+ # @see Enumerable#sort_by The enhanced sort_by method
36
+ # @since 0.9.0
37
+ #
26
38
  class Sorter
27
39
  ##
28
- # Initialize a new Sorter instance
40
+ # Transformation proc for converting hash keys to symbols for indifferent access.
41
+ #
42
+ # Used internally when the `indifferent: true` option is specified in {#dig}.
43
+ # This enables consistent key lookup across hashes with mixed symbol/string keys.
44
+ #
45
+ # @example Usage in indifferent access
46
+ # mixed_hashes = [
47
+ # {name: "Bob"}, # symbol key
48
+ # {"name" => "Alice"} # string key
49
+ # ]
50
+ # # Both will be accessed via :name after transformation
51
+ #
52
+ # @return [Proc] A proc that transforms hash keys to symbols
53
+ # @api private
54
+ #
55
+ INDIFFERENT_KEYS_TRANSFORM = ->(item) { item.transform_keys(&:to_sym) }
56
+
57
+ ##
58
+ # List of enumerable methods that are delegated to the sorted result.
59
+ #
60
+ # These methods allow seamless chaining from sorting operations to common
61
+ # array operations without breaking the fluent interface. Each delegated
62
+ # method executes the sort pipeline first, then applies the requested
63
+ # operation to the sorted result.
64
+ #
65
+ # @example Delegated method usage
66
+ # users.sort_by.dig(:score).desc.first(3) # Get top 3 scores
67
+ # users.sort_by.dig(:name).each { |u| puts u } # Iterate in sorted order
68
+ # users.sort_by.dig(:age)[0..2] # Array slice of sorted results
69
+ #
70
+ # @since 1.0.0
71
+ # @api private
72
+ #
73
+ DELEGATED_METHODS = %i[first last take drop each map select [] size count length].freeze
74
+
75
+ ##
76
+ # Initialize a new Sorter instance.
77
+ #
78
+ # Creates a new chainable sorter for the given collection. Typically called
79
+ # automatically when using `collection.sort_by` without a block.
29
80
  #
30
81
  # @param input [Array, Enumerable] The collection to be sorted
82
+ #
83
+ # @example Direct instantiation (rarely needed)
84
+ # sorter = Sortsmith::Sorter.new(users)
85
+ # sorter.dig(:name).sort
86
+ #
87
+ # @example Typical usage (via sort_by)
88
+ # users.sort_by.dig(:name).sort
89
+ #
31
90
  def initialize(input)
32
91
  @input = input
33
92
  @extractors = []
@@ -40,74 +99,275 @@ module Sortsmith
40
99
  ############################################################################
41
100
 
42
101
  ##
43
- # Extract values from objects using hash keys or object methods
102
+ # Extract values from objects using hash keys or object methods.
103
+ #
104
+ # The workhorse method for value extraction. Works with hashes, structs,
105
+ # and any object that responds to the given identifiers. Supports nested
106
+ # digging with multiple arguments and handles mixed key types gracefully.
44
107
  #
45
- # Works with hashes, structs, and any object that responds to the given identifiers.
46
- # Supports nested digging with multiple arguments.
108
+ # When extracting from objects that don't respond to the specified keys/methods,
109
+ # returns an empty string to preserve the original ordering rather than causing
110
+ # comparison errors.
47
111
  #
48
112
  # @param identifiers [Array<Symbol, String, Integer>] Keys, method names, or indices to extract
49
113
  # @param indifferent [Boolean] When true, normalizes hash keys to symbols for consistent lookup
50
114
  #
51
115
  # @return [Sorter] Returns self for method chaining
52
116
  #
53
- # @example Hash extraction
117
+ # @example Hash key extraction
54
118
  # users.sort_by.dig(:name).sort
55
119
  #
56
- # @example Nested extraction
120
+ # @example Nested hash extraction
57
121
  # users.sort_by.dig(:profile, :email).sort
58
122
  #
59
- # @example Mixed key types
60
- # users.sort_by.dig(:name, indifferent: true).sort
123
+ # @example Array index extraction
124
+ # coordinates.sort_by.dig(0).sort # sort by x-coordinate
125
+ #
126
+ # @example Mixed key types with indifferent access
127
+ # mixed_data = [
128
+ # {name: "Bob"}, # symbol key
129
+ # {"name" => "Alice"} # string key
130
+ # ]
131
+ # mixed_data.sort_by.dig(:name, indifferent: true).sort
132
+ # # => Both key types work seamlessly
61
133
  #
62
134
  # @example Object method calls
63
- # objects.sort_by.dig(:calculate_score).sort
135
+ # users.sort_by.dig(:calculate_score).sort
136
+ #
137
+ # @example Graceful handling of missing keys
138
+ # users.sort_by.dig(:missing_field).sort
139
+ # # => Preserves original order instead of erroring
64
140
  #
65
141
  def dig(*identifiers, indifferent: false)
66
- @extractors << {method: :dig, positional: identifiers, indifferent: indifferent}
142
+ if indifferent
143
+ identifiers = identifiers.map(&:to_sym)
144
+ before_extract = INDIFFERENT_KEYS_TRANSFORM
145
+ end
146
+
147
+ @extractors << {method: :dig, positional: identifiers, before_extract:}
67
148
  self
68
149
  end
69
150
 
151
+ ##
152
+ # Alias for {#dig} - extracts values from objects using hash keys or object methods.
153
+ #
154
+ # Provides semantic clarity when working primarily with hash keys rather than
155
+ # nested structures or method calls.
156
+ #
157
+ # @return [Sorter] Returns self for method chaining
158
+ #
159
+ # @example Key extraction
160
+ # users.sort_by.key(:name).sort
161
+ #
162
+ # @see #dig The main extraction method
163
+ #
164
+ alias_method :key, :dig
165
+
166
+ ##
167
+ # Alias for {#dig} - extracts values from objects using hash keys or object methods.
168
+ #
169
+ # Provides semantic clarity when working with object fields or properties,
170
+ # making the sorting intent more explicit in business domain contexts.
171
+ #
172
+ # @return [Sorter] Returns self for method chaining
173
+ #
174
+ # @example Field extraction
175
+ # products.sort_by.field(:price).desc.sort
176
+ #
177
+ # @example Nested field extraction
178
+ # users.sort_by.field(:profile, :email).sort
179
+ #
180
+ # @see #dig The main extraction method
181
+ #
182
+ alias_method :field, :dig
183
+
184
+ ##
185
+ # Extract values by calling methods on objects with optional arguments.
186
+ #
187
+ # Enables chainable sorting by calling methods on each object in the collection.
188
+ # Supports method calls with both positional and keyword arguments. When objects
189
+ # don't respond to the specified method, returns an empty string to preserve
190
+ # original ordering.
191
+ #
192
+ # This is particularly useful for custom objects, calculated values, or methods
193
+ # that require parameters.
194
+ #
195
+ # @param method_name [Symbol, String] The method name to call on each object
196
+ # @param positional [Array] Positional arguments to pass to the method
197
+ # @param keyword [Hash] Keyword arguments to pass to the method
198
+ #
199
+ # @return [Sorter] Returns self for method chaining
200
+ #
201
+ # @example Basic method sorting
202
+ # users.sort_by.method(:name).sort
203
+ #
204
+ # @example Method with chainable modifiers
205
+ # users.sort_by.method(:full_name).insensitive.desc.sort
206
+ #
207
+ # @example Method with positional arguments
208
+ # products.sort_by.method(:price_in, "USD").sort
209
+ #
210
+ # @example Method with keyword arguments
211
+ # items.sort_by.method(:calculate_score, boost: 1.5).sort
212
+ #
213
+ # @example Complex method calls
214
+ # reports.sort_by.method(:metric_for, :revenue, period: "Q1").desc.sort
215
+ #
216
+ # @example Graceful handling of missing methods
217
+ # mixed_objects.sort_by.method(:priority).sort
218
+ # # => Objects without :priority method maintain original order
219
+ #
220
+ def method(method_name, *positional, **keyword)
221
+ @extractors << {method: method_name, positional:, keyword:}
222
+ self
223
+ end
224
+
225
+ ##
226
+ # Alias for {#method} - extracts values by calling methods on objects.
227
+ #
228
+ # Provides semantic clarity when working with object attributes or properties,
229
+ # emphasizing that you're accessing object state rather than calling behavior.
230
+ #
231
+ # @return [Sorter] Returns self for method chaining
232
+ #
233
+ # @example Attribute extraction
234
+ # users.sort_by.attribute(:full_name).sort
235
+ #
236
+ # @example With arguments
237
+ # reports.sort_by.attribute(:score_for, "Q1").desc.sort
238
+ #
239
+ # @see #method The main method extraction feature
240
+ #
241
+ alias_method :attribute, :method
242
+
243
+ ##
244
+ # Universal extraction method that intelligently chooses the appropriate extraction strategy.
245
+ #
246
+ # This method serves as the smart dispatcher for value extraction, automatically detecting
247
+ # whether the input collection contains hash-like objects (that respond to `dig`) or
248
+ # regular objects (that need method calls). It provides a unified interface regardless
249
+ # of the underlying data structure.
250
+ #
251
+ # When `field` is nil, returns self without adding any extractors to the pipeline,
252
+ # allowing for graceful handling of dynamic field selection scenarios.
253
+ #
254
+ # @param field [Symbol, String, nil] The field name, hash key, or method name to extract
255
+ # @param positional [Array] Additional positional arguments passed to extraction
256
+ # @param keyword [Hash] Additional keyword arguments passed to extraction
257
+ #
258
+ # @return [Sorter] Returns self for method chaining
259
+ #
260
+ # @example Hash extraction (uses dig internally)
261
+ # users = [{ name: "Alice" }, { name: "Bob" }]
262
+ # users.sort_by.extract(:name).sort
263
+ #
264
+ # @example Object method extraction (uses method internally)
265
+ # User = Struct.new(:name, :score)
266
+ # users = [User.new("Alice", 92), User.new("Bob", 78)]
267
+ # users.sort_by.extract(:score).sort
268
+ #
269
+ # @example Indifferent key access
270
+ # mixed_data = [{ name: "Alice" }, { "name" => "Bob" }]
271
+ # mixed_data.sort_by.extract(:name, indifferent: true).sort
272
+ #
273
+ # @example Graceful nil handling
274
+ # field_name = might_return_nil_from_api()
275
+ # users.sort_by.extract(field_name).sort # No extraction if field_name is nil
276
+ #
277
+ # @example Chaining with modifiers
278
+ # users.sort_by.extract(:name).insensitive.desc.sort
279
+ #
280
+ # @see #dig Hash and nested structure extraction
281
+ # @see #method Object method extraction with arguments
282
+ # @since 1.0.0
283
+ #
284
+ def extract(field, *positional, **keyword)
285
+ return self if field.nil?
286
+
287
+ if @input.first.respond_to?(:dig)
288
+ dig(field, **keyword)
289
+ else
290
+ method(field, *positional, **keyword)
291
+ end
292
+ end
293
+
70
294
  ############################################################################
71
295
  # Modifiers
72
296
  ############################################################################
73
297
 
74
298
  ##
75
- # Transform extracted values to lowercase for comparison
299
+ # Transform extracted values to lowercase for comparison.
76
300
  #
77
301
  # Only affects values that respond to #downcase (typically strings).
78
- # Non-string values pass through unchanged.
302
+ # Non-string values are converted to strings first, ensuring consistent
303
+ # behavior across mixed data types.
79
304
  #
80
305
  # @return [Sorter] Returns self for method chaining
81
306
  #
82
307
  # @example Case-insensitive string sorting
308
+ # names = ["charlie", "Alice", "BOB"]
83
309
  # names.sort_by.downcase.sort
310
+ # # => ["Alice", "BOB", "charlie"]
84
311
  #
85
312
  # @example With hash extraction
86
313
  # users.sort_by.dig(:name).downcase.sort
87
314
  #
315
+ # @example Mixed data types
316
+ # mixed = ["Apple", 42, "banana"]
317
+ # mixed.sort_by.downcase.sort
318
+ # # => [42, "Apple", "banana"] (42 becomes "42")
319
+ #
88
320
  def downcase
89
321
  @modifiers << {method: :downcase}
90
322
  self
91
323
  end
92
324
 
93
325
  ##
94
- # Alias for #downcase - provides case-insensitive sorting
326
+ # Alias for {#downcase} - provides case-insensitive sorting.
327
+ #
328
+ # Offers semantic clarity when the intent is case-insensitive comparison
329
+ # rather than specifically forcing lowercase.
95
330
  #
96
331
  # @return [Sorter] Returns self for method chaining
97
332
  #
333
+ # @example Semantic case-insensitive sorting
334
+ # users.sort_by.dig(:name).insensitive.sort
335
+ #
336
+ # @see #downcase The underlying transformation method
337
+ #
98
338
  alias_method :insensitive, :downcase
99
339
 
100
340
  ##
101
- # Transform extracted values to uppercase for comparison
341
+ # Alias for {#downcase} - provides case-insensitive sorting.
342
+ #
343
+ # Offers explicit semantic clarity when the intent is case-insensitive comparison.
344
+ # More verbose than {#insensitive} but crystal clear about the behavior.
345
+ #
346
+ # @return [Sorter] Returns self for method chaining
347
+ #
348
+ # @example Explicit case-insensitive sorting
349
+ # users.sort_by.dig(:name).case_insensitive.sort
350
+ #
351
+ # @see #downcase The underlying transformation method
352
+ # @see #insensitive The shorter alias
353
+ #
354
+ alias_method :case_insensitive, :downcase
355
+
356
+ ##
357
+ # Transform extracted values to uppercase for comparison.
102
358
  #
103
359
  # Only affects values that respond to #upcase (typically strings).
104
- # Non-string values pass through unchanged.
360
+ # Non-string values are converted to strings first, ensuring consistent
361
+ # behavior across mixed data types.
105
362
  #
106
363
  # @return [Sorter] Returns self for method chaining
107
364
  #
108
365
  # @example Uppercase sorting
109
366
  # names.sort_by.upcase.sort
110
367
  #
368
+ # @example With extraction
369
+ # users.sort_by.dig(:department).upcase.desc.sort
370
+ #
111
371
  def upcase
112
372
  @modifiers << {method: :upcase}
113
373
  self
@@ -118,27 +378,40 @@ module Sortsmith
118
378
  ############################################################################
119
379
 
120
380
  ##
121
- # Sort in ascending order (default behavior)
381
+ # Sort in ascending order (default behavior).
122
382
  #
123
- # This is typically unnecessary as ascending is the default,
124
- # but can be useful for explicit clarity or resetting after desc.
383
+ # This is typically unnecessary as ascending is the default sort direction,
384
+ # but can be useful for explicit clarity or resetting direction after
385
+ # previous desc calls in a chain.
125
386
  #
126
387
  # @return [Sorter] Returns self for method chaining
127
388
  #
389
+ # @example Explicit ascending sort
390
+ # users.sort_by.dig(:name).asc.sort
391
+ #
392
+ # @example Resetting after desc
393
+ # users.sort_by.dig(:name).desc.asc.sort # ends up ascending
394
+ #
128
395
  def asc
129
396
  @ordering << {method: :sort!}
130
397
  self
131
398
  end
132
399
 
133
400
  ##
134
- # Sort in descending order
401
+ # Sort in descending order.
135
402
  #
136
403
  # Reverses the final sort order after all comparisons are complete.
404
+ # Can be chained with other modifiers and will apply to the final result.
137
405
  #
138
406
  # @return [Sorter] Returns self for method chaining
139
407
  #
140
408
  # @example Descending sort
141
409
  # users.sort_by.dig(:age).desc.sort
410
+ # # => Oldest users first
411
+ #
412
+ # @example With case modification
413
+ # users.sort_by.dig(:name).insensitive.desc.sort
414
+ # # => Case-insensitive, reverse alphabetical
142
415
  #
143
416
  def desc
144
417
  @ordering << {method: :reverse!}
@@ -150,40 +423,50 @@ module Sortsmith
150
423
  ############################################################################
151
424
 
152
425
  ##
153
- # Execute the sort pipeline and return a new sorted array
426
+ # Execute the sort pipeline and return a new sorted array.
154
427
  #
155
428
  # Applies all chained extraction, transformation, and ordering steps
156
- # to produce the final sorted result. The original collection is unchanged.
429
+ # to produce the final sorted result. The original collection remains
430
+ # unchanged.
157
431
  #
158
432
  # @return [Array] A new array containing the sorted elements
159
433
  #
160
434
  # @example Basic termination
161
435
  # sorted_users = users.sort_by.dig(:name).sort
436
+ # # original users array unchanged
437
+ #
438
+ # @example Complex pipeline
439
+ # result = users.sort_by.dig(:name, indifferent: true).insensitive.desc.sort
162
440
  #
163
441
  def sort
164
442
  # Apply all extraction and transformation steps during comparison
165
- sorted = @input.sort do |item_a, item_b|
166
- apply_steps(item_a, item_b)
167
- end
443
+ sorted = @input.sort { |a, b| apply_sorting(a, b) }
168
444
 
169
445
  # Apply any ordering transformations (like desc)
170
- apply_ordering_steps(sorted)
446
+ apply_ordering(sorted)
171
447
  end
172
448
 
173
449
  ##
174
- # Alias for #sort - returns a new sorted array
450
+ # Alias for {#sort} - returns a new sorted array.
451
+ #
452
+ # Provides semantic clarity when the intent is to convert the sorter
453
+ # pipeline to an array result.
175
454
  #
176
455
  # @return [Array] A new array containing the sorted elements
177
456
  #
178
- # @see #sort
457
+ # @example Array conversion
458
+ # result = users.sort_by.dig(:name).to_a
459
+ #
460
+ # @see #sort The main termination method
179
461
  #
180
462
  alias_method :to_a, :sort
181
463
 
182
464
  ##
183
- # Execute the sort pipeline and mutate the original array in place
465
+ # Execute the sort pipeline and mutate the original array in place.
184
466
  #
185
- # Same as #sort but modifies the original array instead of creating a new one.
186
- # Returns the mutated array for chaining.
467
+ # Same as {#sort} but modifies the original array instead of creating a new one.
468
+ # Returns the mutated array for chaining. Use when memory efficiency is
469
+ # important and you don't need to preserve the original order.
187
470
  #
188
471
  # @return [Array] The original array, now sorted
189
472
  #
@@ -191,127 +474,298 @@ module Sortsmith
191
474
  # users.sort_by.dig(:name).sort!
192
475
  # # users array is now modified
193
476
  #
477
+ # @example Chaining after mutation
478
+ # result = users.sort_by.dig(:name).sort!.first(10)
479
+ #
194
480
  def sort!
195
481
  # Sort the original array in place
196
- @input.sort! do |item_a, item_b|
197
- apply_steps(item_a, item_b)
198
- end
482
+ @input.sort! { |a, b| apply_sorting(a, b) }
199
483
 
200
484
  # Apply any ordering transformations
201
- apply_ordering_steps(@input)
485
+ apply_ordering(@input)
202
486
  end
203
487
 
204
488
  ##
205
- # Shorthand for adding desc and executing sort
489
+ # Alias for {#sort!} - execute sort pipeline and mutate original array.
490
+ #
491
+ # Provides semantic clarity when the intent is to convert the sorter
492
+ # pipeline to an array while modifying the original collection in place.
493
+ #
494
+ # @return [Array] The original array, now sorted
206
495
  #
207
- # Equivalent to calling .desc.sort but more concise.
496
+ # @example In-place array conversion
497
+ # users.sort_by.dig(:name).to_a!
498
+ # # users array is now sorted by name
499
+ #
500
+ # @see #sort! The main in-place sorting method
501
+ #
502
+ alias_method :to_a!, :sort!
503
+
504
+ ##
505
+ # Shorthand for adding desc and executing sort.
506
+ #
507
+ # Equivalent to calling `.desc.sort` but more concise and expressive.
508
+ # Useful when you know you want descending order and don't need other
509
+ # modifiers.
208
510
  #
209
511
  # @return [Array] A new array sorted in descending order
210
512
  #
211
513
  # @example Reverse sorting
212
- # users.sort_by.dig(:name).reverse
514
+ # users.sort_by.dig(:created_at).reverse
515
+ # # => Newest users first
516
+ #
517
+ # @example Equivalent to
518
+ # users.sort_by.dig(:created_at).desc.sort
213
519
  #
214
520
  def reverse
215
521
  desc.sort
216
522
  end
217
523
 
218
524
  ##
219
- # Shorthand for adding desc and executing sort!
525
+ # Shorthand for adding desc and executing sort!.
220
526
  #
221
- # Equivalent to calling .desc.sort! but more concise.
527
+ # Equivalent to calling `.desc.sort!` but more concise. Mutates the
528
+ # original array and returns it in descending order.
222
529
  #
223
530
  # @return [Array] The original array, sorted in descending order
224
531
  #
532
+ # @example In-place reverse sorting
533
+ # users.sort_by.dig(:score).reverse!
534
+ #
225
535
  def reverse!
226
536
  desc.sort!
227
537
  end
228
538
 
539
+ ## The following methods are automatically generated and delegate to the sorted array:
540
+ #
541
+ # @!method first(n = 1)
542
+ # Get the first n elements from the sorted result
543
+ # @param n [Integer] Number of elements to return
544
+ # @return [Object, Array] Single element if n=1, array otherwise
545
+ # @example
546
+ # users.sort_by.dig(:score).desc.first # Highest scoring user
547
+ # users.sort_by.dig(:name).first(3) # First 3 alphabetically
548
+ #
549
+ # @!method last(n = 1)
550
+ # Get the last n elements from the sorted result
551
+ # @param n [Integer] Number of elements to return
552
+ # @return [Object, Array] Single element if n=1, array otherwise
553
+ # @example
554
+ # users.sort_by.dig(:age).last # Oldest user
555
+ # users.sort_by.dig(:score).last(2) # Bottom 2 scores
556
+ #
557
+ # @!method take(n)
558
+ # Take the first n elements from the sorted result
559
+ # @param n [Integer] Number of elements to take
560
+ # @return [Array] Array with up to n elements
561
+ # @example
562
+ # users.sort_by.dig(:score).desc.take(5) # Top 5 users
563
+ #
564
+ # @!method drop(n)
565
+ # Drop the first n elements from the sorted result
566
+ # @param n [Integer] Number of elements to drop
567
+ # @return [Array] Array with remaining elements
568
+ # @example
569
+ # users.sort_by.dig(:score).drop(3) # All except top 3
570
+ #
571
+ # @!method each(&block)
572
+ # Iterate over the sorted result
573
+ # @yield [Object] Each element in sorted order
574
+ # @return [Array, Enumerator] Sorted array if block given, enumerator otherwise
575
+ # @example
576
+ # users.sort_by.dig(:name).each { |user| puts user[:email] }
577
+ #
578
+ # @!method map(&block)
579
+ # Transform each element of the sorted result
580
+ # @yield [Object] Each element in sorted order
581
+ # @return [Array, Enumerator] Transformed array if block given, enumerator otherwise
582
+ # @example
583
+ # users.sort_by.dig(:name).map(&:upcase)
584
+ #
585
+ # @!method select(&block)
586
+ # Filter the sorted result
587
+ # @yield [Object] Each element in sorted order
588
+ # @return [Array, Enumerator] Filtered array if block given, enumerator otherwise
589
+ # @example
590
+ # users.sort_by.dig(:score).select { |u| u[:active] }
591
+ #
592
+ # @!method [](index)
593
+ # Access elements by index in the sorted result
594
+ # @param index [Integer, Range] Index or range to access
595
+ # @return [Object, Array] Element(s) at the specified index/range
596
+ # @example
597
+ # users.sort_by.dig(:score).desc[0] # Highest scoring user
598
+ # users.sort_by.dig(:name)[1..3] # Users 2-4 alphabetically
599
+ #
600
+ # @!method size
601
+ # Get the number of elements in the sorted result
602
+ # @return [Integer] Number of elements
603
+ # @example
604
+ # users.sort_by.dig(:name).size
605
+ #
606
+ # @!method count(&block)
607
+ # Count elements in the sorted result, optionally with a condition
608
+ # @yield [Object] Each element for conditional counting
609
+ # @return [Integer] Count of elements
610
+ # @example
611
+ # users.sort_by.dig(:name).count # Total count
612
+ # users.sort_by.dig(:score).count { |u| u[:active] } # Count active users
613
+ #
614
+ # @!method length
615
+ # Get the number of elements in the sorted result (alias for size)
616
+ # @return [Integer] Number of elements
617
+ # @example
618
+ # users.sort_by.dig(:name).length
619
+ #
620
+ DELEGATED_METHODS.each do |method_name|
621
+ define_method(method_name) do |*args, &block|
622
+ to_a.public_send(method_name, *args, &block)
623
+ end
624
+ end
625
+
229
626
  private
230
627
 
231
628
  ##
232
- # Apply the complete pipeline of steps to two items for comparison
629
+ # Apply the complete pipeline of steps to two items for comparison.
233
630
  #
234
- # Iterates through all extraction and transformation steps,
235
- # applying each one to both items in sequence.
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.
236
634
  #
237
635
  # @param item_a [Object] First item to compare
238
636
  # @param item_b [Object] Second item to compare
239
637
  # @return [Integer] Comparison result (-1, 0, 1)
240
638
  #
241
- def apply_steps(item_a, item_b)
242
- @extractors.each do |step|
243
- item_a, item_b = apply_step(step, item_a, item_b)
639
+ # @api private
640
+ #
641
+ def apply_sorting(item_a, item_b)
642
+ @extractors.each do |extractor|
643
+ item_a, item_b = apply_extractor(extractor, item_a, item_b)
244
644
  end
245
645
 
246
- @modifiers.each do |step|
247
- item_a, item_b = apply_step(step, item_a, item_b)
646
+ @modifiers.each do |modifier|
647
+ item_a, item_b = apply_modifier(modifier, item_a, item_b)
248
648
  end
249
649
 
250
650
  # Final comparison using Ruby's spaceship operator
251
- item_a <=> item_b
651
+ result = item_a <=> item_b
652
+
653
+ if result.nil?
654
+ raise ArgumentError,
655
+ <<~ERROR
656
+ Cannot compare values during sort - this usually means your extraction returned incomparable types or you're missing an extraction method.
657
+ Comparing:
658
+ #{item_a.inspect} (#{item_a.class})
659
+ <=>
660
+ #{item_b.inspect} (#{item_b.class})
661
+ ERROR
662
+ end
663
+
664
+ result
252
665
  end
253
666
 
254
667
  ##
255
- # Apply ordering transformations to the sorted array
668
+ # Apply ordering transformations to the sorted array.
256
669
  #
257
670
  # Executes any ordering steps (like desc) that affect the final
258
- # arrangement of the sorted results.
671
+ # arrangement of the sorted results. This happens after the sort
672
+ # comparison is complete.
259
673
  #
260
674
  # @param sorted [Array] The array to apply ordering to
261
675
  # @return [Array] The array with ordering applied
262
676
  #
263
- def apply_ordering_steps(sorted)
264
- @ordering.each do |step|
265
- sorted.public_send(step[:method])
266
- end
677
+ # @api private
678
+ #
679
+ def apply_ordering(sorted)
680
+ @ordering.each { |step| sorted.public_send(step[:method]) }
267
681
 
268
682
  sorted
269
683
  end
270
684
 
271
685
  ##
272
- # Apply a single step to both items in the comparison
686
+ # Apply an extraction step to both comparison items.
273
687
  #
274
- # Handles different step types and safely manages method calls,
275
- # falling back to string conversion for non-responsive objects.
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.
276
691
  #
277
- # @param step [Hash] Step configuration containing method and arguments
278
- # @param item_a [Object] First item to transform
279
- # @param item_b [Object] Second item to transform
280
- # @return [Array<Object, Object>] Transformed items
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
281
696
  #
282
- def apply_step(step, item_a, item_b)
283
- method = step[:method]
284
- positional = step[:positional] || []
285
- indifferent = step[:indifferent] || false
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)
286
702
 
287
- # For indifferent key access, normalize all positional args to symbols
288
- if indifferent
289
- positional = positional.map { |i| i.respond_to?(:to_sym) ? i.to_sym : i }
290
- end
703
+ [item_a, item_b]
704
+ end
705
+
706
+ ##
707
+ # Extract a value from an object by invoking a specified method.
708
+ #
709
+ # Handles extraction with optional arguments and preprocessing. When the
710
+ # object doesn't respond to the method, returns an empty string to maintain
711
+ # original ordering rather than causing comparison failures.
712
+ #
713
+ # @param item [Object] The object from which to extract the value
714
+ # @param method [Symbol, String] The method to call on the object
715
+ # @param positional [Array] Optional positional arguments to pass to the method
716
+ # @param keyword [Hash] Optional keyword arguments to pass to the method
717
+ # @param before_extract [Proc, nil] Optional proc to preprocess the item before extraction
718
+ # @return [Object, String] The result of the method call, or empty string if method unavailable
719
+ #
720
+ # @api private
721
+ #
722
+ def extract_value(item, method:, positional: [], keyword: {}, before_extract: nil)
723
+ return "" unless item.respond_to?(method)
291
724
 
292
- item_a = extract_value_from(item_a, method, positional, indifferent)
293
- item_b = extract_value_from(item_b, method, positional, indifferent)
725
+ item = before_extract.call(item) if before_extract
726
+ 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)
294
746
 
295
747
  [item_a, item_b]
296
748
  end
297
749
 
298
750
  ##
299
- # Extracts a value from an object using the specified method and parameters.
751
+ # Modify a value using a specified transformation method.
300
752
  #
301
- # @param item [Object] the object to extract a value from
302
- # @param method [Symbol, String] the method name to call on the object
303
- # @param positional [Array] positional arguments to pass to the method
304
- # @param indifferent [Boolean] whether to normalize hash keys to symbols for indifferent access
753
+ # Applies transformations like case changes to values. When the value
754
+ # doesn't respond to the transformation method, converts it to a string
755
+ # first to enable string operations on non-string types.
305
756
  #
306
- # @return [Object] the extracted value, or the string representation of the item
757
+ # @param item [Object] The value to modify
758
+ # @param method [Symbol, String] The transformation method to apply
759
+ # @param positional [Array] Optional positional arguments
760
+ # @param keyword [Hash] Optional keyword arguments
761
+ # @return [Object] The transformed value
307
762
  #
308
- def extract_value_from(item, method, positional, indifferent)
763
+ # @api private
764
+ #
765
+ def modify_value(item, method:, positional: [], keyword: {})
309
766
  return item.to_s unless item.respond_to?(method)
310
767
 
311
- # For hash objects with indifferent access, normalize keys to symbols
312
- item = item.transform_keys(&:to_sym) if indifferent
313
-
314
- item.public_send(method, *positional)
768
+ item.public_send(method, *positional, **keyword)
315
769
  end
316
770
  end
317
771
  end