When developing web applications, presenting data in a way that’s meaningful to users is crucial. Ruby on Rails provides powerful tools to organize, sort, and filter data efficiently. I’ve implemented these techniques in numerous projects and found they significantly enhance user experience while maintaining code cleanliness.
The Fundamentals of Sorting in Rails
Sorting is essential for any data-heavy application. Rails offers several approaches for implementing custom sorting:
# Basic column sorting
users = User.order(created_at: :desc)
# Multiple column sorting
products = Product.order(category: :asc).order(price: :desc)
While these methods work for simple cases, real-world applications require more flexibility. To manage user-controlled sorting, I’ve found parameter-based sorting to be effective:
def index
@products = Product.all
if params[:sort].present?
direction = params[:direction] == "desc" ? :desc : :asc
@products = @products.order(params[:sort] => direction)
else
@products = @products.order(created_at: :desc)
end
end
However, allowing direct parameter usage poses security risks. A better approach uses a whitelist of allowed sorting fields:
def index
@products = Product.all
if params[:sort].present? && allowed_sort_fields.include?(params[:sort])
direction = params[:direction] == "desc" ? :desc : :asc
@products = @products.order(params[:sort] => direction)
else
@products = @products.order(created_at: :desc)
end
end
private
def allowed_sort_fields
%w[name price created_at category]
end
Implementing Basic Filtering Mechanisms
Filtering is where Rails truly shines. The simplest implementation uses where clauses:
# Basic equality filtering
products = Product.where(category: "Electronics")
# Numeric range filtering
products = Product.where("price >= ? AND price <= ?", 100, 500)
# Text search filtering
products = Product.where("name ILIKE ?", "%#{search_term}%")
For multiple filters, we can chain these conditions:
def index
@products = Product.all
@products = @products.where(category: params[:category]) if params[:category].present?
@products = @products.where("price >= ?", params[:min_price]) if params[:min_price].present?
@products = @products.where("price <= ?", params[:max_price]) if params[:max_price].present?
@products = @products.where("name ILIKE ?", "%#{params[:search]}%") if params[:search].present?
end
Advanced Filtering with Scopes
As filtering logic grows complex, moving it into model scopes improves code organization:
# In Product model
class Product < ApplicationRecord
scope :by_category, ->(category) { where(category: category) if category.present? }
scope :by_price_range, ->(min, max) {
query = self
query = query.where("price >= ?", min) if min.present?
query = query.where("price <= ?", max) if max.present?
query
}
scope :search_by_name, ->(term) { where("name ILIKE ?", "%#{term}%") if term.present? }
end
# In controller
def index
@products = Product.all
.by_category(params[:category])
.by_price_range(params[:min_price], params[:max_price])
.search_by_name(params[:search])
end
This approach maintains clean controllers while allowing flexible query composition.
Creating a Dedicated Filtering Service
For complex filtering needs, I’ve found service objects to be invaluable:
class ProductFilterService
def initialize(products, filter_params)
@products = products
@params = filter_params || {}
end
def call
products = @products
products = filter_by_category(products)
products = filter_by_price_range(products)
products = search_by_name(products)
products = filter_by_availability(products)
products
end
private
def filter_by_category(products)
if @params[:category].present?
products.where(category: @params[:category])
else
products
end
end
def filter_by_price_range(products)
products = products.where("price >= ?", @params[:min_price]) if @params[:min_price].present?
products = products.where("price <= ?", @params[:max_price]) if @params[:max_price].present?
products
end
def search_by_name(products)
if @params[:search].present?
products.where("name ILIKE ?", "%#{@params[:search]}%")
else
products
end
end
def filter_by_availability(products)
if @params[:in_stock].present? && @params[:in_stock] == '1'
products.where("inventory_count > 0")
else
products
end
end
end
# In controller
def index
@products = ProductFilterService.new(Product.all, params[:filters]).call
end
Combining Sorting and Filtering
In real applications, we often need both sorting and filtering capabilities:
class ProductSortFilterService
def initialize(base_scope, params)
@base_scope = base_scope
@params = params
@allowed_sort_fields = %w[name price created_at category]
end
def call
products = apply_filters(@base_scope)
products = apply_sorting(products)
products
end
private
def apply_filters(products)
filters = @params[:filters] || {}
products = products.where(category: filters[:category]) if filters[:category].present?
products = products.where("price >= ?", filters[:min_price]) if filters[:min_price].present?
products = products.where("price <= ?", filters[:max_price]) if filters[:max_price].present?
products = products.where("name ILIKE ?", "%#{filters[:search]}%") if filters[:search].present?
products
end
def apply_sorting(products)
sort_by = @params[:sort]
direction = @params[:direction] == "desc" ? :desc : :asc
if sort_by.present? && @allowed_sort_fields.include?(sort_by)
products.order(sort_by => direction)
else
products.order(created_at: :desc)
end
end
end
# In controller
def index
@products = ProductSortFilterService.new(Product.all, params).call
.page(params[:page]).per(20) # with pagination
end
Using Query Objects for Complex Filtering
Query objects provide a structured approach for complex filtering logic:
class ProductQuery
attr_reader :relation
def initialize(relation = Product.all)
@relation = relation
end
def search(term)
return self if term.blank?
@relation = relation.where("name ILIKE :term OR description ILIKE :term", term: "%#{term}%")
self
end
def by_price_range(min, max)
@relation = relation.where("price >= ?", min) if min.present?
@relation = relation.where("price <= ?", max) if max.present?
self
end
def by_categories(categories)
return self if categories.blank?
@relation = relation.where(category: categories)
self
end
def by_manufacturer(manufacturers)
return self if manufacturers.blank?
@relation = relation.where(manufacturer: manufacturers)
self
end
def in_stock(in_stock_only)
return self unless in_stock_only == '1'
@relation = relation.where("inventory_count > 0")
self
end
def sort_by(field, direction = :asc)
allowed_fields = %w[name price created_at category]
if field.present? && allowed_fields.include?(field.to_s)
direction = (direction.to_s == 'desc') ? :desc : :asc
@relation = relation.order(field => direction)
else
@relation = relation.order(created_at: :desc)
end
self
end
end
# In controller
def index
query = ProductQuery.new
.search(params[:search])
.by_price_range(params[:min_price], params[:max_price])
.by_categories(params[:categories])
.by_manufacturer(params[:manufacturers])
.in_stock(params[:in_stock])
.sort_by(params[:sort], params[:direction])
@products = query.relation.page(params[:page]).per(20)
end
Filtering with JSON Parameters
Modern web applications often send filter parameters as structured JSON. Rails handles this elegantly:
class Api::ProductsController < ApplicationController
def index
allowed_filters = [:category, :min_price, :max_price, :manufacturer, :search, :in_stock]
filter_params = params.fetch(:filters, {}).permit(allowed_filters)
products = Product.all
if filter_params[:category].present?
products = products.where(category: filter_params[:category])
end
if filter_params[:min_price].present?
products = products.where("price >= ?", filter_params[:min_price])
end
if filter_params[:max_price].present?
products = products.where("price <= ?", filter_params[:max_price])
end
if filter_params[:search].present?
products = products.where("name ILIKE ?", "%#{filter_params[:search]}%")
end
# Sorting
if params[:sort].present? && %w[name price created_at].include?(params[:sort])
direction = params[:direction] == "desc" ? :desc : :asc
products = products.order(params[:sort] => direction)
else
products = products.order(created_at: :desc)
end
render json: products
end
end
Implementing Sortable and Filterable Concerns
To reuse sorting and filtering logic across models, I’ve found Rails concerns to be effective:
# app/models/concerns/sortable.rb
module Sortable
extend ActiveSupport::Concern
module ClassMethods
def sortable(sort_param, default_sort: {created_at: :desc})
return order(default_sort) if sort_param.blank?
field, direction = parse_sort_param(sort_param)
allowed_fields = sortable_fields
if allowed_fields.include?(field)
direction = (direction == 'desc') ? :desc : :asc
order(field => direction)
else
order(default_sort)
end
end
def parse_sort_param(sort_param)
parts = sort_param.to_s.split(':')
field = parts[0]
direction = parts[1] || 'asc'
[field, direction]
end
def sortable_fields
# Override in models
[]
end
end
end
# app/models/concerns/filterable.rb
module Filterable
extend ActiveSupport::Concern
module ClassMethods
def filter(filtering_params)
results = self.where(nil)
filtering_params.each do |key, value|
results = results.public_send("filter_by_#{key}", value) if value.present? && respond_to?("filter_by_#{key}")
end
results
end
end
end
# In Product model
class Product < ApplicationRecord
include Sortable
include Filterable
scope :filter_by_category, ->(category) { where(category: category) }
scope :filter_by_price_min, ->(price) { where("price >= ?", price) }
scope :filter_by_price_max, ->(price) { where("price <= ?", price) }
scope :filter_by_search, ->(term) { where("name ILIKE ? OR description ILIKE ?", "%#{term}%", "%#{term}%") }
def self.sortable_fields
%w[name price created_at category]
end
end
# In controller
def index
@products = Product.filter(params.slice(:category, :price_min, :price_max, :search))
.sortable(params[:sort])
.page(params[:page]).per(20)
end
Using Ransack for Advanced Filtering and Sorting
For complex requirements, the Ransack gem provides a comprehensive solution:
# Gemfile
gem 'ransack'
# In controller
def index
@q = Product.ransack(params[:q])
@products = @q.result(distinct: true).page(params[:page])
end
With Ransack, the view becomes simpler:
<%= search_form_for @q do |f| %>
<%= f.label :name_cont, "Name contains" %>
<%= f.search_field :name_cont %>
<%= f.label :price_gteq, "Price from" %>
<%= f.number_field :price_gteq %>
<%= f.label :price_lteq, "Price to" %>
<%= f.number_field :price_lteq %>
<%= f.label :category_eq, "Category" %>
<%= f.select :category_eq, Category.pluck(:name, :id), include_blank: true %>
<%= f.submit "Search" %>
<% end %>
<table>
<thead>
<tr>
<th><%= sort_link(@q, :name) %></th>
<th><%= sort_link(@q, :price) %></th>
<th><%= sort_link(@q, :created_at, "Date") %></th>
</tr>
</thead>
<tbody>
<% @products.each do |product| %>
<tr>
<td><%= product.name %></td>
<td><%= product.price %></td>
<td><%= product.created_at.to_date %></td>
</tr>
<% end %>
</tbody>
</table>
Optimizing Performance for Large Datasets
When dealing with large datasets, performance becomes critical. I’ve implemented several optimization techniques:
class OptimizedProductQuery
def initialize(params)
@params = params
@relation = Product.includes(:category, :manufacturer)
end
def results
apply_filters
apply_sorting
add_pagination
@relation
end
private
def apply_filters
filter_by_category
filter_by_price_range
filter_by_search
filter_by_manufacturers
end
def filter_by_category
return unless @params[:category_id].present?
@relation = @relation.where(category_id: @params[:category_id])
end
def filter_by_price_range
if @params[:min_price].present?
@relation = @relation.where("price >= ?", @params[:min_price])
end
if @params[:max_price].present?
@relation = @relation.where("price <= ?", @params[:max_price])
end
end
def filter_by_search
return unless @params[:search].present?
search_term = "%#{@params[:search]}%"
# Use full-text search if available
if connection_supports_full_text_search?
@relation = @relation.where("to_tsvector('english', name || ' ' || description) @@ plainto_tsquery('english', ?)", @params[:search])
else
@relation = @relation.where("name ILIKE ? OR description ILIKE ?", search_term, search_term)
end
end
def filter_by_manufacturers
return unless @params[:manufacturer_ids].present?
@relation = @relation.where(manufacturer_id: @params[:manufacturer_ids])
end
def apply_sorting
sort_column = @params[:sort].present? ? @params[:sort] : 'created_at'
sort_direction = @params[:direction] == 'asc' ? :asc : :desc
allowed_columns = %w[name price created_at category_id]
if allowed_columns.include?(sort_column)
@relation = @relation.order(sort_column => sort_direction)
else
@relation = @relation.order(created_at: :desc)
end
end
def add_pagination
page = (@params[:page] || 1).to_i
per_page = (@params[:per_page] || 20).to_i
per_page = 100 if per_page > 100 # Limit maximum per page
@relation = @relation.page(page).per(per_page)
end
def connection_supports_full_text_search?
ActiveRecord::Base.connection.adapter_name.downcase.include?('postgres')
end
end
# In controller
def index
query = OptimizedProductQuery.new(params)
@products = query.results
end
Implementing URL-Friendly Sorting and Filtering
To create shareable, bookmarkable filtered views:
# routes.rb
resources :products, only: [:index]
# In controller
def index
@products = ProductQuery.new
.search(params[:search])
.by_price_range(params[:min_price], params[:max_price])
.by_categories(params[:categories])
.sort_by(params[:sort], params[:direction])
.relation
.page(params[:page]).per(20)
respond_to do |format|
format.html
format.json { render json: @products }
end
end
For the view:
<%= form_tag products_path, method: :get, class: 'filter-form' do %>
<%= text_field_tag :search, params[:search], placeholder: "Search products..." %>
<%= number_field_tag :min_price, params[:min_price], placeholder: "Min price" %>
<%= number_field_tag :max_price, params[:max_price], placeholder: "Max price" %>
<% Category.all.each do |category| %>
<label>
<%= check_box_tag 'categories[]', category.id, params[:categories]&.include?(category.id.to_s) %>
<%= category.name %>
</label>
<% end %>
<%= hidden_field_tag :sort, params[:sort] %>
<%= hidden_field_tag :direction, params[:direction] %>
<%= submit_tag "Apply Filters" %>
<% end %>
<div class="sorting-links">
Sort by:
<%= link_to "Name", products_path(request.params.merge(sort: 'name', direction: 'asc')), class: params[:sort] == 'name' ? 'active' : '' %>
<%= link_to "Price (low to high)", products_path(request.params.merge(sort: 'price', direction: 'asc')), class: params[:sort] == 'price' && params[:direction] == 'asc' ? 'active' : '' %>
<%= link_to "Price (high to low)", products_path(request.params.merge(sort: 'price', direction: 'desc')), class: params[:sort] == 'price' && params[:direction] == 'desc' ? 'active' : '' %>
<%= link_to "Newest", products_path(request.params.merge(sort: 'created_at', direction: 'desc')), class: params[:sort] == 'created_at' ? 'active' : '' %>
</div>
<div class="products-container">
<% @products.each do |product| %>
<div class="product-card">
<h3><%= product.name %></h3>
<p><%= number_to_currency(product.price) %></p>
<p>Category: <%= product.category.name %></p>
</div>
<% end %>
</div>
<div class="pagination">
<%= paginate @products %>
</div>
I’ve found the techniques described in this article to be highly effective in my Rails projects. By implementing proper sorting and filtering mechanisms, we can provide users with a more intuitive experience while maintaining clean, maintainable code that scales with our application’s growth.
When implementing these patterns, always remember to sanitize user input, use allowed lists for sortable and filterable fields, and optimize database queries for performance. These practices ensure that your sorting and filtering mechanisms remain secure and efficient as your application evolves.