Skip to content

Instantly share code, notes, and snippets.

@andynu
Created October 9, 2025 16:20
Show Gist options
  • Save andynu/378009567631982cc351b92fbba047b2 to your computer and use it in GitHub Desktop.
Save andynu/378009567631982cc351b92fbba047b2 to your computer and use it in GitHub Desktop.
example concerns
require 'active_support/concern'
# The {FiltersConcern} provides a <tt>filter_params</tt> scope which takes a hash <tt>{ col1: term, col2: term }</tt>
#
# see app/assets/javascripts/filterable.js
#
# == type sensitive filtering
# * strings - fuzzy search of %term%
# * dates -
# * July - => month
# * July 2025 => month/year
# * #### => year,
# * ##/#### => month/year,
# * ##/##/#### => full date
# * other - exact term matching
#
# == composite columns/custom
# For composite columns with filter params like { "col_a,col_b" => term } it will attempt to call a
# custom scope.
#
# scope :filter_col_a_col_b, (term) -> { ... }
#
# Generally <tt>param[filters][custom]</tt> => <tt>scope :filter_custom</tt>
#
# With custom getting scrubbed of punctuation with underscores and squeezed.
#
# == Usage
#
# === Model
#
# class Whatever < ApplicationRecord
# include FiltersConcern
#
# === Form Template
#
# = form_for ... do |f|
# = text_field_tag 'filters[col1]'
# = text_field_tag 'filters[col2]'
# ...
#
# === Controller
#
# def index
# @records = Whatever.all
# @records = @records.filter_params(params[:filters])
# ...
#
# == Generators
#
# This is built into our scaffold generator. see lib/templates
#
module FiltersConcern
extend ActiveSupport::Concern
included do
# filters is a hash of { column => search_value, ... }
# Strings fuzzy search. Everything else is an exact match.
scope :filter_params, ->(filters, table_prefix: nil) {
table_prefix = "#{table_prefix}." unless table_prefix.nil?
query = all
if filters.present?
filters.each_pair do |column, value|
if value.blank?
logger.debug "Filtering: #{column} => ALL (value='#{value}')"
next
end
custom_filter_scope = :"filter_#{column.gsub(/[^[:alnum:]]/, '_').squeeze('_')}"
if self.respond_to? custom_filter_scope
logger.debug("Filtering: using custom scope #{custom_filter_scope}('#{value}')")
query = query.send(custom_filter_scope, value)
elsif column.in?(attribute_names)
col_type = query.type_for_attribute(column).type
logger.debug "Filtering: #{column}=#{value} (type: #{col_type})"
case col_type
when :string
query = query.where("#{table_prefix}#{column} like ?", "%#{value}%")
when :date, :datetime
year_i = nil
month_i = nil
day_i = nil
if value =~ /^(January|February|March|April|May|June|July|August|September|October|November|December)(\s+\d{4})?$/i
logger.debug 'FiltersConcern month word detected'
month_capture = value.match(/^(January|February|March|April|May|June|July|August|September|October|November|December)/i)[0]
month_i = Chronic.parse(month_capture+" 1st 1970").month
year_i = value.match(/\d{4}\b/)&.try(:"[]", 0)&.to_i
elsif value =~ /^\d{4}$/
logger.debug 'FiltersConcern year filter'
year_i = value.to_i
elsif value =~ /^\d{1,2}\/\d{4}$/
logger.debug 'FiltersConcern month/year filter'
month_i, year_i = value.split('/').map(&:to_i)
elsif value =~ /^\d{1,2}\/\d{1,2}\/\d{2,4}$/
logger.debug 'FiltersConcern month/day/year filter'
month_i, day_i, year_i = value.split('/').map(&:to_i)
elsif value =~ /^\d{1,2}$/ && value.to_i <= 12
logger.debug 'FiltersConcern month filter'
month_i = value.to_i
else
logger.debug "Unknown date format: '#{value}'"
end
if year_i || month_i || day_i
if defined?(Mysql2)
query = query.where("year(#{table_prefix}#{column}) = ?", year_i) if year_i.present?
query = query.where("month(#{table_prefix}#{column}) = ?", month_i) if month_i.present?
query = query.where("day(#{table_prefix}#{column}) = ?", day_i) if day_i.present?
elsif defined?(PG)
query = query.where("extract(year from #{table_prefix}#{column}) = ?", year_i) if year_i.present?
query = query.where("extract(month from #{table_prefix}#{column}) = ?", month_i) if month_i.present?
query = query.where("extract(day from #{table_prefix}#{column}) = ?", day_i) if day_i.present?
else
logger.warn 'FiltersConcern filter_params on date column with unsupported database type'
end
end
else
query = query.where(column => value)
end
logger.debug "FiltersConcern query: #{query.to_sql}"
else
logger.warn "Could not filter composite column #{column}. To handle this add a custom scope :#{custom_filter_scope}, ->(term){ ... }"
end
end
else
logger.debug 'no filters'
query
end
#logger.warn query.inspect
query
}
end
end
require 'active_support/concern'
# This version of SortableConcern requires at least v1.1.0 of sortable.js
#
# Provides a <tt>sortable</tt> scope that takes the params object.
#
# Dependency:
# * wi_helper gem (provides query_params_url)
#
# Expects params:
# * sortby - must be one of the attribute_names of the ActiveRecord class.
# * sortord - 'asc' || 'desc'
# * sortrel - may specify a valid relation for the ActiveRecord class to use for the sortby attribute
#
# == Usage
# === Model
# class Whatever < ApplicationRecord
# include SortableConcern
#
# === Controller
# def index
# @records = Whatever.all
# @records = @records.sortable(params)
#
# === View
#
# Works in conjunction with app/assets/javascripts/sortable.js
#
# %table.ui.table.sortable{ data: { sorturl: query_params_url(sortby: nil, sortdir: nil, sortrel: nil, page: 1) } }
# %thead
# %th{ sortby: 'col1' } Col1
# %th{ sortby: 'col2' } Col2
# %th{ sortby: 'col3', sortrel: 'related' } Col3
#
# sortable.js accepts clicks on the titles reloading using <tt>sorturl</tt> and the column's <tt>sortby</tt> alternating between sortord asc & desc.
# If sortrel is specified and valid, its table name will be added to the query via .joins() and used for ordering.
#
# The use of <tt>query_params_url</tt> preserves other query params, so this works along with the {FiltersConcern}
#
# == Generators
#
# This is built into our scaffold generator. see lib/templates
module SortableConcern
extend ActiveSupport::Concern
included do
scope :sortable, ->(params) {
sortby, sortdir, sortrel = params.values_at(:sortby, :sortdir, :sortrel)
sortdir = sortdir == 'desc' ? 'desc' : 'asc'
if sortrel.blank? && sortby.in?(attribute_names)
logger.info " sortable: #{self.klass.table_name}.#{sortby} #{sortdir}"
reorder(Arel.sql("#{self.klass.table_name}.#{sortby} #{sortdir}"))
elsif self.klass.reflections.key?(sortrel) && sortby.in?(self.klass.reflections[sortrel].klass.attribute_names)
joins(sortrel.to_sym).reorder(Arel.sql("#{self.klass.reflections[sortrel].table_name}.#{sortby} #{sortdir}"))
end
}
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment