Skip to content

Instantly share code, notes, and snippets.

@OrestF
Last active February 15, 2025 07:26
Show Gist options
  • Save OrestF/2a9862d11e2cbec89e30aefecb777b14 to your computer and use it in GitHub Desktop.
Save OrestF/2a9862d11e2cbec89e30aefecb777b14 to your computer and use it in GitHub Desktop.
Scopable.rb + Sortable.rb
# frozen_string_literal: true
module Scopable
extend ActiveSupport::Concern
GTE_LTE_COLUMN_TYPES = %i[integer float decimal datetime date time].freeze
SEARCHABLE_COLUMN_TYPES = %i[string text integer float decimal datetime date time].freeze
MATCHABLE_COLUMN_TYPES = %i[string text].freeze
SCOPABLE_COLUMN_TYPES = GTE_LTE_COLUMN_TYPES + SEARCHABLE_COLUMN_TYPES + MATCHABLE_COLUMN_TYPES
EXCLUDED_ASSOCIATION_SCOPES_CLASSES = %w[ActiveStorage::Attachment ActiveStorage::Blob].freeze
included do
scope :gt, ->(column, value) { where(arel_table[column].gt(value)) }
scope :lt, ->(column, value) { where(arel_table[column].lt(value)) }
scope :gte, ->(column, value) { where(arel_table[column].gteq(value)) }
scope :lte, ->(column, value) { where(arel_table[column].lteq(value)) }
scope :eq_in, ->(column, values) { where(arel_table[column].in(Array.wrap(values))) }
scope :match, ->(column_name, query) { where(arel_table[column_name].matches("%#{query}%")) }
end
class_methods do
def load_association_scopes
yield(self) if block_given?
define_association_scopes
end
def define_filterable_scopes(column_names = nil, except: [])
yield(self) if block_given?
define_gte_lte_scopes(column_names || column_names_for_gte_lte_scopes, except: except)
define_searchable_scopes(column_names || column_names_for_searchable_scopes, except: except)
define_matchable_scopes(column_names || column_names_for_matchable_scopes, except: except)
define_association_scopes(column_names || relation_names_for_association_scopes, except: except)
end
# rubocop:disable Metrics/AbcSize
def define_gte_lte_scopes(column_names = column_names_for_gte_lte_scopes, except: [])
column_names -= Array.wrap(except).map(&:to_s)
Array.wrap(column_names).each do |column_name|
scope "by_#{column_name}_gte", ->(value) { gte(column_name, value) }
scope "by_#{column_name}_lte", ->(value) { lte(column_name, value) }
scope "by_#{column_name}_gt", ->(value) { gt(column_name, value) }
scope "by_#{column_name}_lt", ->(value) { lt(column_name, value) }
end
end
# rubocop:enable Metrics/AbcSize
def define_searchable_scopes(column_names = column_names_for_searchable_scopes, except: [])
column_names -= Array.wrap(except).map(&:to_s)
Array.wrap(column_names).each do |column_name|
scope "by_#{column_name}", ->(value) { where(column_name => value) }
end
end
def define_matchable_scopes(column_names = column_names_for_matchable_scopes, except: [])
column_names -= Array.wrap(except).map(&:to_s)
Array.wrap(column_names).each do |column_name|
scope "by_#{column_name}_match", ->(query) { match(column_name, query) }
end
end
def define_association_scopes(association_names = relation_names_for_association_scopes, except: [])
assoc_to_define = associations_for_scopes.select do |afs|
afs.name.in?(association_names.map(&:to_sym)) && !afs.name.in?(except.map(&:to_sym))
end
assoc_to_define.each do |association|
scope "by_#{association.name.to_s.singularize}", lambda { |value|
fltrs = [{ association.name.to_s => { association.association_primary_key => { 'is' => value } } }]
ActiveRecord::PredicateBuilder.filter_joins(klass, fltrs).flatten.reduce(self) do |_acc, j|
if j.is_a?(String) || j.is_a?(Arel::Nodes::Join)
joins(j)
elsif j.present?
left_outer_joins(j)
else
self
end
end.where(association.name.to_s => { association.association_primary_key => value })
}
end
end
def columns_types_for_gte_lte_scopes
columns_types.select { |column, type| GTE_LTE_COLUMN_TYPES.include?(type) && !reflection_column?(column) }
end
def columns_types_searchable_scopes
columns_types.select { |column, type| SEARCHABLE_COLUMN_TYPES.include?(type) && !reflection_column?(column) }
end
def columns_types_for_matchable_scopes
columns_types.select { |column, type| MATCHABLE_COLUMN_TYPES.include?(type) && !reflection_column?(column) }
end
def associations_for_scopes
reflect_on_all_associations.reject { |a| a.polymorphic? || a.class_name.in?(EXCLUDED_ASSOCIATION_SCOPES_CLASSES) }
end
def column_names_for_basic_scopes = columns_types_for_basic_scopes.keys
def column_names_for_gte_lte_scopes = columns_types_for_gte_lte_scopes.keys
def column_names_for_searchable_scopes = columns_types_searchable_scopes.keys
def column_names_for_matchable_scopes = columns_types_for_matchable_scopes.keys
def relation_names_for_association_scopes
associations_for_scopes.map(&:name)
end
def columns_types_for_basic_scopes
columns_types_for_gte_lte_scopes.merge(columns_types_searchable_scopes).merge(columns_types_for_matchable_scopes)
end
def reflection_column?(column)
reflect_on_all_associations.map(&:foreign_key).include?(column)
end
def columns_types
columns_hash.transform_values(&:type)
end
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
def basic_scopes_with_types
column_names_for_basic_scopes.each_with_object({}) do |(column, type), h|
h[:"by_#{column}_gte"] = type if defined_scopes.include?(:"by_#{column}_gte")
h[:"by_#{column}_lte"] = type if defined_scopes.include?(:"by_#{column}_lte")
h[:"by_#{column}_gt"] = type if defined_scopes.include?(:"by_#{column}_gt")
h[:"by_#{column}_lt"] = type if defined_scopes.include?(:"by_#{column}_lt")
h[:"by_#{column}"] = type if defined_scopes.include?(:"by_#{column}")
h[:"by_#{column}_match"] = type if defined_scopes.include?(:"by_#{column}_match")
end
end
def association_scopes_with_primary_keys
associations_for_scopes.each_with_object({}) do |association, h|
if defined_scopes.include?(:"by_#{association.name.to_s.singularize}")
h[:"by_#{association.name.to_s.singularize}"] = association.association_primary_key
end
end
end
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
def options_for_search
options = basic_scopes_with_types.merge!(association_scopes_with_primary_keys)
options.merge!(sortable_scopes_with_directions) if defined?(sortable_scopes_with_directions)
options
end
end
end

Usage

Define each feature in a model

  # data/user.rb
  include Scopable
  include Sortable
  
  define_filterable_scopes
  define_sortable_scopes
  
  load_association_scopes do
    belongs_to :operator_organization, optional: true
    has_one_attached :avatar
    # Driver associations
    has_many :work_shifts, foreign_key: :driver_id, dependent: :destroy
    has_many :scheduled_runs, foreign_key: :driver_id, dependent: :nullify
    # Rider associations
    has_many :tickets
    has_many :notifications, as: :recipient, dependent: :destroy, class_name: 'Noticed::Notification'
    has_many :notification_tokens, dependent: :destroy
  end

Get list of available generated scopes

User.options_for_search

# {
#  :by_id_gte=>nil,
#  :by_id_lte=>nil,
#  :by_id_gt=>nil,
#  :by_id_lt=>nil,
#  :by_id=>nil,
#  :by_id_match=>nil,
#  :by_reset_password_sent_at_gte=>nil,
#  :by_reset_password_sent_at_lte=>nil,
#  :by_reset_password_sent_at_gt=>nil,
#  :by_reset_password_sent_at_lt=>nil,
#  :by_reset_password_sent_at=>nil,
#  :by_reset_password_sent_at_match=>nil,
#  :by_remember_created_at_gte=>nil,
#  :by_remember_created_at_lte=>nil,
#  :by_remember_created_at_gt=>nil,
#  :by_remember_created_at_lt=>nil,
#  :by_remember_created_at=>nil,
#  :by_remember_created_at_match=>nil,
#  :by_created_at_gte=>nil,
#  :by_created_at_lte=>nil,
#  :by_created_at_gt=>nil,
#  :by_created_at_lt=>nil,
#  :by_created_at=>nil,
#  :by_created_at_match=>nil,
#  :by_updated_at_gte=>nil,
#  :by_updated_at_lte=>nil,
#  :by_updated_at_gt=>nil,
#  :by_updated_at_lt=>nil,
#  :by_updated_at=>nil,
#  :by_updated_at_match=>nil,
#  :by_invitation_created_at_gte=>nil,
#  :by_invitation_created_at_lte=>nil,
#  :by_invitation_created_at_gt=>nil,
#  :by_invitation_created_at_lt=>nil,
#  :by_invitation_created_at=>nil,
#  :by_invitation_created_at_match=>nil,
#  :by_invitation_sent_at_gte=>nil,
#  :by_invitation_sent_at_lte=>nil,
#  :by_invitation_sent_at_gt=>nil,
#  :by_invitation_sent_at_lt=>nil,
#  :by_invitation_sent_at=>nil,
#  :by_invitation_sent_at_match=>nil,
#  :by_invitation_accepted_at_gte=>nil,
#  :by_invitation_accepted_at_lte=>nil,
#  :by_invitation_accepted_at_gt=>nil,
#  :by_invitation_accepted_at_lt=>nil,
#  :by_invitation_accepted_at=>nil,
#  :by_invitation_accepted_at_match=>nil,
#  :by_invitation_limit_gte=>nil,
#  :by_invitation_limit_lte=>nil,
#  :by_invitation_limit_gt=>nil,
#  :by_invitation_limit_lt=>nil,
#  :by_invitation_limit=>nil,
#  :by_invitation_limit_match=>nil,
#  :by_invitations_count_gte=>nil,
#  :by_invitations_count_lte=>nil,
#  :by_invitations_count_gt=>nil,
#  :by_invitations_count_lt=>nil,
#  :by_invitations_count=>nil,
#  :by_invitations_count_match=>nil,
#  :by_failed_attempts_gte=>nil,
#  :by_failed_attempts_lte=>nil,
#  :by_failed_attempts_gt=>nil,
#  :by_failed_attempts_lt=>nil,
#  :by_failed_attempts=>nil,
#  :by_failed_attempts_match=>nil,
#  :by_locked_at_gte=>nil,
#  :by_locked_at_lte=>nil,
#  :by_locked_at_gt=>nil,
#  :by_locked_at_lt=>nil,
#  :by_locked_at=>nil,
#  :by_locked_at_match=>nil,
#  :by_role_gte=>nil,
#  :by_role_lte=>nil,
#  :by_role_gt=>nil,
#  :by_role_lt=>nil,
#  :by_role=>nil,
#  :by_role_match=>nil,
#  :by_confirmed_at_gte=>nil,
#  :by_confirmed_at_lte=>nil,
#  :by_confirmed_at_gt=>nil,
#  :by_confirmed_at_lt=>nil,
#  :by_confirmed_at=>nil,
#  :by_confirmed_at_match=>nil,
#  :by_confirmation_sent_at_gte=>nil,
#  :by_confirmation_sent_at_lte=>nil,
#  :by_confirmation_sent_at_gt=>nil,
#  :by_confirmation_sent_at_lt=>nil,
#  :by_confirmation_sent_at=>nil,
#  :by_confirmation_sent_at_match=>nil,
#  :by_email_gte=>nil,
#  :by_email_lte=>nil,
#  :by_email_gt=>nil,
#  :by_email_lt=>nil,
#  :by_email=>nil,
#  :by_email_match=>nil,
#  :by_invited_by_type_gte=>nil,
#  :by_invited_by_type_lte=>nil,
#  :by_invited_by_type_gt=>nil,
#  :by_invited_by_type_lt=>nil,
#  :by_invited_by_type=>nil,
#  :by_invited_by_type_match=>nil,
#  :by_unconfirmed_email_gte=>nil,
#  :by_unconfirmed_email_lte=>nil,
#  :by_unconfirmed_email_gt=>nil,
#  :by_unconfirmed_email_lt=>nil,
#  :by_unconfirmed_email=>nil,
#  :by_unconfirmed_email_match=>nil,
#  :by_full_name_gte=>nil,
#  :by_full_name_lte=>nil,
#  :by_full_name_gt=>nil,
#  :by_full_name_lt=>nil,
#  :by_full_name=>nil,
#  :by_full_name_match=>nil,
#  :by_phone_number_gte=>nil,
#  :by_phone_number_lte=>nil,
#  :by_phone_number_gt=>nil,
#  :by_phone_number_lt=>nil,
#  :by_phone_number=>nil,
#  :by_phone_number_match=>nil,
#
#  :by_operator_organization=>"id",
#  :by_work_shift=>"id",
#  :by_scheduled_run=>"id",
#  :by_ticket=>"id",
#  :by_notification=>"id",
#  :by_notification_token=>"id",
#
#  :sort_by_id=>["asc", "desc"],
#  :sort_by_email=>["asc", "desc"],
#  :sort_by_reset_password_sent_at=>["asc", "desc"],
#  :sort_by_remember_created_at=>["asc", "desc"],
#  :sort_by_created_at=>["asc", "desc"],
#  :sort_by_updated_at=>["asc", "desc"],
#  :sort_by_invitation_created_at=>["asc", "desc"],
#  :sort_by_invitation_sent_at=>["asc", "desc"],
#  :sort_by_invitation_accepted_at=>["asc", "desc"],
#  :sort_by_invitation_limit=>["asc", "desc"],
#  :sort_by_invited_by_type=>["asc", "desc"],
#  :sort_by_invitations_count=>["asc", "desc"],
#  :sort_by_failed_attempts=>["asc", "desc"],
#  :sort_by_locked_at=>["asc", "desc"],
#  :sort_by_role=>["asc", "desc"],
#  :sort_by_confirmed_at=>["asc", "desc"],
#  :sort_by_confirmation_sent_at=>["asc", "desc"],
#  :sort_by_unconfirmed_email=>["asc", "desc"],
#  :sort_by_full_name=>["asc", "desc"],
#  :sort_by_phone_number=>["asc", "desc"],
#  :sort_by_payment_provider_details=>["asc", "desc"]
# }

Usage in code

User.by_operator_organization(OperatorOrganization.first)
User.by_operator_organization(1)
User.sort_by_full_name
User.sort_by_full_name(:desc)

User.by_full_name('John Smith')
User.by_full_name_match('hn Sm')

User.by_locked_at_lte(2.days.ago)
User.by_failed_attempts_gte(3)

Use with Search

class Search::Users < BaseSearch
  FORM_OPTIONS = User.options_for_search.merge(
    by_free_text: '*',
    by_status: User::FILTERABLE_STATUSES
  ).freeze
  ...
# frozen_string_literal: true
module Sortable
extend ActiveSupport::Concern
included do
scope :sorted_by, lambda { |column_name, direction = 'asc'|
order(direction.to_s.downcase == 'asc' ? arel_table[column_name].asc : arel_table[column_name].desc)
}
end
class_methods do
def define_sortable_scopes(column_names = column_names_for_sortable_scopes, except: [])
column_names -= Array.wrap(except).map(&:to_s)
Array.wrap(column_names).each do |column_name|
scope "sort_by_#{column_name}", ->(direction = 'asc') { sorted_by(column_name, direction) }
end
end
def column_names_without_associations
column_names - reflect_on_all_associations.map(&:foreign_key)
end
def column_names_for_sortable_scopes
column_names_without_associations
end
def sortable_scopes_with_directions
column_names_for_sortable_scopes.each_with_object({}) do |column, h|
h[:"sort_by_#{column}"] = %w[asc desc] if defined_scopes.include?(:"sort_by_#{column}")
end
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment