Typical Sort

Predictable, allowlisted sorting for Rails controllers.

typical_sort is intentionally small. It does one thing: turns request sort params into safe ActiveRecord ordering.

Installation

gem "typical_sort"

Usage

class PatientsController < ApplicationController
  include TypicalSort

  typical_sort do
    default :created_at, :desc

    sort :created_at
    sort :last_name
    sort "account.name"
    sort "insurance_plans.name", aggregate: :directional
  end

  def index
    @patients = sort_records(Patient.all)
  end
end

Supported params:

?sort=created_at&sort_dir=desc
?sort=-created_at
?sort=account.name
?sort=insurance_plans.name&sort_dir=asc

Only declared sorts are allowed. Unknown request sort params are ignored by default, or raised as TypicalSort::InvalidSort when configured with invalid_sort = :raise.

Base and association sorting

Declare base-table columns with symbols:

sort :last_name
sort :created_at

Declare one-level association paths with strings:

sort "account.name"

Association support:

  • belongs_to
  • has_one
  • has_many, with an aggregate
  • has_many :through, with an aggregate
  • has_and_belongs_to_many, with an aggregate
  • polymorphic has_many, with an aggregate
  • STI associations using normal Rails reflections

Polymorphic belongs_to paths are not supported because there is no single target table to order by. Nested association paths such as "account.organization.name" are also not supported.

Aggregate sorting

Collection associations need one sortable value per parent record. Declare an aggregate explicitly:

sort "insurance_plans.name", aggregate: :directional
sort "payments.amount_cents", aggregate: :sum
sort "comments.id", aggregate: :count

Supported aggregate values:

  • :directionalMIN for ascending, MAX for descending
  • :min
  • :max
  • :sum
  • :avg
  • :count

Scope sorting

Use scope: true when a sort needs custom ordering logic:

class Book < ApplicationRecord
  scope :by_publication, ->(dir) {
    order(year_published: dir).order(author_name: :asc)
  }
end

class BooksController < ApplicationController
  include TypicalSort

  typical_sort do
    sort :by_publication, scope: true
  end
end

Scope sorts always receive the resolved direction as the first argument: :asc or :desc.

Pagination

Apply sort_records before paginating so the database sorts the full filtered relation, then the pagination library applies LIMIT / OFFSET to the sorted relation.

Pagy

class PatientsController < ApplicationController
  include Pagy::Backend
  include TypicalSort

  typical_sort do
    default :created_at, :desc
    sort :created_at
    sort :last_name
    sort "insurance_plans.name", aggregate: :directional
  end

  def index
    patients = Patient.where(active: true)
    patients = sort_records(patients)

    @pagy, @patients = pagy(patients)
  end
end

Kaminari

class PatientsController < ApplicationController
  include TypicalSort

  typical_sort do
    default :created_at, :desc
    sort :created_at
    sort :last_name
    sort "insurance_plans.name", aggregate: :directional
  end

  def index
    @patients = Patient
      .where(active: true)
      .then { |records| sort_records(records) }
      .page(params[:page])
      .per(25)
  end
end

Avoid paginating first and sorting second:

# Avoid: sorts only after the relation is already constrained by pagination.
sort_records(Patient.page(params[:page]))

Configuration

TypicalSort.configure do |config|
  config.sort_param = :sort
  config.direction_param = :sort_dir
  config.default_direction = :asc
  config.invalid_sort = :ignore # or :raise
  config.tie_breaker = :primary_key
  config.nulls = {
    asc: :last,
    desc: :first
  }
end

nulls values must be :first or :last.

Development

bundle install
bundle exec appraisal install
bundle exec appraisal rspec
bundle exec standardrb

Releases

Releases are driven by git tags. The version lives in lib/typical_sort/version.rb, and the gemspec reads TypicalSort::VERSION.

bundle install
bin/release patch # or: minor, major

To publish an RC for a specific version:

bin/release rc 1.2.0

This sets the version to 1.2.0-rc and tags it as v1.2.0-rc.

Prereleases can also use bump's native pre increment:

bin/release pre

pre cycles prerelease labels in order: alpha, beta, rc, then the final version. For example, 1.2.0-rc becomes 1.2.0, tagged as v1.2.0.

bin/release uses bump, commits the version file, creates a v<version> tag, pushes the branch, and pushes the tag.

GitHub Actions publishes only when a v* tag is pushed. The publish workflow builds the gem and pushes it to RubyGems with RUBYGEMS_API_KEY.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/apsislabs/typical_sort.

License

The gem is available as open source under the terms of the MIT License.


Built by Apsis

apsis

typical_sort was built by Apsis Labs. We love sharing what we build! Check out our other libraries on Github, and if you like our work you can hire us to build your vision.