sortsmith 0.2.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.
@@ -1,178 +1,771 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sortsmith
4
+ ##
5
+ # A chainable sorting interface that provides a fluent API for complex sorting operations.
6
+ #
7
+ # The Sorter class allows you to build sorting pipelines by chaining extractors,
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.
10
+ #
11
+ # @example Basic usage
12
+ # users.sort_by.dig(:name).sort
13
+ # # => sorted array by name
14
+ #
15
+ # @example Complex chaining
16
+ # users.sort_by.dig(:name, indifferent: true).downcase.desc.sort
17
+ # # => sorted by name (case-insensitive, descending, with indifferent key access)
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
+ #
23
+ # @example Mixed key types
24
+ # mixed_data = [
25
+ # {name: "Bob"}, # symbol key
26
+ # {"name" => "Alice"} # string key
27
+ # ]
28
+ # mixed_data.sort_by.dig(:name, indifferent: true).sort
29
+ # # => handles both key types gracefully
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
+ #
4
38
  class Sorter
39
+ ##
40
+ # Transformation proc for converting hash keys to symbols for indifferent access.
5
41
  #
6
- # Creates a Sorter builder instance
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.
7
44
  #
8
- # @param enumerable [Enumerable] The enumerable (Array, Hash) to sort
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
9
51
  #
10
- def initialize(enumerable)
11
- @enumerable = enumerable
12
- @pipeline = []
13
- @direction = :asc
14
- end
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) }
15
56
 
57
+ ##
58
+ # List of enumerable methods that are delegated to the sorted result.
16
59
  #
17
- # Finalizes the Sorter instance and sorts the enumerable
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.
18
64
  #
19
- # @return [Enumerable] The sorted enumerable
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
20
69
  #
21
- def sort
22
- filter_steps = select_filter_steps
23
- transformation_steps = select_transformation_steps
24
-
25
- result =
26
- @enumerable.sort do |left, right|
27
- filter_steps.each do |step|
28
- left = step.perform(left)
29
- right = step.perform(right)
30
- end
70
+ # @since 1.0.0
71
+ # @api private
72
+ #
73
+ DELEGATED_METHODS = %i[first last take drop each map select [] size count length].freeze
31
74
 
32
- left_priority = type_priority(left)
33
- right_priority = type_priority(right)
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.
80
+ #
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
+ #
90
+ def initialize(input)
91
+ @input = input
92
+ @extractors = []
93
+ @modifiers = []
94
+ @ordering = []
95
+ end
34
96
 
35
- # Apply the transformation pipeline only for same-type comparisons
36
- if left_priority == right_priority
37
- left = apply_transformations(transformation_steps, left)
38
- right = apply_transformations(transformation_steps, right)
97
+ ############################################################################
98
+ # Extractors
99
+ ############################################################################
39
100
 
40
- left <=> right
41
- else
42
- left_priority <=> right_priority
43
- end
44
- end
101
+ ##
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.
107
+ #
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.
111
+ #
112
+ # @param identifiers [Array<Symbol, String, Integer>] Keys, method names, or indices to extract
113
+ # @param indifferent [Boolean] When true, normalizes hash keys to symbols for consistent lookup
114
+ #
115
+ # @return [Sorter] Returns self for method chaining
116
+ #
117
+ # @example Hash key extraction
118
+ # users.sort_by.dig(:name).sort
119
+ #
120
+ # @example Nested hash extraction
121
+ # users.sort_by.dig(:profile, :email).sort
122
+ #
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
133
+ #
134
+ # @example Object method calls
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
140
+ #
141
+ def dig(*identifiers, indifferent: false)
142
+ if indifferent
143
+ identifiers = identifiers.map(&:to_sym)
144
+ before_extract = INDIFFERENT_KEYS_TRANSFORM
145
+ end
45
146
 
46
- (@direction == :asc) ? result : result.reverse
147
+ @extractors << {method: :dig, positional: identifiers, before_extract:}
148
+ self
47
149
  end
48
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
49
198
  #
50
- # Adds a "filter" step to the sort pipeline.
51
- # Filter steps are used to get data from the current item being sorted
52
- # These are performed before transformation steps
199
+ # @return [Sorter] Returns self for method chaining
53
200
  #
54
- # @param & [Proc] The block to execute
201
+ # @example Basic method sorting
202
+ # users.sort_by.method(:name).sort
55
203
  #
56
- # @return [Self] The sorter instance
204
+ # @example Method with chainable modifiers
205
+ # users.sort_by.method(:full_name).insensitive.desc.sort
57
206
  #
58
- def add_filter(&)
59
- add_step(type: Step::FILTER, &)
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
60
223
  end
61
224
 
225
+ ##
226
+ # Alias for {#method} - extracts values by calling methods on objects.
62
227
  #
63
- # Adds a "transformation" step to the sort pipeline
64
- # Transformation steps are used to transform data.
65
- # These are performed after filter steps
228
+ # Provides semantic clarity when working with object attributes or properties,
229
+ # emphasizing that you're accessing object state rather than calling behavior.
66
230
  #
67
- # @param & [Proc] The block to execute
231
+ # @return [Sorter] Returns self for method chaining
68
232
  #
69
- # @return [Self] The sorter instance
233
+ # @example Attribute extraction
234
+ # users.sort_by.attribute(:full_name).sort
70
235
  #
71
- def add_transformation(&)
72
- add_step(type: Step::TRANSFORMATION, &)
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
73
292
  end
74
293
 
294
+ ############################################################################
295
+ # Modifiers
296
+ ############################################################################
297
+
298
+ ##
299
+ # Transform extracted values to lowercase for comparison.
300
+ #
301
+ # Only affects values that respond to #downcase (typically strings).
302
+ # Non-string values are converted to strings first, ensuring consistent
303
+ # behavior across mixed data types.
304
+ #
305
+ # @return [Sorter] Returns self for method chaining
75
306
  #
76
- # Instructs the sorter to perform a fetch by key on the Hash being sorted
307
+ # @example Case-insensitive string sorting
308
+ # names = ["charlie", "Alice", "BOB"]
309
+ # names.sort_by.downcase.sort
310
+ # # => ["Alice", "BOB", "charlie"]
77
311
  #
78
- # @param key [String, Symbol, Any] The hash key to fetch
312
+ # @example With hash extraction
313
+ # users.sort_by.dig(:name).downcase.sort
79
314
  #
80
- # @return [Self] The sorter instance
315
+ # @example Mixed data types
316
+ # mixed = ["Apple", 42, "banana"]
317
+ # mixed.sort_by.downcase.sort
318
+ # # => [42, "Apple", "banana"] (42 becomes "42")
81
319
  #
82
- def by_key(key)
83
- add_filter { |i| i&.fetch(key) }
320
+ def downcase
321
+ @modifiers << {method: :downcase}
84
322
  self
85
323
  end
86
324
 
325
+ ##
326
+ # Alias for {#downcase} - provides case-insensitive sorting.
87
327
  #
88
- # Instructs the sorter to perform a method call on the object being sorted
328
+ # Offers semantic clarity when the intent is case-insensitive comparison
329
+ # rather than specifically forcing lowercase.
89
330
  #
90
- # @param method [String, Symbol] The method name to call
331
+ # @return [Sorter] Returns self for method chaining
91
332
  #
92
- # @return [Self] The sorter instance
333
+ # @example Semantic case-insensitive sorting
334
+ # users.sort_by.dig(:name).insensitive.sort
93
335
  #
94
- def by_method(method)
95
- add_filter { |i| i&.public_send(method) }
96
- end
336
+ # @see #downcase The underlying transformation method
337
+ #
338
+ alias_method :insensitive, :downcase
97
339
 
98
- alias_method :by_attribute, :by_method
340
+ ##
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
99
355
 
356
+ ##
357
+ # Transform extracted values to uppercase for comparison.
100
358
  #
101
- # Instructs the sorter to sort by a case insensitive value
102
- # This will prioritize capital letters first, followed by their lowercase counterparts
359
+ # Only affects values that respond to #upcase (typically strings).
360
+ # Non-string values are converted to strings first, ensuring consistent
361
+ # behavior across mixed data types.
103
362
  #
104
- # @return [Self] The sorter instance
363
+ # @return [Sorter] Returns self for method chaining
105
364
  #
106
- def case_insensitive
107
- add_transformation do |item|
108
- case item
109
- when String
110
- item.chars.flat_map { |c| [c.downcase, c] }
111
- else
112
- item
113
- end
114
- end
365
+ # @example Uppercase sorting
366
+ # names.sort_by.upcase.sort
367
+ #
368
+ # @example With extraction
369
+ # users.sort_by.dig(:department).upcase.desc.sort
370
+ #
371
+ def upcase
372
+ @modifiers << {method: :upcase}
373
+ self
115
374
  end
116
375
 
376
+ ############################################################################
377
+ # Ordering
378
+ ############################################################################
379
+
380
+ ##
381
+ # Sort in ascending order (default behavior).
382
+ #
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.
117
386
  #
118
- # Controls which direction the array will be sorted
387
+ # @return [Sorter] Returns self for method chaining
119
388
  #
120
- # @return [Self] The sorter instance
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
121
394
  #
122
395
  def asc
123
- @direction = :asc
396
+ @ordering << {method: :sort!}
124
397
  self
125
398
  end
126
399
 
127
- alias_method :forward, :asc
128
-
400
+ ##
401
+ # Sort in descending order.
402
+ #
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.
129
405
  #
130
- # Controls which direction the array will be sorted
406
+ # @return [Sorter] Returns self for method chaining
131
407
  #
132
- # @return [Self] The sorter instance
408
+ # @example Descending sort
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
133
415
  #
134
416
  def desc
135
- @direction = :desc
417
+ @ordering << {method: :reverse!}
136
418
  self
137
419
  end
138
420
 
139
- alias_method :reverse, :desc
421
+ ############################################################################
422
+ # Terminators
423
+ ############################################################################
140
424
 
141
- private
425
+ ##
426
+ # Execute the sort pipeline and return a new sorted array.
427
+ #
428
+ # Applies all chained extraction, transformation, and ordering steps
429
+ # to produce the final sorted result. The original collection remains
430
+ # unchanged.
431
+ #
432
+ # @return [Array] A new array containing the sorted elements
433
+ #
434
+ # @example Basic termination
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
440
+ #
441
+ def sort
442
+ # Apply all extraction and transformation steps during comparison
443
+ sorted = @input.sort { |a, b| apply_sorting(a, b) }
142
444
 
143
- def add_step(type:, &block)
144
- @pipeline << Step.new(type:, block:)
145
- self
445
+ # Apply any ordering transformations (like desc)
446
+ apply_ordering(sorted)
447
+ end
448
+
449
+ ##
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.
454
+ #
455
+ # @return [Array] A new array containing the sorted elements
456
+ #
457
+ # @example Array conversion
458
+ # result = users.sort_by.dig(:name).to_a
459
+ #
460
+ # @see #sort The main termination method
461
+ #
462
+ alias_method :to_a, :sort
463
+
464
+ ##
465
+ # Execute the sort pipeline and mutate the original array in place.
466
+ #
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.
470
+ #
471
+ # @return [Array] The original array, now sorted
472
+ #
473
+ # @example In-place sorting
474
+ # users.sort_by.dig(:name).sort!
475
+ # # users array is now modified
476
+ #
477
+ # @example Chaining after mutation
478
+ # result = users.sort_by.dig(:name).sort!.first(10)
479
+ #
480
+ 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)
146
486
  end
147
487
 
148
- def select_filter_steps
149
- @pipeline.select { |s| s.type == Step::FILTER }
488
+ ##
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
495
+ #
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.
510
+ #
511
+ # @return [Array] A new array sorted in descending order
512
+ #
513
+ # @example Reverse sorting
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
519
+ #
520
+ def reverse
521
+ desc.sort
150
522
  end
151
523
 
152
- def select_transformation_steps
153
- @pipeline.select { |s| s.type == Step::TRANSFORMATION }
524
+ ##
525
+ # Shorthand for adding desc and executing sort!.
526
+ #
527
+ # Equivalent to calling `.desc.sort!` but more concise. Mutates the
528
+ # original array and returns it in descending order.
529
+ #
530
+ # @return [Array] The original array, sorted in descending order
531
+ #
532
+ # @example In-place reverse sorting
533
+ # users.sort_by.dig(:score).reverse!
534
+ #
535
+ def reverse!
536
+ desc.sort!
154
537
  end
155
538
 
156
- def type_priority(value)
157
- case value
158
- when NilClass then 0
159
- when Numeric then 1
160
- when String then 2
161
- when Array then 3
162
- when Hash then 4
163
- else
164
- 5
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)
165
623
  end
166
624
  end
167
625
 
168
- def apply_transformations(steps, value)
169
- result = value
626
+ private
627
+
628
+ ##
629
+ # Apply the complete pipeline of steps to two items for comparison.
630
+ #
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.
634
+ #
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)
638
+ #
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)
644
+ end
170
645
 
171
- steps.each do |step|
172
- result = step.perform(result)
646
+ @modifiers.each do |modifier|
647
+ item_a, item_b = apply_modifier(modifier, item_a, item_b)
648
+ end
649
+
650
+ # Final comparison using Ruby's spaceship operator
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
173
662
  end
174
663
 
175
664
  result
176
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]
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)
724
+
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)
746
+
747
+ [item_a, item_b]
748
+ end
749
+
750
+ ##
751
+ # Modify a value using a specified transformation method.
752
+ #
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.
756
+ #
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
762
+ #
763
+ # @api private
764
+ #
765
+ def modify_value(item, method:, positional: [], keyword: {})
766
+ return item.to_s unless item.respond_to?(method)
767
+
768
+ item.public_send(method, *positional, **keyword)
769
+ end
177
770
  end
178
771
  end