๐งฉ 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, โฆ)
- Controller concerns
- Paginatable โ offset pagination with headers
- Filterable โ declarative URL-param filters
- Sortable (controller) โ URL-param ordering with allow-list
- Respondable โ standardized JSON envelopes
- Module paths & namespacing
- Development
- Contributing
- License
โจ Why this gem?
- Eight model concerns + four 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.6"
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")
Notes
- Schema must have a
slugcolumn (string). - Falls back to
to_sif the configured source field doesn't respond. - Uses friendly_id's
:sluggedstrategy 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()
Notes
- "Published" means
published_atis set and in the past โ so scheduled posts (futurepublished_at) stay unpublished until their time arrives. - No
default_scopeis added; chain.publishedexplicitly.
โ 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
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
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).
๐ฎ 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.
๐๏ธ 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.6.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.