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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +146 -1
- data/README.md +269 -60
- data/flake.lock +3 -3
- data/flake.nix +11 -5
- data/lib/sortsmith/core_ext/enumerable.rb +109 -0
- data/lib/sortsmith/sorter.rb +692 -99
- data/lib/sortsmith/version.rb +4 -1
- data/lib/sortsmith.rb +49 -2
- metadata +4 -9
- data/Steepfile +0 -8
- data/lib/sortsmith/step.rb +0 -17
- data/sig/sortsmith/sorter.rbs +0 -107
- data/sig/sortsmith/step.rbs +0 -28
- data/sig/sortsmith/version.rbs +0 -3
- data/sig/sortsmith.rbs +0 -4
data/lib/sortsmith/sorter.rb
CHANGED
@@ -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
|
-
#
|
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
|
-
# @
|
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
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
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
|
-
#
|
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
|
-
# @
|
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
|
-
|
22
|
-
|
23
|
-
|
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
|
-
|
33
|
-
|
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
|
-
|
36
|
-
|
37
|
-
|
38
|
-
right = apply_transformations(transformation_steps, right)
|
97
|
+
############################################################################
|
98
|
+
# Extractors
|
99
|
+
############################################################################
|
39
100
|
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
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
|
-
|
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
|
-
#
|
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
|
-
# @
|
201
|
+
# @example Basic method sorting
|
202
|
+
# users.sort_by.method(:name).sort
|
55
203
|
#
|
56
|
-
# @
|
204
|
+
# @example Method with chainable modifiers
|
205
|
+
# users.sort_by.method(:full_name).insensitive.desc.sort
|
57
206
|
#
|
58
|
-
|
59
|
-
|
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
|
-
#
|
64
|
-
#
|
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
|
-
# @
|
231
|
+
# @return [Sorter] Returns self for method chaining
|
68
232
|
#
|
69
|
-
# @
|
233
|
+
# @example Attribute extraction
|
234
|
+
# users.sort_by.attribute(:full_name).sort
|
70
235
|
#
|
71
|
-
|
72
|
-
|
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
|
-
#
|
307
|
+
# @example Case-insensitive string sorting
|
308
|
+
# names = ["charlie", "Alice", "BOB"]
|
309
|
+
# names.sort_by.downcase.sort
|
310
|
+
# # => ["Alice", "BOB", "charlie"]
|
77
311
|
#
|
78
|
-
# @
|
312
|
+
# @example With hash extraction
|
313
|
+
# users.sort_by.dig(:name).downcase.sort
|
79
314
|
#
|
80
|
-
# @
|
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
|
83
|
-
|
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
|
-
#
|
328
|
+
# Offers semantic clarity when the intent is case-insensitive comparison
|
329
|
+
# rather than specifically forcing lowercase.
|
89
330
|
#
|
90
|
-
# @
|
331
|
+
# @return [Sorter] Returns self for method chaining
|
91
332
|
#
|
92
|
-
# @
|
333
|
+
# @example Semantic case-insensitive sorting
|
334
|
+
# users.sort_by.dig(:name).insensitive.sort
|
93
335
|
#
|
94
|
-
|
95
|
-
|
96
|
-
|
336
|
+
# @see #downcase The underlying transformation method
|
337
|
+
#
|
338
|
+
alias_method :insensitive, :downcase
|
97
339
|
|
98
|
-
|
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
|
-
#
|
102
|
-
#
|
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 [
|
363
|
+
# @return [Sorter] Returns self for method chaining
|
105
364
|
#
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
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
|
-
#
|
387
|
+
# @return [Sorter] Returns self for method chaining
|
119
388
|
#
|
120
|
-
# @
|
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
|
-
@
|
396
|
+
@ordering << {method: :sort!}
|
124
397
|
self
|
125
398
|
end
|
126
399
|
|
127
|
-
|
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
|
-
#
|
406
|
+
# @return [Sorter] Returns self for method chaining
|
131
407
|
#
|
132
|
-
# @
|
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
|
-
@
|
417
|
+
@ordering << {method: :reverse!}
|
136
418
|
self
|
137
419
|
end
|
138
420
|
|
139
|
-
|
421
|
+
############################################################################
|
422
|
+
# Terminators
|
423
|
+
############################################################################
|
140
424
|
|
141
|
-
|
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
|
-
|
144
|
-
|
145
|
-
|
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
|
-
|
149
|
-
|
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
|
-
|
153
|
-
|
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
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
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
|
-
|
169
|
-
|
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
|
-
|
172
|
-
|
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
|