๐งฉ ConcernsOnRails
๐ป๐ณ Hoร ng Sa and Trฦฐแปng Sa belong to Viแปt Nam.
A plug-and-play collection of reusable ActiveSupport concerns for Rails models and controllers โ slugs, soft delete, scheduled publish, expiry, pagination, filtering, JSON envelopes, and more. One include, one declarative macro, done.
class Article < ApplicationRecord
include ConcernsOnRails::Sluggable
include ConcernsOnRails::Publishable
include ConcernsOnRails::SoftDeletable
sluggable_by :title
end
Article.published.without_deleted.find("hello-world")
๐ Table of Contents
- Why this gem?
- Installation
- Compatibility
- Quick Start
- Model concerns
- Sluggable โ URL-friendly slugs
- Sortable โ list ordering via
acts_as_list - Publishable โ
published_attimestamp publishing - SoftDeletable โ soft delete with scopes & hooks
- Hashable โ auto-generate tokens / UUIDs / codes
- Schedulable โ
starts_at/ends_attime windows - Expirable โ single-timestamp expiry
- Normalizable โ attribute normalization (
:email,:phone, โฆ) - Searchable โ LIKE/ILIKE search across configured columns
- Activatable โ boolean active/inactive toggle
- Tokenizable โ security tokens with timing-safe lookup
- Stateable โ lightweight string-backed state machine
- Controller concerns
- Paginatable โ offset pagination with headers
- Filterable โ declarative URL-param filters
- Sortable (controller) โ URL-param ordering with allow-list
- Respondable โ standardized JSON envelopes
- ErrorHandleable โ JSON
rescue_fromhandlers for common controller errors - Includable โ whitelisted association sideloading + sparse fieldsets
- Module paths & namespacing
- Development
- Contributing
- License
โจ Why this gem?
- Twelve model concerns + six controller concerns, all production-ready
- One include, one macro โ no boilerplate, no glue code
- Lean dependencies โ only
acts_as_list(Sortable) andfriendly_id(Sluggable); controller concerns have zero extra deps - Schema-validated configuration โ every macro checks that the configured column exists and raises
ArgumentErrorearly - Composable โ concerns are independent; mix and match per model
๐ฆ Installation
Add to your application's Gemfile:
gem "concerns_on_rails", "~> 1.9"
Or pull the latest from GitHub:
gem "concerns_on_rails", github: "VSN2015/concerns_on_rails"
Then run:
bundle install
๐งช Compatibility
- Ruby: 3.2+
- Rails: 5.0 through 8.x
๐ Quick Start
# A model that's sluggable, publishable, and soft-deletable
class Article < ApplicationRecord
include ConcernsOnRails::Sluggable
include ConcernsOnRails::Publishable
include ConcernsOnRails::SoftDeletable
include ConcernsOnRails::Normalizable
sluggable_by :title
normalizable :title, with: :squish
end
# A controller that paginates, filters, sorts, and renders JSON envelopes
class ArticlesController < ApplicationController
include ConcernsOnRails::Controllers::Paginatable
include ConcernsOnRails::Controllers::Filterable
include ConcernsOnRails::Controllers::Sortable
include ConcernsOnRails::Controllers::Respondable
filter_by :published, scope: :published
sortable_by :created_at, :title, default: :created_at, direction: :desc
def index
render_success(data: paginated(sorted(filtered(Article.all))))
end
end
That's it. The sections below document each concern individually.
๐งฑ Model Concerns
All model concerns are independent โ include only what you need.
๐ Sluggable
URL-friendly slugs via friendly_id โ auto-updates when the source attribute changes.
class Post < ApplicationRecord
include ConcernsOnRails::Sluggable
sluggable_by :title
end
post = Post.create!(title: "Hello World")
post.slug # => "hello-world"
post.update!(title: "Hello, World!")
post.slug # => "hello-world" (regenerates on title change)
Post.friendly.find("hello-world")
Options
# Keep old slugs resolvable after a title change (needs a friendly_id_slugs migration)
sluggable_by :title, history: true
Post.friendly.find("old-slug") # still resolves to the renamed post
# Unique slug only within a scope column (same slug allowed in different accounts)
sluggable_by :title, scope: :account_id
Notes
- Schema must have a
slugcolumn (string). history: truerequires afriendly_id_slugstable โ generate withrails generate friendly_idor add a manual migration.scope: :colrequirescolto exist in the same table.- Falls back to
to_sif the configured source field doesn't respond. - Uses friendly_id's
:slugged(+ optionally:history,:scoped) strategies under the hood.
๐ข Sortable
List ordering via acts_as_list โ adds a default_scope ordering plus move_higher, move_lower, move_to_top, move_to_bottom, and automatic position reordering on destroy.
class Task < ApplicationRecord
include ConcernsOnRails::Sortable
sortable_by :position # default โ ascending
end
Task.create!(name: "A")
Task.create!(name: "B")
Task.last.move_higher
Configuration
sortable_by :priority # ascending priority
sortable_by priority: :desc # descending priority
sortable_by :position, use_acts_as_list: false # just the default_scope ordering, no acts_as_list
Notes
- The configured field must exist as a column.
- Direction values other than
:asc/:descsilently fall back to:asc.
๐ค Publishable
Manage published / unpublished records via a published_at timestamp.
class Article < ApplicationRecord
include ConcernsOnRails::Publishable
publishable_by :published_at # default โ call is optional
end
article = Article.create!(title: "Draft")
article.published? # => false
article.publish!
article.published? # => true
article.unpublish!
Article.published # WHERE published_at <= NOW()
Article.unpublished # WHERE published_at IS NULL OR published_at > NOW()
Article.scheduled # WHERE published_at > NOW() (future-dated โ not live yet)
Article.draft # WHERE published_at IS NULL (no date set at all)
Scheduling
article.publish_at!(1.week.from_now) # sets published_at to a future time
article.scheduled? # => true (not live yet)
article.draft? # => false
Opt-in default scope
publishable_by :published_at, default_scope: true
# Article.all now returns only published records automatically
# Article.unscoped reaches everything
Notes
- "Published" means
published_atis set and in the past โ so future-dated posts stay unpublished until their time arrives. - No
default_scopeis added by default; chain.publishedexplicitly (or opt in withdefault_scope: true).
โ SoftDeletable
Soft delete records using a timestamp field (default: deleted_at). Includes a default_scope that hides deleted records and overrides destroy_all to soft-delete in bulk.
class User < ApplicationRecord
include ConcernsOnRails::SoftDeletable
soft_deletable_by :deleted_at, touch: true # both args optional
end
user = User.create!(email: "alice@example.com")
user.soft_delete!
user.deleted? # => true
user.restore!
user.deleted? # => false
user.really_delete! # bypasses callbacks, hard deletes from DB
Scopes
User.active # alias of .without_deleted โ non-deleted records
User.without_deleted # same
User.soft_deleted # only deleted records (timestamp set)
User.only_deleted # alias for .soft_deleted
User.with_deleted # all records โ deleted + non-deleted
User.deleted_within(7.days) # soft-deleted within the last 7 days
User.all # default scope: non-deleted only
User.unscoped # everything (deleted + non-deleted)
Bulk operations
User.destroy_all # soft-deletes all matching records
User.really_destroy_all # hard-deletes all matching records
User.restore_all # restores all soft-deleted records
Lifecycle hooks โ override these methods on the model:
class User < ApplicationRecord
include ConcernsOnRails::SoftDeletable
def before_soft_delete; end
def after_soft_delete; end
def before_restore; end
def after_restore; end
end
Aliases: soft_deleted? and is_soft_deleted? both delegate to deleted?.
๐ Hashable
Auto-generate random values on create โ tokens, codes, UUIDs, or anything from a custom alphabet.
class Order < ApplicationRecord
include ConcernsOnRails::Hashable
hashable_by :token # default: type: :hex, length: 16 โ 32-char hex string
end
order = Order.create!
order.token # => "a3f7c9b1e2d40859e2f1c9b73d40a857"
order.regenerate_token! # rolls a new value and persists it
Generators
| Type | length means |
Example |
|---|---|---|
:hex |
byte count (output is length * 2 chars) |
"a3f7c9b1e2d40859" |
:uuid |
ignored | "550e8400-e29b-41d4-a716-446655440000" |
:integer |
digit count | 483921 |
:custom |
output length, samples from alphabet: |
"K7M3PQ9A" |
hashable_by :token, type: :hex, length: 16
hashable_by :external_id, type: :uuid
hashable_by :code, type: :integer, length: 6
hashable_by :code, type: :custom, length: 8,
alphabet: "ABCDEFGHJKMNPQRSTUVWXYZ23456789" # Crockford-style, no ambiguous chars
Notes
- Auto-assigns in
before_createonly when the field is blank โ callers can pass an explicit value. - A
regenerate_<field>!instance method is defined dynamically. - For fixed-width numeric codes (e.g.
000042), use a string column โ integer columns drop leading zeros. - No uniqueness retry is built in. For collision-prone configs (short integer codes), add a unique index and rescue at the app level.
- If your model has
validates :<field>, presence: true, switch this concern's hook tobefore_validationin your model โ it usesbefore_createby default.
๐๏ธ Schedulable
Records with a starts_at / ends_at time window โ promotions, events, feature flags.
class Promotion < ApplicationRecord
include ConcernsOnRails::Schedulable
schedulable_by # defaults: starts_at: :starts_at, ends_at: :ends_at
end
promo = Promotion.create!(starts_at: 1.hour.ago, ends_at: 1.day.from_now)
promo.current? # => true
promo.upcoming? # => false
promo.expired? # => false
Promotion.current # WHERE starts_at <= NOW AND (ends_at IS NULL OR ends_at > NOW)
Promotion.upcoming # WHERE starts_at > NOW
Promotion.expired # WHERE ends_at <= NOW
Promotion.active_at(time) # active at an arbitrary time
Configuration
# Custom column names
schedulable_by starts_at: :starts_on, ends_at: :ends_on
# Open-ended start (only an end / expiry)
schedulable_by starts_at: nil, ends_at: :expires_at
Mutators
promo.start! # sets starts_at = now
promo.finish! # sets ends_at = now
promo.reschedule!(starts_at: 1.day.from_now,
ends_at: 2.days.from_now)
Notes
- Boundary semantics: inclusive start, exclusive end โ active at exactly
starts_at, not at exactlyends_at. - A
nilend means "never expires"; anilstart means "not yet started". - No
default_scope; chain.currentexplicitly.
โณ Expirable
Single-timestamp expiry โ tokens, sessions, password-reset links, invitations.
class ApiToken < ApplicationRecord
include ConcernsOnRails::Expirable
expirable_by # default field: :expires_at
end
token = ApiToken.create!(expires_at: 1.hour.from_now)
token.active? # => true
token.expired? # => false
token.time_until_expiry # => ActiveSupport::Duration (~1.hour)
ApiToken.active # nil expiry OR future expiry
ApiToken.expired # past expiry
ApiToken.expiring_within(1.day) # future expiry within the next 1 day
Mutators
token.expire! # expires_at = now
token.expire!(2.hours.from_now) # explicit time
token.extend_expiry!(by: 1.day) # pushes expiry forward
extend_expiry! is smart about the base:
- If
expires_atisnilor in the past โ new value isnow + by - If
expires_atis still in the future โbyis added to the existing value
Custom field name
expirable_by :valid_until
Expirable vs. Schedulable: Expirable is the ergonomic choice when you just care about expiry; Schedulable adds a start time. They overlap (schedulable_by starts_at: nil, ends_at: :expires_at is similar) โ pick whichever reads better in your domain.
โจ Normalizable
Auto-normalize attribute values in before_validation โ strip whitespace, downcase emails, dedupe spaces, run any custom transform.
class User < ApplicationRecord
include ConcernsOnRails::Normalizable
normalizable :email, with: :email # strip + downcase
normalizable :phone, with: :phone # digits only
normalizable :first_name, :last_name, with: :whitespace # strip โ same rule, multiple fields
normalizable :slug, with: ->(v) { v.to_s.parameterize } # custom lambda
end
User.create(email: " ALICE@Example.com ").email # => "alice@example.com"
User.create(phone: "+1 (415) 555-1234").phone # => "14155551234"
Built-in presets
| Preset | Transform |
|---|---|
:email |
strip + downcase |
:phone |
digits only (gsub(/\D/, "")) |
:whitespace |
strip |
:squish |
squish (collapse inner whitespace) |
:downcase |
downcase |
:upcase |
upcase |
Notes
- Runs in
before_validation, so DB constraints and AR validations see the normalized value. nilvalues are skipped โ nonil โ ""coercion.- Preset normalizers pass non-string values through unchanged.
- Works on Rails 5+ (no dependency on Rails 7.1's built-in
normalizes).
๐ Searchable
LIKE-based search across one or more columns โ no external search engine, no extra gems.
class Article < ApplicationRecord
include ConcernsOnRails::Searchable
searchable_by :title, :body
end
Article.search("hello") # WHERE title ILIKE '%hello%' OR body ILIKE '%hello%'
Article.search("") # no-op โ returns the full relation
Article.search("foo").where(state: 1) # chainable like any scope
Options
# mode: :any (default) โ any term in any field matches (OR)
# mode: :all โ every whitespace-separated term must match somewhere (AND per term, OR across fields)
searchable_by :title, :body, mode: :all
Article.search("ruby framework") # title OR body must contain "ruby" AND "framework"
# match: :contains (default) โ %term% (substring)
# match: :prefix โ term% (starts with)
# match: :exact โ term (full match)
searchable_by :sku, match: :prefix
Notes
- Uses Arel's
matches, which emitsILIKEon Postgres (case-insensitive) andLIKEelsewhere. - The query is escaped before interpolation โ
%,_, and\from user input are treated as literals, not wildcards. - Blank or nil queries return the relation unchanged, so it's safe to drop into a controller pipeline.
- Reach for
pg_search/ Elasticsearch when you need ranking, stemming, or full-text indexes.
โ Activatable
Boolean active/inactive toggle backed by a single column.
class Subscription < ApplicationRecord
include ConcernsOnRails::Activatable
activatable_by # defaults to :active
# activatable_by :enabled # custom column name
end
sub = Subscription.create!(active: true)
sub.active? # => true
sub.deactivate!
sub.inactive? # => true
sub.toggle_active! # flips back to true
Subscription.active # WHERE active = TRUE
Subscription.inactive # WHERE active = FALSE OR active IS NULL
Notes
NULLis treated as inactive (same convention as most apps' "unset = off").- The configured column must exist;
activatable_byraisesArgumentErrorotherwise. SoftDeletablealso defines a.activescope (alias of.without_deleted). If both concerns are included on the same model, the later one wins โ include the one whose.activesemantics you want last, or stick to one of them.
๐ Tokenizable
Generate and manage security tokens โ API keys, invite codes, share links, password-reset tokens. One model can declare any number of independently-configured token fields.
class User < ApplicationRecord
include ConcernsOnRails::Tokenizable
tokenizable_by :api_token # 32-char URL-safe
tokenizable_by :reset_password_token, length: 24
tokenizable_by :invite_code, type: :alphanumeric, length: 8
end
user = User.create! # all three tokens auto-generated
user.api_token # => "k3Jf...g2" (32 URL-safe chars)
user.api_token? # => true
user.regenerate_api_token! # rotates and persists
user.revoke_api_token! # nils the column
User.find_by_api_token(token) # Rails default
User.authenticate_by_api_token(token) # timing-safe; returns user or nil
Options
| Option | Default | Notes |
|---|---|---|
type: |
:urlsafe |
One of :urlsafe, :hex, :alphanumeric, :numeric |
length: |
32 |
Character length of the generated token |
Notes
- URL-safe by default (
AโZ,aโz,0โ9,-,_) โ drop straight into URLs and headers. - Caller-supplied values are respected:
User.create!(api_token: "preset")won't be overwritten. - Generation does a best-effort uniqueness check before insert and retries up to 10 times. Pair with a
uniqueDB index for real safety, especially for short alphanumeric/numeric codes. .authenticate_by_<field>usesActiveSupport::SecurityUtils.secure_compareto avoid leaking partial matches via response timing.- Distinct from
Hashable: Hashable handles a single random field; Tokenizable focuses on security tokens (multi-field, URL-safe default, timing-safe lookup, revocation).
๐ Stateable
Lightweight string-backed state machine โ the 80% of AASM without the dependency.
class Article < ApplicationRecord
include ConcernsOnRails::Stateable
stateable_by :status,
states: %i[draft pending published archived],
default: :draft,
transitions: {
publish: { from: %i[draft pending], to: :published },
archive: { to: :archived } # no :from โ allowed from any state
}
end
Generated API
article = Article.new # status defaults to "draft"
article.draft? # => true (predicate per state)
article.published? # => false
Article.draft # scope: WHERE status = 'draft'
Article.published # scope: WHERE status = 'published'
article.published! # direct setter โ updates regardless of current state
article.publish! # guarded transition โ raises InvalidTransition if not allowed
article.may_publish? # => true (guard check without raising)
article.transition_to!(:archived) # generic move to any declared state
Prefix / suffix โ avoid clashes when the state names overlap with other concerns or scopes:
stateable_by :state, states: %i[open closed], prefix: true
# generates: state_open?, state_closed?, state_open!, state_closed!, State.state_open, โฆ
Validation
- Raises
ArgumentErrorif the column, states, default, or transition config is invalid. - Raises
Stateable::InvalidTransitionat runtime for disallowed guarded transitions.
Notes
- String-column backed (not integer-backed like Rails enum) โ values are stored as-is.
- States like
active/expiredoverlap withActivatable/Expirablescopes โ useprefix:orsuffix:to disambiguate. - No persistence of transition history; combine with
Publishable/Schedulablefor time-based state tracking.
๐ฎ Controller Concerns
Pure ActionController + ActiveRecord โ zero extra runtime dependencies (no Kaminari, Pundit, or Ransack).
๐ Paginatable
Offset-based pagination with standard response headers โ no Kaminari needed.
class ArticlesController < ApplicationController
include ConcernsOnRails::Controllers::Paginatable
paginate_by per_page: 25, max_per_page: 200 # optional โ these are the defaults
def index
render json: paginated(Article.all)
end
end
URL params
| Param | Default | Notes |
|---|---|---|
?page= |
1 |
Page numbers below 1 are clamped to 1 |
?per_page= |
25 |
Capped at max_per_page (default 200) |
Response headers: X-Total-Count, X-Page, X-Per-Page, X-Total-Pages.
๐ Filterable
Declarative URL-param filtering with three modes per filter.
class ArticlesController < ApplicationController
include ConcernsOnRails::Controllers::Filterable
filter_by :status, :category # ?status=draft โ .where(status: 'draft')
filter_by :published, scope: :published # ?published=1 โ Article.published
filter_by :q, with: ->(rel, v) { rel.where("title ILIKE ?", "%#{v}%") }
def index
render json: filtered(Article.all)
end
end
Modes
| Mode | Declaration | What it does |
|---|---|---|
| Direct | filter_by :status |
relation.where(status: params[:status]) |
| Scope | filter_by :published, scope: :published |
relation.published (when param is present) |
| Custom | filter_by :q, with: ->(rel, v) { ... } |
Calls your lambda with (relation, value) |
Notes
- Blank params are skipped โ unset filters don't narrow the relation.
- Passing both
:scopeand:withraisesArgumentError. - Scope mode pairs naturally with
Publishable.published,SoftDeletable.active,Expirable.active, etc.
โ๏ธ Sortable (controller)
URL-param-driven ordering with a strict allow-list โ never orders by an arbitrary user-supplied column.
class ArticlesController < ApplicationController
include ConcernsOnRails::Controllers::Sortable
sortable_by :created_at, :title, :published_at,
default: :created_at, direction: :desc
def index
render json: sorted(Article.all)
end
end
URL params: ?sort=title&direction=asc
params[:sort]selects the column; non-whitelisted values fall back to the declared default.params[:direction]acceptsasc/desc(case-insensitive); invalid values fall back to the declared default direction.- If no
default:is given, the first declared field is used.
Distinct from
Models::Sortable(which manages list position viaacts_as_list). Both can coexist on a model + its controller.
๐ฆ Respondable
Standardized JSON envelopes for API controllers โ two methods, zero state.
class Api::ArticlesController < ApplicationController
include ConcernsOnRails::Controllers::Respondable
def show
article = Article.find_by(id: params[:id])
return render_error(message: "Not found", status: :not_found) unless article
render_success(data: article)
end
def create
article = Article.new(article_params)
if article.save
render_success(data: article, status: :created)
else
render_error(message: "Invalid", errors: article.errors.)
end
end
end
Response shapes
// Success
{ "success": true, "data": {...}, "meta": {...} /* meta omitted when empty */ }
// Error
{ "success": false, "error": { "message": "...", "code": "...", "details": [...] } }
API
| Method | Signature |
|---|---|
render_success |
render_success(data: nil, status: :ok, meta: {}) |
render_error |
render_error(message:, status: :unprocessable_entity, code: nil, errors: nil) |
data:is a keyword arg (not positional) on purpose โ it sidesteps Ruby 3's behavior of treating hash literals as kwargs when a method declares any keyword params.
๐ ErrorHandleable
Install rescue_from handlers for the three most common controller exceptions and render them as the same JSON envelope used by Respondable.
class Api::BaseController < ApplicationController
include ConcernsOnRails::Controllers::Respondable # recommended
include ConcernsOnRails::Controllers::ErrorHandleable
end
Handled exceptions
| Exception | Status | code |
|---|---|---|
ActiveRecord::RecordNotFound |
404 | "not_found" |
ActionController::ParameterMissing |
400 | "parameter_missing" |
ActiveRecord::RecordInvalid |
422 | "record_invalid" |
Response shape (matches Respondable#render_error):
{ "success": false, "error": { "message": "...", "code": "...", "details": [...] } }
Overriding a handler
Each handler is a public instance method, so subclasses can customize the message or response shape without re-declaring the rescue_from:
class Api::BaseController < ApplicationController
include ConcernsOnRails::Controllers::ErrorHandleable
def handle_record_not_found(error)
render json: { success: false, error: { message: "Not here, friend." } }, status: :not_found
end
end
Notes
- When
Respondableis also included, the handlers delegate torender_errorso the envelope shape stays in one place. Otherwise they render the same envelope inline. RecordInvalid.detailsare populated fromerror.record.errors.full_messages.
๐ Includable
Whitelisted association sideloading + sparse fieldsets for JSON APIs โ zero arbitrary .includes from user input.
class ArticlesController < ApplicationController
include ConcernsOnRails::Controllers::Includable
includable :author, :comments,
fields: { articles: %i[id title published_at], authors: %i[id name] }
def index
render json: with_includes(Article.all),
include: requested_includes,
fields: requested_fields
end
end
URL params
GET /articles?include=author,comments&fields[articles]=id,title&fields[authors]=id,name
API
| Method | What it does |
|---|---|
with_includes(rel) |
Parses params[:include], intersects with the allow-list, calls relation.includes(...) |
requested_includes |
Returns the sanitized [:author, :comments] array (pass to render json:, include:) |
requested_fields |
Returns { articles: [:id, :title] } sanitized map (pass to your serializer) |
Notes
- Non-whitelisted associations are silently dropped โ no error, no arbitrary eager-loading.
- Non-whitelisted tables in
params[:fields]are dropped; non-whitelisted columns within an allowed table are dropped. - Pass
requested_fieldsto your serializer (e.g. AMS / Blueprinter) โIncludableitself does not alter the JSON output, only the query.
๐๏ธ Module paths & namespacing
Every concern is available under two paths:
# Short form (recommended for brevity):
include ConcernsOnRails::Sluggable
include ConcernsOnRails::Normalizable
# Fully-qualified form:
include ConcernsOnRails::Models::Sluggable
include ConcernsOnRails::Models::Normalizable
Controller concerns live under ConcernsOnRails::Controllers::* (no short form, to disambiguate from Models::Sortable):
include ConcernsOnRails::Controllers::Paginatable
include ConcernsOnRails::Controllers::Sortable
Both forms reference the same module, so you can freely mix them.
๐ ๏ธ Development
bundle install # install dev dependencies
bundle exec rspec # run the test suite
gem build concerns_on_rails.gemspec # build the gem
gem install ./concerns_on_rails-1.9.0.gem # install locally
The test suite uses an in-memory SQLite database and a lightweight FakeController harness for controller-concern specs โ no Rails routes or boot required.
๐ค Contributing
Bug reports and pull requests are welcome at github.com/VSN2015/concerns_on_rails. โญ๏ธ stars and ๐ด forks appreciated.
๐ License
MIT โ see LICENSE.
๐ป๐ณ Hoร ng Sa and Trฦฐแปng Sa belong to Viแปt Nam.