QueryParamsProcessor
Reusable; allows any controller to handle queryParams (e.g. :index action)
Implement in controller (items_controller.rb):
class ItemsController < ApplicationController
include QueryParamsProcessor
def index
...
# Process query params
@items = process_query_params(@items)
...
render json: @items
end
end
Logic:
# app/controllers/concerns/query_params_processor.rb
module QueryParamsProcessor
extend ActiveSupport::Concern
# NOTES:
# - Error Handling: Consider adding error handling. For instance, if someone passes a non-integer for limit, it might cause an error. Validating user input before using it would be beneficial.
# - Performance: For operations that could return a large dataset (like a broad select without limits), be aware of potential performance issues. Always monitor and check query performance, especially when the dataset grows. Tools like rack-mini-profiler can help with this.
# - Security: The comments below indicate the right level of caution ("IMPORTANT"). Especially, always be wary of using direct user input in queries. Always sanitize and validate. For the methods you've commented out (like WHERE and JOIN), you're absolutely correct about the risks of SQL injection. Never pass user input directly into such methods. Always use ActiveRecord's built-in methods to prevent injection.
ALLOWED_SORT_DIRECTIONS = ['asc', 'desc', 'newest', 'oldest']
def process_query_params(resource)
# FILTER
# (e.g. api.get('.../users?fields=id,name,email')) (inclusive)
# (e.g. api.get('.../users?exclude=id,name,email')) (exclusive)
# (e.g. SELECT id, name, email FROM users)
# Used for selecting specific fields
# Common keys (inclusive): 'filter', 'fields', 'columns'
# Common keys (exclusive): 'exclude'
# Keys used: 'fields', 'exclude'
resource = filter_fields(resource)
# SORT
# (e.g. api.get('.../users?sort=role'))
# (e.g. SELECT * FROM users ORDER BY role)
# Used for sorting
# Common keys: 'sort', 'order', 'order_by', 'order_dir', 'sort_by', 'sort_dir', 'sort_field'
# Keys used: 'sort' (field), 'order' (direction)
resource = sort_resource(resource)
# LIMIT
# (e.g. api.get('.../users?limit=10'))
# (e.g. SELECT * FROM users LIMIT 10)
# Used for pagination
# Common keys: 'limit', 'per_page'
resource = resource.limit(params[:limit].to_i) if params[:limit].present?
# PAGE
# (e.g. api.get('.../users?page=2'))
# (e.g. SELECT * FROM users LIMIT 10 OFFSET 10)
# Used for pagination
# Common keys: 'page', 'offset'
# resource = resource.page(params[:page].to_i) if params[:page].present? # '.page()' = requires gem (e.g. kaminari, will_paginate) (1-based)
# resource = resource.offset(params[:page].to_i) if params[:page].present? # '.offset()' = native to Rails (0-based) (using 'page' key) ('page' is generally preferred... but probably don't want to use 'page' for key and also be 0-based)
resource = resource.offset(params[:offset].to_i) if params[:offset].present? # '.offset()' = native to Rails (0-based) (using 'offset' key)
## ADDITIONAL COMMON QUERY PARAMS (consider in future)
##################################
# WHERE
# (e.g. api.get('.../users?where=role=manager'))
# (e.g. SELECT * FROM users WHERE role = 'manager')
# Used for filtering
# Common keys: 'where', 'filter'
# IMPORTANT: this is not a very secure way to filter (e.g. SQL injection attacks) (always sanitize user input) (this may go for GROUP, HAVING, DISTINCT, etc. as well)
# resource = resource.where(params[:where]) if params[:where].present?
# GROUP
# (e.g. api.get('.../users?group=role'))
# (e.g. SELECT role, count(*) FROM users GROUP BY role)
# Used for aggregations (e.g. count, sum, average, etc.)
# Common keys: 'group', 'group_by'
# resource = resource.group(params[:group]) if params[:group].present?
# HAVING
# (e.g. api.get('.../users?having=role=manager'))
# (e.g. SELECT role, count(*) FROM users GROUP BY role HAVING role = 'manager')
# Used for aggregations (e.g. count, sum, average, etc.)
# Common keys: 'having'
# resource = resource.having(params[:having]) if params[:having].present?
# DISTINCT
# (e.g. api.get('.../users?distinct=true'))
# (e.g. SELECT DISTINCT role FROM users)
# Used for aggregations (e.g. count, sum, average, etc.)
# Common keys: 'distinct', 'uniq'
# resource = resource.distinct if params[:distinct].present?
# INCLUDE
# (e.g. api.get('.../users?include=projects'))
# (e.g. SELECT * FROM users LEFT OUTER JOIN projects ON projects.user_id = users.id)
# Used for eager loading (e.g. to avoid N+1 queries) (https://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations)
# Common keys: 'include', 'includes'
# resource = resource.includes(params[:include]) if params[:include].present?
# JOINS
# (e.g. api.get('.../users?joins=projects'))
# (e.g. SELECT * FROM users INNER JOIN projects ON projects.user_id = users.id)
# Used for eager loading (e.g. to avoid N+1 queries)
# Common keys: 'joins', 'join'
# resource = resource.joins(params[:joins]) if params[:joins].present?
# SELECT
# (e.g. api.get('.../users?select=id,name,email'))
# (e.g. SELECT id, name, email FROM users)
# Used for selecting specific fields
# Common keys: 'fields', 'columns'
# resource = resource.select(params[:select]) if params[:select].present?
resource
end
private
## MAIN METHODS
def filter_fields(resource)
if params[:fields].present? && params[:fields] != '*'# Example: api.get('.../users?fields=id,name,email')
selected_fields = params[:fields].split(',') # ['id', 'name', 'email']
# Ensure only valid columns are selected
selected_fields = selected_fields.select { |field| sort_field_exists?(field) }
resource = resource.select(selected_fields) unless selected_fields.empty? # SELECT id, name, email FROM users
elsif params[:exclude].present? # Example: api.get('.../users?exclude=id,name,email')
all_fields = resource.klass.column_names
exclude_fields = params[:exclude].split(',')
selected_fields = all_fields - exclude_fields
resource = resource.select(selected_fields) unless selected_fields.empty? # Example: SELECT created_at, updated_at FROM users
end
resource
end
def sort_resource(resource)
sort_field = params[:sort].present? ? determine_sort_field(params[:sort], resource) : nil
direction = params[:order].present? ? determine_direction(params[:order]) : nil
# Default behavior if neither is present
return resource unless sort_field || direction
# Default to 'created_at' if only direction is specified
sort_field ||= 'created_at'
# Default to 'desc' if only sort_field is specified
direction ||= 'desc'
resource.order(sort_field => direction)
end
## HELPER METHODS
def determine_sort_field(sort, resource)
if column_exists?(sort, resource)
sort
else
'created_at'
end
end
def column_exists?(field, resource)
resource.klass.column_names.include?(field)
end
def determine_direction(order)
return 'desc' unless ALLOWED_SORT_DIRECTIONS.include?(order) # default is 'desc' (newest)
case order
when 'asc', 'desc'
order
when 'newest'
'desc'
when 'oldest'
'asc'
else
'desc'
end
end
end
Last updated