Class: Sortsmith::Sorter

Inherits:
Object
  • Object
show all
Defined in:
lib/sortsmith/sorter.rb

Overview

A chainable sorting interface that provides a fluent API for complex sorting operations.

The Sorter class allows you to build sorting pipelines by chaining extractors, modifiers, and ordering methods before executing the sort with a terminator method. This creates readable, expressive sorting code that handles edge cases gracefully.

Examples:

Basic usage

users.sort_by.dig(:name).sort
# => sorted array by name

Complex chaining

users.sort_by.dig(:name, indifferent: true).downcase.desc.sort
# => sorted by name (case-insensitive, descending, with indifferent key access)

Method extraction

users.sort_by.method(:full_name).insensitive.sort
# => sorted by calling full_name method on each user

Mixed key types

mixed_data = [
  {name: "Bob"},      # symbol key
  {"name" => "Alice"} # string key
]
mixed_data.sort_by.dig(:name, indifferent: true).sort
# => handles both key types gracefully

Handling missing methods gracefully

users.sort_by.method(:missing_email).sort
# => preserves original order when method doesn't exist

Nil value handling

users = [{name: "Bob"}, {name: nil}, {name: "Alice"}]
users.sort_by.dig(:name).nil_first.sort
# => [{name: nil}, {name: "Alice"}, {name: "Bob"}]

users.sort_by.dig(:name).nil_last.desc.sort
# => [{name: "Bob"}, {name: "Alice"}, {name: nil}]

See Also:

Since:

  • 0.9.0

Constant Summary collapse

INDIFFERENT_KEYS_TRANSFORM =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

Transformation proc for converting hash keys to symbols for indifferent access.

Used internally when the indifferent: true option is specified in #dig. This enables consistent key lookup across hashes with mixed symbol/string keys.

Examples:

Usage in indifferent access

mixed_hashes = [
  {name: "Bob"},        # symbol key
  {"name" => "Alice"}   # string key
]
# Both will be accessed via :name after transformation

Returns:

  • (Proc)

    A proc that transforms hash keys to symbols

Since:

  • 0.9.0

->(item) { item.transform_keys(&:to_sym) }
DELEGATED_METHODS =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

List of enumerable methods that are delegated to the sorted result.

These methods allow seamless chaining from sorting operations to common array operations without breaking the fluent interface. Each delegated method executes the sort pipeline first, then applies the requested operation to the sorted result.

Examples:

Delegated method usage

users.sort_by.dig(:score).desc.first(3)    # Get top 3 scores
users.sort_by.dig(:name).each { |u| puts u } # Iterate in sorted order
users.sort_by.dig(:age)[0..2]               # Array slice of sorted results

Since:

  • 1.0.0

%i[first last take drop each map select [] size count length].freeze

Instance Method Summary collapse

Constructor Details

#initialize(input) ⇒ Sorter

Initialize a new Sorter instance.

Creates a new chainable sorter for the given collection. Typically called automatically when using collection.sort_by without a block.

Examples:

Direct instantiation (rarely needed)

sorter = Sortsmith::Sorter.new(users)
sorter.dig(:name).sort

Typical usage (via sort_by)

users.sort_by.dig(:name).sort

Parameters:

  • input (Array, Enumerable)

    The collection to be sorted

Since:

  • 0.9.0



98
99
100
101
102
103
104
# File 'lib/sortsmith/sorter.rb', line 98

def initialize(input)
  @input = input
  @extractors = []
  @modifiers = []
  @descending = false
  @nil_first = false
end

Instance Method Details

#[](index) ⇒ Object, Array

Access elements by index in the sorted result

Examples:

users.sort_by.dig(:score).desc[0]      # Highest scoring user
users.sort_by.dig(:name)[1..3]         # Users 2-4 alphabetically

Parameters:

  • index (Integer, Range)

    Index or range to access

Returns:

  • (Object, Array)

    Element(s) at the specified index/range



740
741
742
743
744
# File 'lib/sortsmith/sorter.rb', line 740

DELEGATED_METHODS.each do |method_name|
  define_method(method_name) do |*args, &block|
    to_a.public_send(method_name, *args, &block)
  end
end

#ascSorter

Sort in ascending order (default behavior).

This is typically unnecessary as ascending is the default sort direction, but can be useful for explicit clarity or resetting direction after previous desc calls in a chain.

Examples:

Explicit ascending sort

users.sort_by.dig(:name).asc.sort

Resetting after desc

users.sort_by.dig(:name).desc.asc.sort  # ends up ascending

Returns:

  • (Sorter)

    Returns self for method chaining

Since:

  • 0.9.0



404
405
406
407
# File 'lib/sortsmith/sorter.rb', line 404

def asc
  @descending = false
  self
end

#count {|Object| ... } ⇒ Integer

Count elements in the sorted result, optionally with a condition

Examples:

users.sort_by.dig(:name).count                    # Total count
users.sort_by.dig(:score).count { |u| u[:active] } # Count active users

Yields:

  • (Object)

    Each element for conditional counting

Returns:

  • (Integer)

    Count of elements



740
741
742
743
744
# File 'lib/sortsmith/sorter.rb', line 740

DELEGATED_METHODS.each do |method_name|
  define_method(method_name) do |*args, &block|
    to_a.public_send(method_name, *args, &block)
  end
end

#descSorter

Sort in descending order.

Reverses the final sort order after all comparisons are complete. Can be chained with other modifiers and will apply to the final result.

Examples:

Descending sort

users.sort_by.dig(:age).desc.sort
# => Oldest users first

With case modification

users.sort_by.dig(:name).insensitive.desc.sort
# => Case-insensitive, reverse alphabetical

Returns:

  • (Sorter)

    Returns self for method chaining

Since:

  • 0.9.0



425
426
427
428
# File 'lib/sortsmith/sorter.rb', line 425

def desc
  @descending = true
  self
end

#dig(*identifiers, indifferent: false) ⇒ Sorter Also known as: key, field

Extract values from objects using hash keys or object methods.

The workhorse method for value extraction. Works with hashes, structs, and any object that responds to the given identifiers. Supports nested digging with multiple arguments and handles mixed key types gracefully.

When extracting from objects that don't respond to the specified keys/methods, returns an empty string to preserve the original ordering rather than causing comparison errors.

Examples:

Hash key extraction

users.sort_by.dig(:name).sort

Nested hash extraction

users.sort_by.dig(:profile, :email).sort

Array index extraction

coordinates.sort_by.dig(0).sort  # sort by x-coordinate

Mixed key types with indifferent access

mixed_data = [
  {name: "Bob"},        # symbol key
  {"name" => "Alice"}   # string key
]
mixed_data.sort_by.dig(:name, indifferent: true).sort
# => Both key types work seamlessly

Object method calls

users.sort_by.dig(:calculate_score).sort

Graceful handling of missing keys

users.sort_by.dig(:missing_field).sort
# => Preserves original order instead of erroring

Parameters:

  • identifiers (Array<Symbol, String, Integer>)

    Keys, method names, or indices to extract

  • indifferent (Boolean) (defaults to: false)

    When true, normalizes hash keys to symbols for consistent lookup

Returns:

  • (Sorter)

    Returns self for method chaining

Since:

  • 0.9.0



150
151
152
153
154
155
156
157
158
# File 'lib/sortsmith/sorter.rb', line 150

def dig(*identifiers, indifferent: false)
  if indifferent
    identifiers = identifiers.map(&:to_sym)
    before_extract = INDIFFERENT_KEYS_TRANSFORM
  end

  @extractors << {method: :dig, positional: identifiers, before_extract:}
  self
end

#downcaseSorter Also known as: insensitive, case_insensitive

Transform extracted values to lowercase for comparison.

Only affects values that respond to #downcase (typically strings). Non-string values are converted to strings first, ensuring consistent behavior across mixed data types.

Examples:

Case-insensitive string sorting

names = ["charlie", "Alice", "BOB"]
names.sort_by.downcase.sort
# => ["Alice", "BOB", "charlie"]

With hash extraction

users.sort_by.dig(:name).downcase.sort

Mixed data types

mixed = ["Apple", 42, "banana"]
mixed.sort_by.downcase.sort
# => [42, "Apple", "banana"] (42 becomes "42")

Returns:

  • (Sorter)

    Returns self for method chaining

Since:

  • 0.9.0



329
330
331
332
# File 'lib/sortsmith/sorter.rb', line 329

def downcase
  @modifiers << {method: :downcase}
  self
end

#drop(n) ⇒ Array

Drop the first n elements from the sorted result

Examples:

users.sort_by.dig(:score).drop(3)       # All except top 3

Parameters:

  • n (Integer)

    Number of elements to drop

Returns:

  • (Array)

    Array with remaining elements



740
741
742
743
744
# File 'lib/sortsmith/sorter.rb', line 740

DELEGATED_METHODS.each do |method_name|
  define_method(method_name) do |*args, &block|
    to_a.public_send(method_name, *args, &block)
  end
end

#each {|Object| ... } ⇒ Array, Enumerator

Iterate over the sorted result

Examples:

users.sort_by.dig(:name).each { |user| puts user[:email] }

Yields:

  • (Object)

    Each element in sorted order

Returns:

  • (Array, Enumerator)

    Sorted array if block given, enumerator otherwise



740
741
742
743
744
# File 'lib/sortsmith/sorter.rb', line 740

DELEGATED_METHODS.each do |method_name|
  define_method(method_name) do |*args, &block|
    to_a.public_send(method_name, *args, &block)
  end
end

#extract(field, *positional, **keyword) ⇒ Sorter

Universal extraction method that intelligently chooses the appropriate extraction strategy.

This method serves as the smart dispatcher for value extraction, automatically detecting whether the input collection contains hash-like objects (that respond to dig) or regular objects (that need method calls). It provides a unified interface regardless of the underlying data structure.

When field is nil, returns self without adding any extractors to the pipeline, allowing for graceful handling of dynamic field selection scenarios.

Examples:

Hash extraction (uses dig internally)

users = [{ name: "Alice" }, { name: "Bob" }]
users.sort_by.extract(:name).sort

Object method extraction (uses method internally)

User = Struct.new(:name, :score)
users = [User.new("Alice", 92), User.new("Bob", 78)]
users.sort_by.extract(:score).sort

Indifferent key access

mixed_data = [{ name: "Alice" }, { "name" => "Bob" }]
mixed_data.sort_by.extract(:name, indifferent: true).sort

Graceful nil handling

field_name = might_return_nil_from_api()
users.sort_by.extract(field_name).sort  # No extraction if field_name is nil

Chaining with modifiers

users.sort_by.extract(:name).insensitive.desc.sort

Parameters:

  • field (Symbol, String, nil)

    The field name, hash key, or method name to extract

  • positional (Array)

    Additional positional arguments passed to extraction

  • keyword (Hash)

    Additional keyword arguments passed to extraction

Returns:

  • (Sorter)

    Returns self for method chaining

See Also:

Since:

  • 1.0.0



293
294
295
296
297
298
299
300
301
# File 'lib/sortsmith/sorter.rb', line 293

def extract(field, *positional, **keyword)
  return self if field.nil?

  if @input.first.respond_to?(:dig)
    dig(field, **keyword)
  else
    method(field, *positional, **keyword)
  end
end

#first(n = 1) ⇒ Object, Array

Get the first n elements from the sorted result

Examples:

users.sort_by.dig(:score).desc.first    # Highest scoring user
users.sort_by.dig(:name).first(3)       # First 3 alphabetically

Parameters:

  • n (Integer) (defaults to: 1)

    Number of elements to return

Returns:

  • (Object, Array)

    Single element if n=1, array otherwise



740
741
742
743
744
# File 'lib/sortsmith/sorter.rb', line 740

DELEGATED_METHODS.each do |method_name|
  define_method(method_name) do |*args, &block|
    to_a.public_send(method_name, *args, &block)
  end
end

#last(n = 1) ⇒ Object, Array

Get the last n elements from the sorted result

Examples:

users.sort_by.dig(:age).last            # Oldest user
users.sort_by.dig(:score).last(2)       # Bottom 2 scores

Parameters:

  • n (Integer) (defaults to: 1)

    Number of elements to return

Returns:

  • (Object, Array)

    Single element if n=1, array otherwise



740
741
742
743
744
# File 'lib/sortsmith/sorter.rb', line 740

DELEGATED_METHODS.each do |method_name|
  define_method(method_name) do |*args, &block|
    to_a.public_send(method_name, *args, &block)
  end
end

#lengthInteger

Get the number of elements in the sorted result (alias for size)

Examples:

users.sort_by.dig(:name).length

Returns:

  • (Integer)

    Number of elements



740
741
742
743
744
# File 'lib/sortsmith/sorter.rb', line 740

DELEGATED_METHODS.each do |method_name|
  define_method(method_name) do |*args, &block|
    to_a.public_send(method_name, *args, &block)
  end
end

#map {|Object| ... } ⇒ Array, Enumerator

Transform each element of the sorted result

Examples:

users.sort_by.dig(:name).map(&:upcase)

Yields:

  • (Object)

    Each element in sorted order

Returns:

  • (Array, Enumerator)

    Transformed array if block given, enumerator otherwise



740
741
742
743
744
# File 'lib/sortsmith/sorter.rb', line 740

DELEGATED_METHODS.each do |method_name|
  define_method(method_name) do |*args, &block|
    to_a.public_send(method_name, *args, &block)
  end
end

#method(method_name, *positional, **keyword) ⇒ Sorter Also known as: attribute

Extract values by calling methods on objects with optional arguments.

Enables chainable sorting by calling methods on each object in the collection. Supports method calls with both positional and keyword arguments. When objects don't respond to the specified method, returns an empty string to preserve original ordering.

This is particularly useful for custom objects, calculated values, or methods that require parameters.

Examples:

Basic method sorting

users.sort_by.method(:name).sort

Method with chainable modifiers

users.sort_by.method(:full_name).insensitive.desc.sort

Method with positional arguments

products.sort_by.method(:price_in, "USD").sort

Method with keyword arguments

items.sort_by.method(:calculate_score, boost: 1.5).sort

Complex method calls

reports.sort_by.method(:metric_for, :revenue, period: "Q1").desc.sort

Graceful handling of missing methods

mixed_objects.sort_by.method(:priority).sort
# => Objects without :priority method maintain original order

Parameters:

  • method_name (Symbol, String)

    The method name to call on each object

  • positional (Array)

    Positional arguments to pass to the method

  • keyword (Hash)

    Keyword arguments to pass to the method

Returns:

  • (Sorter)

    Returns self for method chaining

Since:

  • 0.9.0



229
230
231
232
# File 'lib/sortsmith/sorter.rb', line 229

def method(method_name, *positional, **keyword)
  @extractors << {method: method_name, positional:, keyword:}
  self
end

#nil_firstSorter

Position nil values at the beginning of sort results.

By default, nil values sort last (matching SQL/database conventions). This modifier overrides that behavior to place nils first, regardless of sort direction (asc/desc).

The nil positioning is independent of asc/desc modifiers, meaning nil_first with desc will show nils first, then non-nil values in descending order.

Examples:

Basic nil_first usage

users = [
  {name: "Bob"},
  {name: nil},
  {name: "Alice"}
]
users.sort_by.dig(:name).nil_first.sort
# => [{name: nil}, {name: "Alice"}, {name: "Bob"}]

Combining with desc

users.sort_by.dig(:name).nil_first.desc.sort
# => [{name: nil}, {name: "Bob"}, {name: "Alice"}]
# Nils first, then descending order

With case modifiers

users.sort_by.dig(:name).insensitive.nil_first.sort
# => Case-insensitive sort with nils first

Returns:

  • (Sorter)

    Returns self for method chaining

See Also:

Since:

  • 0.9.0



463
464
465
466
# File 'lib/sortsmith/sorter.rb', line 463

def nil_first
  @nil_first = true
  self
end

#nil_lastSorter

Position nil values at the end of sort results (explicit default).

This is the default behavior, but can be used for explicitness or to override a previous nil_first call in a chain. Nil values will appear last regardless of sort direction (asc/desc).

Examples:

Explicit nil_last

users = [
  {name: "Bob"},
  {name: nil},
  {name: "Alice"}
]
users.sort_by.dig(:name).nil_last.sort
# => [{name: "Alice"}, {name: "Bob"}, {name: nil}]

Overriding nil_first

users.sort_by.dig(:name).nil_first.nil_last.sort
# => Last setting wins: nils appear last

With desc

users.sort_by.dig(:name).nil_last.desc.sort
# => [{name: "Bob"}, {name: "Alice"}, {name: nil}]
# Descending order, then nils last

Returns:

  • (Sorter)

    Returns self for method chaining

See Also:

Since:

  • 0.9.0



497
498
499
500
# File 'lib/sortsmith/sorter.rb', line 497

def nil_last
  @nil_first = false
  self
end

#reverseArray

Shorthand for adding desc and executing sort.

Equivalent to calling .desc.sort but more concise and expressive. Useful when you know you want descending order and don't need other modifiers.

Examples:

Reverse sorting

users.sort_by.dig(:created_at).reverse
# => Newest users first

Equivalent to

users.sort_by.dig(:created_at).desc.sort

Returns:

  • (Array)

    A new array sorted in descending order

Since:

  • 0.9.0



640
641
642
# File 'lib/sortsmith/sorter.rb', line 640

def reverse
  desc.sort
end

#reverse!Array

Shorthand for adding desc and executing sort!.

Equivalent to calling .desc.sort! but more concise. Mutates the original array and returns it in descending order.

Examples:

In-place reverse sorting

users.sort_by.dig(:score).reverse!

Returns:

  • (Array)

    The original array, sorted in descending order

Since:

  • 0.9.0



655
656
657
# File 'lib/sortsmith/sorter.rb', line 655

def reverse!
  desc.sort!
end

#select {|Object| ... } ⇒ Array, Enumerator

Filter the sorted result

Examples:

users.sort_by.dig(:score).select { |u| u[:active] }

Yields:

  • (Object)

    Each element in sorted order

Returns:

  • (Array, Enumerator)

    Filtered array if block given, enumerator otherwise



740
741
742
743
744
# File 'lib/sortsmith/sorter.rb', line 740

DELEGATED_METHODS.each do |method_name|
  define_method(method_name) do |*args, &block|
    to_a.public_send(method_name, *args, &block)
  end
end

#sizeInteger

Get the number of elements in the sorted result

Examples:

users.sort_by.dig(:name).size

Returns:

  • (Integer)

    Number of elements



740
741
742
743
744
# File 'lib/sortsmith/sorter.rb', line 740

DELEGATED_METHODS.each do |method_name|
  define_method(method_name) do |*args, &block|
    to_a.public_send(method_name, *args, &block)
  end
end

#sortArray Also known as: to_a

Execute the sort pipeline and return a new sorted array.

Applies all chained extraction, transformation, and ordering steps to produce the final sorted result. The original collection remains unchanged.

Examples:

Basic termination

sorted_users = users.sort_by.dig(:name).sort
# original users array unchanged

Complex pipeline

result = users.sort_by.dig(:name, indifferent: true).insensitive.desc.sort

Returns:

  • (Array)

    A new array containing the sorted elements

Since:

  • 0.9.0



522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
# File 'lib/sortsmith/sorter.rb', line 522

def sort
  # Schwartzian Transform: extract once (O(n)), sort (O(n log n)), map back (O(n))

  # Step 1: Pair each item with its extracted sort value
  pairs = @input.map { |item| [extract_and_transform(item), item] }

  # Step 2: Partition nils so we can use C-level sort_by on non-nil values
  nil_pairs, non_nil_pairs = pairs.partition { |v, _| v.nil? }

  # Step 3: Sort non-nil values using Ruby's native sort_by (runs in C)
  begin
    non_nil_pairs.sort_by! { |v, _| v }
  rescue ArgumentError
    # Re-raise with a more helpful error message
    # Find the first pair of incomparable values for the message
    non_nil_pairs.each_cons(2) do |(val_a, _), (val_b, _)|
      result = val_a <=> val_b
      next unless result.nil?

      # <=> returned nil - incomparable types
      raise ArgumentError, <<~ERROR
        Cannot compare values during sort - the values are incomparable types.
        This usually means your extraction returned mixed types or you're missing an extraction method.
        Comparing:
          #{val_a.inspect} (#{val_a.class})
          <=>
          #{val_b.inspect} (#{val_b.class})
      ERROR
    rescue ArgumentError
      # <=> raised instead of returning nil
      raise ArgumentError, <<~ERROR
        Cannot compare values during sort - the <=> operator raised an exception.
        This usually means the class doesn't implement <=>, has a buggy implementation, or you're missing an extraction method.
        Comparing:
          #{val_a.inspect} (#{val_a.class})
          <=>
          #{val_b.inspect} (#{val_b.class})
      ERROR
    end
  end

  non_nil_pairs.reverse! if @descending

  # Step 4: Combine based on nil positioning
  result = @nil_first ? nil_pairs.concat(non_nil_pairs) : non_nil_pairs.concat(nil_pairs)

  # Step 5: Strip the sort values, keeping only the original items
  result.map! { |_, item| item }
end

#sort!Array Also known as: to_a!

Execute the sort pipeline and mutate the original array in place.

Same as #sort but modifies the original array instead of creating a new one. Returns the mutated array for chaining. Use when memory efficiency is important and you don't need to preserve the original order.

Examples:

In-place sorting

users.sort_by.dig(:name).sort!
# users array is now modified

Chaining after mutation

result = users.sort_by.dig(:name).sort!.first(10)

Returns:

  • (Array)

    The original array, now sorted

Since:

  • 0.9.0



603
604
605
606
# File 'lib/sortsmith/sorter.rb', line 603

def sort!
  sorted = sort
  @input.replace(sorted)
end

#take(n) ⇒ Array

Take the first n elements from the sorted result

Examples:

users.sort_by.dig(:score).desc.take(5)  # Top 5 users

Parameters:

  • n (Integer)

    Number of elements to take

Returns:

  • (Array)

    Array with up to n elements



740
741
742
743
744
# File 'lib/sortsmith/sorter.rb', line 740

DELEGATED_METHODS.each do |method_name|
  define_method(method_name) do |*args, &block|
    to_a.public_send(method_name, *args, &block)
  end
end

#upcaseSorter

Transform extracted values to uppercase for comparison.

Only affects values that respond to #upcase (typically strings). Non-string values are converted to strings first, ensuring consistent behavior across mixed data types.

Examples:

Uppercase sorting

names.sort_by.upcase.sort

With extraction

users.sort_by.dig(:department).upcase.desc.sort

Returns:

  • (Sorter)

    Returns self for method chaining

Since:

  • 0.9.0



380
381
382
383
# File 'lib/sortsmith/sorter.rb', line 380

def upcase
  @modifiers << {method: :upcase}
  self
end