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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +53 -1
- data/README.md +106 -86
- data/flake.lock +3 -3
- data/lib/sortsmith/core_ext/enumerable.rb +53 -19
- data/lib/sortsmith/sorter.rb +536 -82
- data/lib/sortsmith/version.rb +1 -1
- metadata +3 -3
data/lib/sortsmith/sorter.rb
CHANGED
@@ -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
|
-
#
|
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
|
-
#
|
46
|
-
#
|
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
|
60
|
-
#
|
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
|
-
#
|
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
|
-
|
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
|
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
|
-
#
|
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
|
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
|
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
|
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
|
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
|
-
|
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
|
-
# @
|
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!
|
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
|
-
|
485
|
+
apply_ordering(@input)
|
202
486
|
end
|
203
487
|
|
204
488
|
##
|
205
|
-
#
|
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
|
-
#
|
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(:
|
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
|
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
|
-
|
242
|
-
|
243
|
-
|
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 |
|
247
|
-
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
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
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
|
686
|
+
# Apply an extraction step to both comparison items.
|
273
687
|
#
|
274
|
-
#
|
275
|
-
#
|
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
|
278
|
-
# @param item_a [Object] First item to
|
279
|
-
# @param item_b [Object] Second item to
|
280
|
-
# @return [Array<Object, Object>]
|
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
|
-
|
283
|
-
|
284
|
-
|
285
|
-
|
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
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
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
|
-
|
293
|
-
|
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
|
-
#
|
751
|
+
# Modify a value using a specified transformation method.
|
300
752
|
#
|
301
|
-
#
|
302
|
-
#
|
303
|
-
#
|
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
|
-
# @
|
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
|
-
|
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
|
-
|
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
|