๐งฉ ConcernsOnRails
Plug-and-play ActiveSupport concerns for Rails models & controllers.
One include, one declarative macro โ done.
๐ Documentation ย ยทย ๐ RubyGems ย ยทย ๐ Changelog ย ยทย ๐ Issues
๐งฉ 26 model concerns ย ยทย ๐ฎ 16 controller concerns ย ยทย ๐ชถ lean deps ย ยทย โ schema-validated ย ยทย ๐งช 2,723 specs
๐ป๐ณ 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, sequential reference numbers, 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")๐ Prefer browsable docs? Every concern has its own searchable, deep-linkable page (dark mode included) at vsn2015.github.io/concerns_on_rails โ same content as this README, one page per concern. For install stats, every released version, and the live download counter, see the gem page: rubygems.org/gems/concerns_on_rails.
๐ Table of Contents
Find your concern ย ยทย Why this gem? ย ยทย Installation ย ยทย Compatibility ย ยทย Quick Start ย ยทย Module paths ย ยทย AI assistants ย ยทย Development ย ยทย Contributing ย ยทย License
Every concern below links to its section in this README and to its standalone page on the docs site (๐).
๐งฑ Model concerns
| Concern | What it does | Docs |
|---|---|---|
| ๐ Sluggable | URL-friendly slugs | ๐ |
| ๐ข Sortable | List ordering via acts_as_list
|
๐ |
| ๐ค Publishable |
published_at timestamp publishing |
๐ |
| โ SoftDeletable | Soft delete with scopes & hooks | ๐ |
| ๐ Hashable | Auto-generate tokens / UUIDs / codes | ๐ |
| ๐๏ธ Schedulable |
starts_at / ends_at time windows |
๐ |
| โณ Expirable | Single-timestamp expiry | ๐ |
| โจ Normalizable | Attribute normalization (:email, :phone, โฆ) |
๐ |
| ๐ Searchable | LIKE / ILIKE search across columns | ๐ |
| โ Activatable | Boolean active / inactive toggle | ๐ |
| ๐ Tokenizable | Security tokens with timing-safe lookup | ๐ |
| ๐งพ Sequenceable | Ordered, human-friendly reference numbers | ๐ |
| ๐ Stateable | Lightweight string-backed state machine | ๐ |
| ๐ Addressable | Postal address normalization + validation | ๐ |
| ๐ท๏ธ Taggable | Lightweight tagging over a single column | ๐ |
| ๐งผ Sanitizable | Opt-in HTML sanitization (XSS defense) | ๐ |
| ๐ Maskable | Non-destructive display masking | ๐ |
| ๐ฐ Monetizable | Integer-cents money columns (BigDecimal) | ๐ |
| ๐ Auditable | Single-column change history ("paper_trail-lite") | ๐ |
| ๐ Lockable | Failed-attempt tracking + account lockout | ๐ |
| ๐ช Aliasable | Full read / write / query association aliases | ๐ |
| โ๏ธ Storable | Typed accessors over one JSON column ("store_attribute-lite") | ๐ |
| ๐งฎ CounterCacheable | Conditional denormalized counters ("counter_culture-lite") | ๐ |
| ๐ Encryptable | Transparent field encryption (AES-256-GCM) + blind-index lookups | ๐ |
| ๐ต๏ธ Anonymizable | GDPR right-to-erasure with per-field strategies | ๐ |
| ๐งฌ Duplicable | Concern-aware deep copy ("clone this invoice") | ๐ |
๐ฎ Controller concerns
| Concern | What it does | Docs |
|---|---|---|
| ๐ Paginatable | Offset pagination with headers | ๐ |
| ๐งญ CursorPaginatable | Cursor (keyset) pagination with headers | ๐ |
| ๐ Filterable | Declarative URL-param filters | ๐ |
| โ๏ธ Sortable (controller) | URL-param ordering with allow-list | ๐ |
| ๐ฆ Respondable | Standardized JSON envelopes | ๐ |
| ๐ ErrorHandleable | JSON rescue_from handlers |
๐ |
| ๐ Includable | Association sideloading + sparse fieldsets | ๐ |
| ๐ก๏ธ SecureHeadable | Security response headers + native CSP DSL | ๐ |
| ๐ Localizable | Per-request locale from params / Accept-Language
|
๐ |
| ๐ Authorizable | Per-action 403 authorization gate | ๐ |
| ๐ฆ Throttleable | Rate limiting (429 + X-RateLimit-*) |
๐ |
| ๐ Timezoneable | Per-request Time.zone from params / header / cookie |
๐ |
| ๐ Idempotentable |
Idempotency-Key request replay |
๐ |
| ๐ช WebhookVerifiable | HMAC verification for inbound webhooks | ๐ |
| ๐ Deprecatable | RFC Deprecation / Sunset headers + 410 |
๐ |
| ๐๏ธ Cacheable | HTTP conditional GET (ETag / 304) + Cache-Control
|
๐ |
| ๐ Permittable | Typed, validated params contracts, schema-drift guard, OpenAPI export | ๐ |
๐งญ Find your concern
Forty-three concerns is a lot of menu. Start from the problem instead:
| "I need toโฆ" | Reach for |
|---|---|
Give records pretty URLs โ /posts/hello-world, not /posts/42
|
๐ Sluggable |
| Let users trash records, then restore them | โ SoftDeletable |
| Schedule a post to go live Friday at 9am | ๐ค Publishable |
| Support drag-and-drop reordering | ๐ข Sortable |
| Issue API keys / invite codes with timing-safe lookup | ๐ Tokenizable |
Number invoices like INV-2026-00042, per account, resetting yearly |
๐งพ Sequenceable |
| Add a small state machine without the AASM dependency | ๐ Stateable |
Encrypt SSNs at rest and still find_by them |
๐ Encryptable |
| Handle a GDPR "delete my data" request in one call | ๐ต๏ธ Anonymizable |
| Know who changed which field, and when | ๐ Auditable |
| Lock accounts after 5 failed logins | ๐ Lockable |
| Keep typed, defaulted settings in one JSON column | โ๏ธ Storable |
Maintain approved_comments_count next to comments_count
|
๐งฎ CounterCacheable |
| Ship a "duplicate this invoice" button (line items included) | ๐งฌ Duplicable |
| Handle money without ever touching a Float | ๐ฐ Monetizable |
| Paginate a JSON index โ page numbers or infinite scroll | ๐ Paginatable / ๐งญ CursorPaginatable |
Turn ?status=published&sort=title into scopes safely |
๐ Filterable + โ๏ธ Sortable |
| Render one consistent JSON envelope, errors included | ๐ฆ Respondable + ๐ ErrorHandleable |
| Rate-limit login attempts per IP | ๐ฆ Throttleable |
Make payment retries safe (Stripe-style Idempotency-Key) |
๐ Idempotentable |
| Verify Stripe / GitHub / Shopify webhook signatures | ๐ช WebhookVerifiable |
Retire /api/v1 with proper Deprecation / Sunset headers |
๐ Deprecatable |
| Serve ETags + 304s so clients stop re-downloading JSON | ๐๏ธ Cacheable |
| Get strong params that also cast, bound-check, and default | ๐ Permittable |
Still browsing? The docs site has instant search across all 43 concerns โ press / and type.
โจ Why this gem?
- Twenty-six model concerns + sixteen controller concerns, all production-ready
- One include, one macro โ no boilerplate, no glue code
-
Lean dependencies โ only
acts_as_list(Sortable) andfriendly_id(Sluggable), and both load lazily: an app that never includes those concerns never loads them. Depends onactiverecord/actionpack/activesupport, not the fullrailsmeta-gem; controller concerns have zero extra deps -
Schema-validated configuration โ every macro checks that the configured columns exist and raises
ArgumentErrorearly โ listing every missing column at once, with one ready-to-pasterails generate migrationcommand that adds them all - Composable โ concerns are independent; mix and match per model
- Tested like an app, not a snippet โ 3,420 RSpec examples run against a real database on every CI build
- Documented twice โ everything in this README also lives as a per-concern page on the docs site, searchable and deep-linkable
๐ฆ Installation
Add to your application's Gemfile:
gem "concerns_on_rails", "~> 1.32"Or pull the latest from GitHub:
gem "concerns_on_rails", github: "VSN2015/concerns_on_rails"Then run:
bundle installOptional: one-initializer configuration
Everything works with zero configuration. The store-backed controller concerns
(Throttleable, Idempotentable) need a cache store โ set it once, gem-wide,
instead of per controller class:
# config/initializers/concerns_on_rails.rb
ConcernsOnRails.setup do |config|
config.cache_store = -> { Rails.cache } # fallback for Throttleable / Idempotentable
config.audit_actor = -> { Current.user&.id } # fallback "by" for every Auditable model
end
# Encryptable's key lives in its own config (see the Encryptable section):
ConcernsOnRails.configure_encryption do |c|
c.key = -> { Rails.application.credentials.dig(:encryption, :key) }
endA per-class self.throttleable_store = ... / self.idempotency_store = ...
still wins over the gem-wide fallback.
๐งช Compatibility
- Ruby: 3.2+
- Rails: 6.0 through 8.1
Every one of those lines runs the full suite on every push โ Rails 6.0, 6.1, 7.0, 7.1, 7.2,
8.0 and 8.1 on SQLite, plus PostgreSQL and MySQL. Ruby 3.2, 3.3, 3.4 and 4.0 each run
against every one of those Rails lines, and a non-blocking ruby-head job gives early
warning of the next Ruby. See .github/workflows/ci.yml.
Rails 6.0/6.1 on Ruby 3.4+ needs a few Gemfile lines that Rails 6.x itself never
declared. Ruby 3.4 and 4.0 moved these libraries out of the default gems, and without them
require "active_record" fails with LoadError: cannot load such file -- mutex_m:
gem "base64"
gem "bigdecimal"
gem "drb"
gem "mutex_m"
gem "benchmark" # Ruby 4.0+
gem "logger" # Ruby 4.0+Rails 5.x is not supported, despite what older versions of this gem declared. Rails 5
cannot run on any Ruby this gem supports: Ruby 3 separated keyword arguments, which breaks
Active Record 5.2's own create_table, and required_ruby_version here is >= 3.2.0. The
old >= 5.0 dependency was therefore unreachable โ no bundle could ever have satisfied
both halves of it.
Two things need a Rails line newer than the 6.0 floor. Both raise or are documented where they apply, and nothing else in the gem is affected:
| Feature | Needs | Below that |
|---|---|---|
Controllers::Sortable โ nulls: ordering |
Rails 6.1 | Raises ArgumentError at macro time (Arel gained the ordering nodes in 6.1) |
Models::Aliasable โ query side (joins/where/eager_load through an alias) |
Rails 6.1 | Rails resolves a where-hash key to a table name, not an association, so the alias is not applied to the join. Readers, writers and build_/create_ work on 6.0 |
๐ 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
endThat'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
sluggable_by :title, scope: %i[account_id locale] # ... or within each combination of columns
# Reject reserved slugs โ saving a record whose slug would be reserved fails validation
sluggable_by :title, reserved_words: %w[new edit admin]
# Let Model.find accept a slug directly (not just the id)
sluggable_by :title, finders: true
sluggable_by :title, candidates: [:title, %i[title city]] # try "title", then "title-city", then friendly_id's uuid suffix on the first candidate
sluggable_by :title, max_length: 60 # truncate at a word boundary (uniqueness suffix added after)
page.regenerate_slug! # rebuild from the current source โ even over a hand-assigned slug
Post.find("hello-world") # resolves by slugNotes
- Schema must have a
slugcolumn (string). -
history: truerequires afriendly_id_slugstable โ generate withrails generate friendly_idor add a manual migration. -
scope: :col(or an Array of columns) requires each column to exist in the same table; abelongs_toassociation name is accepted too (friendly_id scopes by its foreign key). It can't be an Encryptable column (nor an association whose foreign key is one, nor a member of a multi-column scope): friendly_id's uniqueness check would compare ciphertext, match no row and give two records in one scope the same slug, so the declaration raisesArgumentError, in either order. -
candidates:takes friendly_id's shapes โ a Symbol/String method, a Proc, or an Array of those joined with-โ tried in order until one is free (all taken โ the first candidate plus a uuid); the slug still regenerates only when the primary field changes (a candidate-only change doesn't churn the URL), and a NULL slug backfills through the candidates. -
max_length:truncates each candidate at the last-inside the limit (a single long word is hard-cut); friendly_id's conflict suffix is appended afterwards, so a colliding slug may exceed the limit โ unlike friendly_id's ownslug_limit, which squeezes the uuid inside it. -
regenerate_slug!is the escape hatch for the explicit-slug rule: it forces regeneration and saves (save!), keeping uniqueness handling. - A blank assigned slug โ
""from an optional form field, ornil(friendly_id's "regenerate" idiom) โ is not an explicit slug: it is built from the source in the same save (stored blank only when the source is blank too). - Clearing the source (
update!(title: nil),"", or a value that normalizes to nothing such as"!!!") keeps the existing slug instead of replacing it with a random uuid; the uuid fallback only applies when there is no slug yet. -
Order-independent source. A sibling that transforms the source in
before_validationโ Sanitizableon: :write, Normalizable, your own callback โ no longer loses to include order: a slug built earlier in the same save from the raw value ("<b>Hello</b>"โ"b-hello-b") is rebuilt at the start of validation, before any validator (reserved_words:too) or siblingbefore_savesees it. A slug sourced from a column generated inbefore_create(Sequenceable'sinto:, a Hashable code) is built in the sameINSERTinstead of stayingniluntil a later save โ with the slug column's own write-mode Sanitizable / Normalizable rules applied, since their callbacks have already run. An explicitly assigned slug still wins. - 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
# Save a drag-and-drop order in ONE UPDATE (CASE id WHEN โฆ): the ids' order becomes their positions
Task.reposition!(params[:ids]) # => 12 โ rows not listed are pushed after, in their current order
Task.where(list_id: 1).reposition!(ids, missing: :raise) # scoped; a partial list is an errorConfiguration
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
sortable_by :position, scope: :list_id # independent position sequence per list (acts_as_list scope:)
sortable_by :position, add_new_at: :top # new rows insert at the top (acts_as_list add_new_at:)Notes
- The configured field must exist as a column; a direction other than
:asc/:descraises at declaration. -
scope:can't be an Encryptable column (a Symbol โ resolved as acts_as_list does, soscope: :listmeanslist_idor the association's foreign key โ an Array entry or a fixed-value Hash key): acts_as_list's scope query would compare ciphertext, match no row and leave every record alone in its list (each new one at position 1,move_*a no-op), so the declaration raisesArgumentError, in either order. A String SQL scope is not inspected. -
reposition!runs inside the current relation (Task.where(list_id: 1)โ the same set acts_as_list'sscope:would use), rejects ids outside it and duplicates before writing anything, coerces String ids from params, and on a descending list gives the first id the highest value. It bypasses acts_as_list callbacks by design (oneupdate_all, no per-row shifting).
๐ค 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.publish! # already published (saved state): a no-op (true), keeps published_at, no hooks
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? # => falseOpt-in default scope
publishable_by :published_at, default_scope: true
# Article.all now returns only published records automatically
# Article.unscoped reaches everythingBulk operations
Post.draft.publish_all # publishes every not-currently-published post; returns the count
Post.published.unpublish_all # unpublishes every currently-published post; returns the countBoth respect the current relation, return an Integer count, and run in a transaction โ a
record that fails to save raises ActiveRecord::RecordNotSaved and rolls the whole batch
back. With no overridden before_publish/after_publish/before_unpublish/after_unpublish/
publish!/unpublish! and no validations on the model โ neither validates/validates_with,
a custom validate :method, nor an association's autosave validation (a bare has_many
registers one, so most models with associations take the streaming path) โ both collapse to a
single UPDATE, which bumps updated_at
exactly as the per-record path does; otherwise they stream per record through
publish!/unpublish! so validations still run. Both paths touch the same rows, each once: an
ordered, limited relation (Post.order(created_at: :desc).limit(10).publish_all) publishes
exactly those ten on the streaming path too (it resolves the limit to primary keys first), a
has_many join (Post.joins(:comments).publish_all) publishes โ and runs after_publish for โ
each post once however many comments it joins, and a model's default-scope ORDER BY (Sortable)
no longer trips error_on_ignored_order โ true of every *_all verb.
They also stream per record whenever one of the gem's own sibling concerns keeps data in step from
save callbacks that update_all would skip: a CounterCacheable rule with an if: condition, or
Auditable tracking a column the verb writes. The single UPDATE used to leave such a counter or audit
trail behind, and whether it did depended on whether some unrelated validator forced the per-record
path. (expire_all, activate_all and deactivate_all are gated the same way.)
STI tables always use the per-record path. On a single-table-inheritance table (a present inheritance_column that is a real column) the relation also holds every subclass's rows, and a subclass may override a hook, declare validations or re-declare the macro with other options. A bulk UPDATE would skip all of that, so no batch verb takes it there: hooks and validations run per record (slower on large STI tables, always correct). A model that sets self.inheritance_column = nil is not STI and keeps the single UPDATE. This holds for every batch verb below (publish_all/unpublish_all, soft_delete_all/restore_all, expire_all, activate_all/deactivate_all, unlock_expired).
publish_all targets every not-currently-published row โ including scheduled ones, whose
future published_at it overwrites โ so chain .draft (Post.draft.publish_all) to exclude
them. Its predicate composes with your own constraint on the publish column, so that chain
really does leave scheduled rows alone. The flip side of composing: with default_scope: true
the relation is already narrowed to published rows, so a bare Post.publish_all matches
nothing โ chain .draft or .unpublished first (both unscope the column themselves, so the
chain resolves to the rows you mean).
Scope-name collisions
publishable_by :published_at, prefix: :article # => Article.article_published / .article_draft
publishable_by :published_at, suffix: :posts # => Article.published_posts / .draft_posts
publishable_by :published_at, prefix: true # => Article.published_at_published / ...prefix:/suffix: rename every scope publishable_by generates, so a model can include
Publishable alongside another concern that would otherwise generate a same-named scope
without one clobbering the other. With no affix passed, scope names, the optional default
scope, and the emitted SQL are all unchanged.
Publishable, SoftDeletable and Schedulable define their default-named scopes as soon as they
are included, and their macro's affix renames them. So when the concern is included before
the other concern's declaration, its affixing macro call may come before or after it:
include SoftDeletable; include Stateable; stateable_by :status, states: %i[pending active]; soft_deletable_by prefix: :trash leaves .active to Stateable and moves SoftDeletable's to
.trash_active. Including the concern after stateable_by raises (its include-time scope
would replace Stateable's), and so does a state that takes an include-time scope a parent
class defined, which the subclass can never rename. With Stateable, a shared name that is
never renamed is caught when the scope is called (ArgumentError), so declare the affix in
the same class body. Only scopes are renamed: a state named after one of the concern's predicates
(published/draft, scheduled, expired, โฆ) still needs prefix:/suffix: on
stateable_by.
prefix:/suffix: mean three different things across the gem, depending on the concern:
- Scope-name affix (renames generated scopes) โ Activatable, Expirable, Lockable, Stateable, Anonymizable, Publishable, SoftDeletable, Schedulable.
- Accessor-name affix (renames generated reader/writer methods) โ Storable.
-
A literal string prepended to the generated value itself โ Sequenceable's
prefix:(e.g."INV-"), unrelated to scope/method naming.
Passing true for a scope- or accessor-name affix means "use the configured field name"
(publishable_by :published_at, prefix: true โ published_at_published/published_at_draft).
Unrelated to all three: Searchable's match: :prefix is a LIKE-match mode (term%), not a
naming affix.
Lifecycle hooks โ override before_publish / after_publish / before_unpublish /
after_unpublish. They and the write share their own savepoint, so a hook that raises โ or
vetoes with raise ActiveRecord::Rollback โ undoes the write: publish! / unpublish! /
publish_at! return false and nothing is written, even inside your own transaction, and
publish_all / unpublish_all raise ActiveRecord::RecordNotSaved and roll the whole batch
back. A write that fails validation returns false and rolls back the before-hook's side
effects too. After an aborted write the record's published_at goes back to its previous value
in memory.
Notes
- "Published" means
published_atis set and in the past โ so future-dated posts stay unpublished until their time arrives. - On a
datepublish column (publishable_by :published_on),published?/scheduled?compare against today inTime.zone, as.published/.scheduleddo. -
publish_at!casts its argument through the column's type. A value that is not a time raisesArgumentErrorbefore any hook runs: a String must name a year (ISO 8601, RFC 2822,"Oct 1 2026"and"2026-10-01 10:30"all do;"junk","Monday"and"10:30"do not, althoughTime.zone.parsereads them as June 1st and today), and42or1.hournever cast. It used to writeNULLand returntrue.nilstill writesNULL(and fires the publish hooks). - No
default_scopeis added by default; chain.publishedexplicitly (or opt in withdefault_scope: true). An explicitdefault_scope: falseon a later call or an STI subclass turns it off again; omitting the option keeps the current (inherited) setting. - A boolean publishable column works too (
publishable_by :is_published). Its.publishedscope is(is_published = TRUE), wrapped in parentheses (an Arel Grouping) so that Rails does not copy the condition onto new records built through the scope. So withdefault_scope: truea new record still starts unpublished, and so doesPost.published.new. The predicate stays in the index-friendly= TRUEform, so a partial indexWHERE published = truestill matches. On Rails 6.0, which cannotunscopea Grouping, the scope usesis_published <> FALSEinstead; it selects the same rows.unpublish!/unpublish_allwritefalseinto a boolean column (NULLinto a timestamp one), so anull: falsecolumn can be unpublished.
โ SoftDeletable
Soft delete records using a timestamp field (default: deleted_at). By default a default_scope hides deleted records โ opt out with soft_deletable_by :deleted_at, default_scope: false and chain .without_deleted explicitly (the safer choice for new models, avoiding default_scope's join/uniqueness footguns). soft_delete! / restore! and the bulk helpers run inside a transaction, so a raising hook rolls the change back.
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; the instance is then destroyed? (not persisted?)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.soft_delete_all # soft-deletes all matching records; returns the count (explicit โ preferred)
User.destroy_all # alias of soft_delete_all (kept for backwards compatibility; returns a count, not records)
User.where(org_id: 7).destroy_all # a relation's destroy_all soft-deletes too (returns the records)
post.comments.destroy_all # and so does an association's (comments include SoftDeletable)
User.only_deleted.destroy_all # ...except through a trash scope: rows ALREADY soft-deleted are destroyed for real (purges the trash)
User.really_destroy_all # hard-deletes the records matching the CURRENT relation (soft-deleted included)
User.restore_all # restores the matching soft-deleted records; returns the count
User.deleted_within(1.hour).restore_all # undo a bulk delete โ only the last hour's trash
User.deleted_within(30.days).really_destroy_all # purge recent trash; older rows untouched
User.only_deleted.really_destroy_all # empty the trash can, nothing elsedestroy_all is contextual. An app-initiated destroy_all on the class, on a relation (where(...),
destroy_by, in_batches) or on a non-:through association (post.comments.destroy_all,
post.comments.where(...)) follows three rules:
- A live record is soft-deleted, through its own
soft_delete!(hooks,cascade:), atomically (RecordNotSaved+ rollback on failure). The relation and association forms return the records they acted on, as ActiveRecord's do (an association whosebefore_removeaborts answersnil); only the class form returns a count. - A record that is already soft-deleted is destroyed for real (as
paranoiadoes) ONLY when you reached the trash on purpose, through one of that model's own trash scopes:only_deleted,soft_deleted,deleted_within(...)orwith_deleted(or their affixed / renamed variants; also inside an association declared-> { only_deleted }, or merged in:User.where(...).merge(User.only_deleted)). Another model's trash scope merged into the relation does not count:Post.joins(:comments).merge(Comment.only_deleted) .destroy_allsoft-deletes the live posts that have trashed comments and purges no post. SoUser.only_deleted.destroy_allandUser.deleted_within(30.days).destroy_allpurge the trash, and a mixed relation (with_deleted.destroy_all) soft-deletes its live rows and purges its trashed ones. Every other relation leaves the trash untouched, includingwhere(...),all,destroy_by,post.commentsandunscopedon adefault_scope: falsemodel, whose relations already contain the trashed rows. The trashed set is decided when the call starts: a row this call soft-deletes (a self-referentialcascade:, anafter_soft_deletehook) is never purged by a later batch (it stays in the trash; as withsoft_delete_all, a stale copy that a self-referentialcascade:already trashed can be re-stamped, so the parent'srestore!may not bring it back). Each row is re-checked right before its purge, so a row restored before its turn is not purged (a plainEXISTS, not a row lock: a restore committed concurrently between that check and theDELETEis still lost). The real destroy is Rails' own (callbacks anddependent: :destroyrun); a trashed parent first purges its trashedcascade:children, whatever their timestamp (live ones are left todependent:), becausedependent: :destroycannot see them through their default scope; a cascade cycle (a self-parented row, a user owning the team it belongs to) purges each row once; the purge pass ignores a cascaded association's ownlimit/offset. A vetoed destroy (the record's own, or any trashed child's, which rolls that record's whole purge back) is skipped on a relation; an association raisesRecordNotDestroyedfor it, as Rails'destroy!does. -
Rails' own association maintenance keeps Rails semantics.
has_many :groups, through: :memberships, dependent: :destroyon a destroyed owner,user.groups.destroy(group)anduser.groups.destroy_allremove the join rows with a hard delete, even when the join model is soft-deletable (soft-deleting them would leave rows pointing at a destroyed owner). A:throughassociation'sdestroy_allremoves the links, never the targets. Only the ONEdestroy_allRails issues for that is hard: a destroy callback of the join model that callsdestroy_allitself is an app call again and soft-deletes. (A join model with no primary key is the exception: Rails then runs its destroy callbacks and adelete_allinstead, so adestroy_allon the join model from one of those callbacks is still hard.)
The association form goes through Rails' own remove path, so before_remove / after_remove run and the
loaded instances are the ones marked deleted; a loaded relation (or one with limit / offset, or a
has_many :through, disable_joins: true relation) acts on its records as loaded, once per record. Instance
destroy is unchanged: Rails' own hard delete.
These forms stay hard deletes (they are Rails' own, not an app destroy_all): has_many ..., dependent: :destroy, post.comments.destroy(comment), post.comments.delete(comment) on a dependent: :destroy
association, assigning a collection that drops records (post.comments = [...]) on a dependent: :destroy association and
nested attributes with _destroy. Use cascade:, soft_delete! or post.comments.soft_delete_all to soft-delete through an
association.
To get the pre-fix behavior (a relation's or association's destroy_all is ActiveRecord's own hard delete) declare
soft_deletable_by :deleted_at, relation_destroy_all: :hard (default :soft; set by every macro call like the
other options, inherited by subclasses that do not call it). Model.destroy_all / soft_delete_all always
soft-delete. Use really_destroy_all to hard-delete on purpose.
Upgrading. Code that called Model.where(...).destroy_all or record.assoc.destroy_all on a SoftDeletable
model and relied on it hard-deleting now soft-deletes: switch to really_destroy_all, or set
relation_destroy_all: :hard. Beyond the delete itself, for live rows a scoped destroy_all no longer runs
before_destroy / after_destroy or after_commit on: :destroy / after_destroy_commit: it runs
before_soft_delete / after_soft_delete instead, and under the default touch: true also the model's
validations and update callbacks (after_update_commit). A row that fails validation (or whose
before_soft_delete vetoes) now raises ActiveRecord::RecordNotSaved and rolls the batch back, where
ActiveRecord's own destroy_all skipped the rows whose destroy was vetoed. On Rails 7.2+ in_batches.destroy_all
sums count(&:destroyed?), so soft-deleted rows read as 0 there (they are not destroyed?). Rows now stay in
the table, so a unique index that also covers soft-deleted rows blocks re-creating the record (email, a
join-table pair): use a partial index that ignores the trash, CREATE UNIQUE INDEX ... WHERE deleted_at IS NULL
(add_index :users, :email, unique: true, where: "deleted_at IS NULL"). A soft-deleted parent does not trigger
dependent: :destroy on its children (nothing is destroyed): list them in cascade: to send them to the trash
with it.
A record that fails to transition raises ActiveRecord::RecordNotSaved and rolls the whole
batch back. With touch: false and no overridden hooks, soft_delete_all / restore_all
collapse to a single UPDATE (never on an STI table, which always streams per record so each class's hooks and validations run). Both restore_all and really_destroy_all peel off only the
default scope's own deleted_at IS NULL: a predicate you put on the column โ deleted_within,
where(deleted_at: range), only_deleted โ survives, as does any other default scope the model
declares. (Previously they unscoped the column outright, so deleted_within(1.hour).restore_all
restored the whole trash can and only_deleted.really_destroy_all widened to the whole relation.)
The scopes still unscope the column, so chain them first: soft_deleted.where(...), not
where(...).soft_deleted. They unscope it on the model's own table only (Rails 6.1+), and the
default scope records no unscope at all. So merging a soft-deletable model's relation into another
model's (Comment.joins(:post).merge(Post.where(...)), .merge(Post.with_deleted)) keeps the
other model's own deleted_at filter. It used to bring that model's soft-deleted rows back.
Scope-name collisions
soft_deletable_by :deleted_at, prefix: :account # => .account_active / .account_soft_deleted / ...
soft_deletable_by :deleted_at, suffix: :records # => .active_records / .soft_deleted_records / ...
soft_deletable_by :deleted_at, prefix: true # => .deleted_at_active / ...prefix:/suffix: rename every scope soft_deletable_by generates (active,
without_deleted, soft_deleted, only_deleted, with_deleted, deleted_within), so a
model can combine SoftDeletable with another concern that also defines .active (Activatable,
Expirable) without a collision. prefix: true uses the configured field name. With no affix
passed, scope names, the default scope, and the emitted SQL are unchanged. See the
Publishable section above for how prefix:/suffix: differ across the gem.
Cascading to dependents
class Post < ApplicationRecord
include ConcernsOnRails::SoftDeletable
has_many :comments
has_one :cover
soft_deletable_by :deleted_at, cascade: %i[comments cover] # Comment and Cover include SoftDeletable too
end
post.soft_delete! # comments + cover soft-deleted in the same transaction, with the post's exact timestamp
post.restore! # brings back the comments/cover the cascade deleted โ NOT a comment someone trashed last week
post.soft_delete!(at: 1.day.ago) # new at: keyword โ backdate, or hand a timestamp down a cascade
post.soft_delete!(at: params[:at]) # absent or blank means now, like expire! (it used to write NULL and return true)
post.soft_delete!(at: "junk") # ArgumentError before any hook runs: a String must name a yearDependents go through their own soft_delete! / restore! (hooks and nested cascades run). A dependent
that fails โ whether it raises or just fails validation โ aborts the cascade with
ActiveRecord::RecordNotSaved and rolls the parent back with it, so you never end up with a deleted
parent and a live child. Declare the cascaded associations above soft_deletable_by; the macro
resolves them at class load. Restore matches on the parent's timestamp, so independently
deleted dependents keep their own. Dependents are loaded with only the gem's own hiding
predicates removed โ those on the child's own table's SoftDeletable column, and on its
Publishable column under default_scope: true โ so a child that hides its drafts has those drafts
soft-deleted and restored too. Everything else still applies, exactly as for Rails' dependent::
the association's own conditions and every other default-scope predicate, including a tenant, a
discriminator on a shared table, and a joined table's same-named column
(joins(:author).where(authors: { deleted_at: nil }) keeps a deleted author's comments out). A has_one cascades to the child its reader returns
(a hidden one only when the reader returns none). cascade: accepts has_many / has_one (no belongs_to, HABTM or
:through) whose models include SoftDeletable; with a cascade configured soft_delete_all / restore_all
take the per-record path (a bulk UPDATE cannot follow associations).
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
endThe hooks, the write and any cascade share their own savepoint. A hook that raises, or vetoes
with raise ActiveRecord::Rollback, undoes all of it. In that case soft_delete! / restore!
return false and nothing is written, even inside your own transaction. soft_delete_all /
restore_all raise ActiveRecord::RecordNotSaved and roll the batch back. The in-memory
deleted_at also goes back to its old value, so a retry really writes: it is not skipped by the
deleted? guard. A write that fails validation (touch: true) rolls back the before-hook's
side effects too.
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 โ and, optionally, use them as the public ID in your URLs.
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 itGenerators
| 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
# Public IDs: a Stripe-style prefix and the value as the URL parameter
hashable_by :public_id, type: :custom, length: 14, prefix: "ord_", unique: true, to_param: true,
alphabet: "abcdefghijklmnopqrstuvwxyz0123456789"
order.public_id # => "ord_k7m3pq9a2x5n8v"
order_path(order) # => "/orders/ord_k7m3pq9a2x5n8v"
Order.find_by!(public_id: params[:id])
# while backfilling, accept both shapes โ to_param falls back to the integer id when public_id is blank
Order.find_by(public_id: params[:id]) || Order.find(params[:id])Notes
- Auto-assigns in
before_createonly when the field is blank โ callers can pass an explicit value. The generated value is reported to the siblings that already ran, whatever the include order: an Encryptable blind index is fingerprinted (a fresh record is findable byfind_by_<field>), a Sluggable slug from the field is built, and an Auditable creation entry includes it. Withencryptable :field, blind_index: true,unique: trueprechecks through the blind index. - A
regenerate_<field>!instance method is defined dynamically. -
prefix:is prepended to every generated value (string types only โ not:integer); the uniqueness check sees the full prefixed value. -
to_param: trueoverridesto_paramto return the hashed field, falling back to the id while it is blank โ pair it withfind_by!(field: params[:id]), or withfind_by(field: params[:id]) || find(params[:id])while a backfill is still in flight (a blank field puts an integer in the URL). Raises at class load alongside Sluggable, in either declaration order โ friendly_id overridesto_paramtoo. -
unique: trueprechecks for collisions (across the whole table โ an STI subclass sees its siblings' values) and retries a bounded number of times (still add a unique index โ that is the real guarantee).regenerate_<field>!retries aRecordNotUniquefrom that index in its own savepoint, so it also works inside your transaction on PostgreSQL. - A
:customalphabet needs at least 2 distinct characters (ArgumentErrorotherwise). A repeated character would silently be drawn more often, so duplicates are removed andConcernsOnRails.deprecatorwarns โ this will raise in 2.0. - For fixed-width numeric codes (e.g.
000042), use a string column โ integer columns drop leading zeros. - 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
Promotion.overlapping(from, to) # windows intersecting [from, to) โ clashing bookings, a calendar page
Promotion.overlapping(from..to) # Range form; `..` makes the end inclusive; nil on either side = unbounded
promo.overlaps?(from, to) # the instance-side predicateConfiguration
# 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_atMutators
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)Scope-name collisions
schedulable_by prefix: :promo # => .promo_current / .promo_upcoming / .promo_expired / .promo_active_at
schedulable_by suffix: :window # => .current_window / .upcoming_window / .expired_window / .active_at_window
schedulable_by prefix: true # => .starts_at_current / ... (the configured starts_at/ends_at field)prefix:/suffix: rename every scope schedulable_by generates (active_at, current,
upcoming, expired, overlapping). With no affix passed, scope names and the emitted SQL are unchanged.
See the Publishable section above for how prefix:/suffix: differ across the gem.
An affix also defines affixed predicates: promo_current?, promo_upcoming?,
promo_expired?, promo_active_at?(time) and promo_overlaps?(from, to). These always give
Schedulable's answer. The plain predicates stay, but a shared name (expired?, which
Expirable also defines) belongs to whichever concern is included last.
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". -
start!/finish!/reschedule!cast each time through its column's type. A value that is not a time raisesArgumentErrorand nothing is written (it used to clear the column): a String must name a year (ISO 8601, RFC 2822,"Oct 1 2026"and"2026-10-01 10:30"all do;"junk","Monday"and"10:30"do not, althoughTime.zone.parsereads them as June 1st and today), and42or1.hournever cast.nilstill clears a side. - No
default_scope; chain.currentexplicitly. -
datecolumns work (schedulable_by starts_at: :starts_on, ends_at: :ends_on): the predicates cast the instant through thedatecolumn's type, as the scopes' binds are, socurrent?/upcoming?/expired?/active_at?/overlaps?compare against the date inTime.zoneand agree with.current/.upcoming/.expired. They used to compare the stored Date at UTC midnight, so outside UTC they disagreed with the scopes for part of every day. Expirable's and Publishable's predicates do the same. Adatetimecolumn is not touched: its String / Date bounds are read exactly as before.
โณ 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 dayMutators
token.expire! # expires_at = now (nil or "" also mean now)
token.expire!(2.hours.from_now) # explicit time (a Time, or a parseable String); never pushes an expiry later
token.expire!("garbage") # ArgumentError, raised before any hook runs (a String must name a year)
token.expire_in!(15.minutes) # absolute lifetime from now, whatever the current expiry
token.extend_expiry!(by: 1.day) # pushes expiry forward
token.clear_expiry! # never expires (nil)Expiring only ever brings an expiry forward. When the saved expiry is already at or before the
requested time, expire! returns true and writes nothing, and no hook fires: calling expire! on an
already-expired token keeps its expiry, and expire!(1.week.from_now) on a token expiring in an hour keeps
the hour. Until this fix it rewrote the expiry (to a later time) and fired after_expire again. To set a
later expiry, use expire_in! (an absolute lifetime, whatever the current expiry) or extend_expiry!. A no-op expire! still saves the record's other unsaved changes (token.note = "revoked"; token.expire!). The guard reads the instance's saved expiry, so a stale instance cannot revoke an expiry another process pushed later (reload first, or use expire_all); and expire_in! writes through a private writer, so an expire! override does not see it.
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
The existing value is the one stored, read under a row lock (SELECT ... FOR UPDATE of that column,
no reload) โ so two workers renewing the same license concurrently compound (+30 days twice = +60), where the
in-memory copy used to lose one. An expiry you assigned but have not saved (and a new record) extends from the
instance's value; under optimistic locking a stale instance raises StaleObjectError rather than losing a renewal.
Lifecycle hooks โ override before_expire / after_expire on the model; they fire around a write that
actually expires the record (expire! with a past-or-now time, and expire_all). They and the write share
their own savepoint. A hook that raises, or vetoes with raise ActiveRecord::Rollback, undoes the expiry:
expire! returns false even inside your own transaction, and expire_all raises
ActiveRecord::RecordNotSaved and rolls back. A write that fails validation also rolls back before_expire's
side effects. A future time only schedules expiry, so expire_in!(14.days)
fires nothing โ as with renewals (extend_expiry!) and clear_expiry!. Overriding either hook moves
expire_all from its single UPDATE to the per-record path so the hooks run for every row.
Degenerate times โ expire! and expire_all treat nil or a blank value ("", false, []) as
now. They never write the raw value, which Active Record would cast to nil, meaning never expires.
A value that cannot be cast to a time ("garbage", an Integer, a Duration) raises ArgumentError before any
hook runs or any row is written. This applies to both the fast path and the per-record path of expire_all.
Scope- and predicate-name collisions
expirable_by :expires_at, prefix: :term # => .term_active / .term_expired / .term_expiring_within
# and #term_active? / #term_expired?prefix:/suffix: rename the scopes and also define affixed predicates. active? / expired? keep their
plain names, but Activatable (active?) and Schedulable (expired?) define the same names. On a model
that combines them, the concern included last owns the plain name, so use the affixed predicates.
The plain predicates keep their old relationship: active? is !expired?, so overriding expired? changes both.
The affixed predicates always give Expirable's own answer.
On a date expiry column, expired? / active? compare against today in Time.zone, as .expired / .active do.
The same rules apply to every concern with affixed predicates (Expirable, Activatable, Schedulable):
- The macro raises
ArgumentErrorif an affixed predicate would shadow a column's query method. For example,activatable_by :active, prefix: :flagon a table that has aflag_activecolumn is refused. There is one exception: the colliding column is the concern's own flag (activatable_by :account_active, prefix: :account). In that case the column's own query methodaccount_active?already answers the question, so it is left alone and nothing is raised. - Re-declaring the macro with a different affix (or none) removes the previous affix's predicates. On a subclass, every inherited affixed predicate that the current affix no longer defines is hidden, and the parent keeps its own.
- An affixed predicate or scope that another concern already generates on the model raises
ArgumentErrornaming that concern. For example,expirable_by prefix: :termfollowed byactivatable_by prefix: :termwould generateterm_active?/.term_activetwice, andactivatable_by suffix: :atwould generate.active_at, which is Schedulable's scope. The scopes of Publishable, SoftDeletable, Schedulable, Expirable, Activatable and Lockable are all checked. Choose a different affix. Until this fix the second concern silently replaced the first one's predicate and scope. Unaffixed names are still shared: the concern declared last owns them. - The batch verbs never select their rows through those shared scope names.
expire_all,activate_all/deactivate_allandunpublish_allbuild their own predicates, so whichever concern owns.activecannot change which rows they write.
Bulk operations
ApiToken.expiring_within(1.day).expire_all # => 12expire_all(time = Time.zone.now) expires every currently-active record in the relation whose expiry
is unset or later than time, and returns the Integer count of rows written, in a transaction. Like
expire! it never pushes an expiry later: Token.where(user: u).expire_all(1.hour.from_now) leaves a
token due in 5 minutes at 5 minutes (it used to be extended to the hour). With expire! and both hooks unoverridden and no validations on
the model โ neither validates/validates_with, a custom validate :method, nor an
association's autosave validation (a bare has_many registers one, so most models with
associations take the streaming path) โ and no STI, it collapses
to a single UPDATE, which bumps updated_at exactly as the per-record path does (unless a CounterCacheable
rule with if: or Auditable tracking the expiry column needs the per-record callbacks); otherwise it
streams per record through expire! so validations still run, and a record that fails to save
raises ActiveRecord::RecordNotSaved and rolls the whole batch back.
Custom field name
expirable_by :valid_untilExpirable 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 (plus a before_save backstop for saves that skip validation) โ strip whitespace, downcase emails, dedupe spaces, chain transforms, run any custom lambda.
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 :display_name, with: %i[squish titleize] # a chain, applied left to right
normalizable :bio, with: %i[squish nullify_blank] # "" / " " โ nil
normalizable :website, with: :url # "Example.COM/x" โ "https://example.com/x"
normalizable :slug, with: ->(v) { v.to_s.parameterize } # custom lambda (chains with presets too)
end
User.create(email: " ALICE@Example.com ").email # => "alice@example.com"
User.create(phone: "+1 (415) 555-1234").phone # => "14155551234"
# The same rule outside a record โ lookups and params see what the DB sees:
User.find_by(email: User.normalize(:email, params[:email]))Built-in presets
| Preset | Transform |
|---|---|
:email |
strip + downcase
|
:phone |
digits only (gsub(/\D/, "")) |
:whitespace / :strip
|
strip |
:squish |
squish (collapse inner whitespace) |
:downcase / :upcase / :capitalize
|
the String method of the same name |
:titleize |
upcase each word's first letter and downcase the rest, in place โ deliberately not String#titleize (that is humanize(underscore(v)), which splits "Jean-Luc" into "Jean Luc" and drops the _id of "customer_id"). No character is added or removed, so hyphens and underscores survive; only case changes, so "DVD player" โ "Dvd Player"
|
:parameterize |
parameterize (URL slug) |
:nullify_blank |
"" or whitespace-only โ nil (content untouched) |
:url |
strip, default scheme to https:// (host:port counts as schemeless; a protocol-relative //CDN.example.com/x becomes https://cdn.example.com/x), lowercase scheme + host, keep userinfo/path/query, drop a redundant default port. Only http/https are canonicalized โ any other scheme (mailto:, tel:, javascript:, data:) and unparseable input come back stripped for your format validator to reject |
Notes
- Runs in
before_validation, so DB constraints and AR validations see the normalized value โ with abefore_savebackstop for saves that skip validation (update_attribute,save(validate: false));update_column(s)/update_allbypass it. A rule runs once per save, even a non-idempotent Proc undersave if valid?(the second validation skips a field still holding what the first left, also after a Sanitizableon: :writerule rewrote it;reloadforgets the earlier validation). The backstop is prepended to the save callbacks, so a sibling'sbefore_save(Addressable's fingerprint) sees the normalized value whichever concern was included first; Encryptable's blind index and Auditable's entry run later still, at the write boundary (Support::WriteBoundary). It is one shared dispatcher with the Sanitizable / Taggable / Addressable backstops, run in theirbefore_validationorder, so a save that skips validation stores the same value as a validated one when two of them transform the same column. -
with:takes a preset, a Proc, or an Array of them (applied in order); every entry is validated at class load. -
nilvalues are skipped โ nonil โ ""coercion (use:nullify_blankfor the opposite direction). - Preset normalizers pass non-string values through unchanged.
- "Whitespace" is Unicode whitespace:
:strip/:whitespace/:email/:urlstrip a pasted no-break space, em space or ideographic space from both ends (plainString#stripdoes not),:squishcollapses them, and:nullify_blanktreats a value of only such spaces as blank. - Works on Rails 6.0+ (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 scopeOptions
# 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
# ranked: true โ best matches first: exact, then prefix, then substring hits;
# within a tier the earlier-declared column wins. Portable CASE expression, no index needed.
searchable_by :title, :body, ranked: true
Article.search("ruby") # "ruby" (title) โ "Ruby" (body) โ "Rubyistsโฆ" โ "โฆabout ruby"
Article.search("ruby", ranked: false) # per-call override (and `ranked: true` opts in per call)
Article.recent.search("ruby") # relevance leads; the existing ORDER BY breaks ties
Article.group(:author_id).search("ruby") # grouped relations are returned unranked
Article.joins(:tags).distinct.search("ruby") # so are DISTINCT ones (distinct BEFORE search; after it, use ranked: false)
Article.search(q).pluck(:id, Article.search_rank(q)) # the score itself (0 = exact hit on the first column)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.
- An Encryptable column holds ciphertext, which
LIKEnever matches โ naming one insearchable_byraisesArgumentErrorat declaration, in either order (it used to return nothing, silently). -
ranked:usesreorder, so relevance leads โ until something reorders again.Controllers::Sortable#sortedandCursorPaginatableboth reorder unconditionally, so chain.searchafter them (paginated(sorted(Article.all).search(q))), not before. - Grouped relations are returned unranked (ORDER BY on a non-grouped column is a hard error on Postgres/MySQL), so
Article.group(:author_id).search(q).countis safe. DISTINCT relations are returned unranked too (SELECT DISTINCT โฆ ORDER BY <expression not in the select list>is rejected by Postgres and MySQL 5.7.5+), soArticle.joins(:tags).distinct.search(q)is safe โ order it yourself if it needs an order. Applydistinct/groupBEFOREsearch, or passranked: false: a DISTINCT/GROUP added aftersearchstill carries the rankORDER BY, which PostgreSQL and MySQL reject. Undermode: :allthe per-term scores are summed. - Reach for
pg_search/ Elasticsearch when you need stemming, weighting by frequency, 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
# activatable_by timestamps: true # stamps activated_at / deactivated_at
# activatable_by timestamps: { activated_at: :enabled_at, deactivated_at: nil }
def after_deactivate = Billing.pause!(self) # before/after_activate, before/after_deactivate hooks
end
sub = Subscription.create!(active: true)
sub.active? # => true
sub.deactivate!
sub.inactive? # => true
sub.deactivate! # already inactive: a no-op (true), no hooks, deactivated_at kept
sub.toggle_active! # flips back to true
Subscription.active # WHERE active = TRUE
Subscription.inactive # WHERE active = FALSE OR active IS NULLBulk operations
Subscription.inactive.activate_all # => 12
Subscription.active.deactivate_all # => 3Both target the relation, return an Integer count, and run in a transaction. Gating is per
direction: with activate!, before_activate and after_activate unoverridden and no
validations on the model โ neither
validates/validates_with, a custom validate :method, nor an association's autosave
validation (a bare has_many registers one, so most models with associations take the
streaming path) and the table is not STI โ activate_all collapses to a single
UPDATE, which bumps updated_at exactly as the per-record path does; otherwise it streams
per record so the hooks and validations still run, and a record that fails to save raises
ActiveRecord::RecordNotSaved and rolls the whole batch back. deactivate_all is gated the
same way by deactivate!/before_deactivate/after_deactivate, so overriding only
after_deactivate leaves activate_all on the fast path. A CounterCacheable rule with if:, or
Auditable tracking the flag or a stamp, also keeps both on the per-record path (see Publishable). toggle_active!'s row lock has
no batch analogue. It reloads a clean record under the lock; a record with unsaved changes (which
with_lock refuses) has only the flag read under the lock, and its changes save with the flip.
Notes
-
NULLis treated as inactive (same convention as most apps' "unset = off"). - Hooks (
before_activate/after_activate/before_deactivate/after_deactivate) share their own savepoint with the write. A hook that raises, or vetoes withraise ActiveRecord::Rollback, rolls the flip back and the verb returnsfalse, even inside your own transaction.activate_all/deactivate_allthen raiseActiveRecord::RecordNotSavedand roll back. A failedupdate(validation) skips the after-hook, returnsfalse, and rolls back the before-hook's side effects.toggle_active!and the batch verbs go through the same path. -
timestamps: truestampsactivated_aton activate anddeactivated_aton deactivate (the other column keeps its last value, so you can see both the last activation and the last deactivation); a Hash renames either column or drops a side withnil. The stamp columns must already exist โactivatable_bychecks that at declaration and raisesArgumentErrorotherwise; thedatetimetype itself is not enforced, it only types the migration hint in that error. - The configured column must exist;
activatable_byraisesArgumentErrorotherwise. -
SoftDeletableandExpirablealso define a.activescope, andExpirablealso defines anactive?predicate. On a model that combines them, passprefix:/suffix:(activatable_by :active, prefix: :flag). This renames the scopes (.flag_active/.flag_inactive) and defines affixed predicates (flag_active?/flag_inactive?) that always give Activatable's answer. The plainactive?/inactive?stay for compatibility and belong to the concern included last.toggle_active!reads the flag itself, so it flips the right way whichever concern ownsactive?.
๐ 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, expires_in: 2.hours # needs reset_password_token_expires_at
tokenizable_by :invite_code, type: :alphanumeric, length: 8
end
user = User.create! # all three tokens auto-generated (+ the reset token's expiry stamped)
user.api_token # => "k3Jf...g2" (32 URL-safe chars)
user.api_token? # => true
user.reset_password_token_expired? # => false, until reset_password_token_expires_at passes
user.regenerate_api_token! # rotates and persists (an expiring field gets a fresh expiry too)
user.revoke_api_token! # nils the column (and the expiry)
User.find_by_api_token(token) # Rails default
User.authenticate_by_api_token(token) # timing-safe; returns user or nil โ nil for an EXPIRED token
User.consume_reset_password_token(token) # single use: authenticate AND revoke atomically; nil the second time
User.reset_password_token_expired # scope: rows whose expiry has passed (cleanup jobs)Options
| Option | Default | Notes |
|---|---|---|
type: |
:urlsafe |
One of :urlsafe, :hex, :alphanumeric, :numeric
|
length: |
32 |
Character length of the generated token |
expires_in: |
nil |
A Duration/seconds. Stamps <field>_expires_at (a datetime column you add) on every generation; authenticate_by_/consume_ refuse a stale token; adds <field>_expired? and the <field>_expired scope |
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 (across the whole table โ an STI subclass sees its siblings' tokens) and retries up to 10 times. Pair with a
uniqueDB index for real safety, especially for short alphanumeric/numeric codes;regenerate_<field>!retries aRecordNotUniquefrom that index in its own savepoint, so it also works inside your transaction on PostgreSQL. -
.authenticate_by_<field>usesActiveSupport::SecurityUtils.secure_compareto avoid leaking partial matches via response timing, and returnsnilonce anexpires_in:token has expired. Only a String or Integer is looked up: a crafted non-scalar param (?token[a]=b) answersnil(asconsume_<field>does) instead of raisingTypeError. -
.consume_<field>(value)(every field) is the single-use verb โ password resets, invite codes, magic links: it authenticates, then revokes with a conditionalUPDATEkeyed on the token still being present, so two concurrent consumers cannot both succeed; the loser getsnil. An expired token is refused and left in place. - A caller-supplied token on an
expires_in:field gets the configured lifetime on create unless the caller also sets<field>_expires_at. Assigning one to an already-persisted row (user.update!(reset_password_token: "preset")) stamps nothing, and a row whose expiry isnilnever expires โ rotate withregenerate_<field>!(which stamps a fresh expiry) or set<field>_expires_atyourself. - Tokens are assigned in
before_create, and reported to the siblings that already ran: an Encryptable blind index is fingerprinted, so a freshly created record is findable; a slug or Auditable entry built from the token sees it. Whatever the include order. -
Encrypted tokens: with
encryptable :api_token, blind_index: true,authenticate_by_/consume_(and the uniqueness precheck) look the token up through the blind index โ stillsecure_compared โ andconsume_clears the index too. Encrypted without a blind index, the token is still generated and rotated, butauthenticate_by_/consume_raiseArgumentErrorwhen called (they used to returnnilfor every token). Tokens generated before this fix were stored with a NULL blind index โ backfill them once (recipe). - Distinct from
Hashable: Hashable handles a single random field; Tokenizable focuses on security tokens (multi-field, URL-safe default, timing-safe lookup, revocation).
๐งพ Sequenceable
Ordered, human-friendly reference numbers โ invoice numbers, order numbers, ticket IDs, support cases. Unlike the random identifiers from Hashable / Tokenizable, Sequenceable produces sequential ones backed by an integer column that is the source of truth.
class Invoice < ApplicationRecord
include ConcernsOnRails::Sequenceable
sequenceable_by :sequence, # integer column โ the source of truth
into: :number, # optional string column for the formatted value
prefix: "INV-",
padding: 5,
scope: :account_id, # one independent counter per account
reset: :year # restart numbering each calendar year
end
invoice = Invoice.create!(account_id: 1)
invoice.sequence # => 1, 2, 3 ... (per account, per year)
invoice.number # => "INV-2026-00001"
invoice.formatted_sequence # => "INV-2026-00001"
Invoice.next_sequence(account_id: 1) # => 4 (peek the next value, without creating)Options
| Option | Default | Purpose |
|---|---|---|
field (positional) |
:sequence |
Integer column holding the sequence โ the source of truth. |
into: |
nil |
String column to persist the formatted reference into (immutable display value). |
prefix: |
"" |
Prepended to the formatted value. |
padding: |
0 |
Zero-pad width of the numeric portion (0 = no padding). |
separator: |
"-" |
Joins prefix / period token / number in the default format. |
start_at: |
1 |
First value when the scope/period has no rows yet. |
scope: |
nil |
Column (or array of columns) the counter is scoped to โ e.g. one sequence per account_id. |
reset: |
:never |
:never / :year / :month / :day โ restart numbering each period (needs created_at, or your period_column:). |
period_column: |
:created_at |
Datetime column the reset: period is cut from. With assign: :manual set it to a stamp such as :finalized_at so a draft numbers into the period it is finalized in (assign_<field>! stamps it when blank); with the default a late-finalized draft numbers into its creation period. Not :updated_at. Declare it on the class that declares the format (or on an assign:-only re-declaration, which inherits it): a subclass restating the same format with a different period_column: is refused, since both series would print the same numbers. |
time_zone: |
app zone | Zone the reset: periods are cut in (a zone name as a String or Symbol, or an ActiveSupport::TimeZone). Defaults to config.time_zone (Time.zone_default), else UTC โ never the per-request Time.zone. Unknown names raise ArgumentError. |
template: |
nil |
->(seq, record) { ... } full custom formatter; overrides prefix / padding / period. To render the period, read record.sequenceable_period_time(:field) (the fixed-zone instant), not created_at. |
assign: |
:create |
:create numbers every record in before_create; :manual leaves the column NULL until assign_<field>! is called โ for invoices that get their number when finalized, not when drafted. Re-declaring the field (same class or an STI subclass) switches the mode; the last declaration wins. Pass only assign: to keep the rest of the config (see the notes). |
Default format
reset: |
Example | Shape |
|---|---|---|
:never |
INV-00001 |
prefix + padded |
:year |
INV-2026-00001 |
prefix + YYYY + sep + padded |
:month |
INV-202606-00001 |
prefix + YYYYMM + sep + padded |
:day |
INV-20260604-00001 |
prefix + YYYYMMDD + sep + padded |
Generated API
| Method | What it does |
|---|---|
formatted_<field> |
The formatted string โ the persisted into: value when set, otherwise computed. |
Model.next_<field>(scope_attrs) |
Peek the next integer for a scope without creating a record. |
assign_<field>! |
Number the record now (assign: :manual, or any row still blank): next value + into: string, save!d when persisted, left for your save when new. true when assigned, false when already numbered. |
<field>_assigned? |
Whether the record has its number. |
Model.pending_<field> |
Scope: rows still awaiting a number (WHERE <field> IS NULL). |
sequenceable_period_time(field) |
The instant anchoring the record's reset: period, in the field's fixed zone โ what the MAX range and the period token use. Read it from a template: instead of created_at. |
Notes
- The next value is
MAX(<field>) + 1within the scope (and period), so numbering is dense and ordered โ not random. - A
scope:column can't be Encryptable: the scopedMAXwould compare ciphertext, match no row and number every recordstart_at(two invoices of one region both1, which a unique index over the ciphertext never rejects). Declaring one raisesArgumentError, in either order. - Caller-supplied values are respected:
Invoice.create!(sequence: 100)is not overwritten (and itsinto:string is still formatted from100). - The number is drawn in
before_create, then reported to the siblings that already ran, whatever the include order:sluggable_by :numbergets"inv-1"in the sameINSERT(notniluntil a later save), an Auditable creation entry includes a trackednumber, and an Encryptable blind index on it is fingerprinted. - With
assign: :manual, numbering follows assignment order (the first invoice finalized is #1, whenever it was drafted); withreset:the period is taken from the row'screated_atby default โ so a draft finalized in a later period numbers into the period it was created in. Passperiod_column: :finalized_at(any datetime column;assign_<field>!stamps it when blank) to number it in the period it is finalized in. Switching an existing series to aperiod_column:is safe: a numbered row whose column is still NULL keeps counting by itscreated_at. Adatecolumn is stamped with the calendar day in the field'stime_zone:. - Generation reads
MAXthen inserts, so two concurrent inserts can race. It's best-effort โ add a scoped unique index on<field>(and oninto:) for a real guarantee, the same way you would for anyMAX-based numbering, and wrap the write inConcernsOnRails::Support::UniqueRetry.with_retries { โฆ }(passsavepoint: Invoicewhen it runs inside your own transaction, so a rejected attempt rolls back to a savepoint instead of aborting it on PostgreSQL).assign_<field>!saves in its own savepoint and puts the number back when the save fails, so a retriedassign_<field>!draws a fresh one. A create that rolls back (a lost race, a caller's transaction rolling back) restores the number andinto:string too, sowith_retries { invoice.save! }on the same new record draws afresh (Tokenizable, Hashable and Sortable's position behave the same; a value you supplied yourself is kept). - With STI, the rows that share a counter are those of the class that declared
sequenceable_by(and its subclasses): declared on the base, every subclass draws from one table-wide counter; declared on each subclass (INV-/CN-), each keeps its own sequence. A subclass that re-declares a different format under a declaring parent numbers its own series (see the next notes for what counts as different), while the parent's MAX still spans the whole table: the parent series may show a gap, never a duplicate. For independent per-type series without gaps, usescope: :type. Declared on an abstract class, each concrete table gets its own counter.next_<field>previews exactly what the receiving class's nextcreate!gets in each of these setups. Per-subclass series share one integer column, so index(type, <field>)rather than the column alone. -
reset:requires acreated_atcolumn; the period is taken from each row's creation time, in one fixed zone (time_zone:, defaultconfig.time_zone, else UTC). It never follows the per-requestTime.zone(Timezoneable,Time.use_zone): requests in different zones would disagree on which day it is and issue the same number, andformatted_<field>withoutinto:would render a different date per reader. Apps that never changeTime.zoneper request see no change. Withinto:(and notemplate:), theMAXalso counts rows whose stored value already carries this period'sprefix + token + separator. So a number stamped under a request zone before the upgrade is continued, never reissued; the cost is at most a gap. Withoutinto:, add a unique index before upgrading. -
Re-declaring a field (later on the same class, or on an STI subclass). A call that passes
assign:and/ortime_zone:and nothing else keeps every other option of the field's current config. Any other call โ one that passes a format option, or a baresequenceable_by :sequencewith no options โ restates the format: every option it omits takes its default, exactly as in a first declaration โ so a subclass switching toprefix: "CN-"repeatsinto:,reset:,padding:โฆ if it wants them. A draft/manual subclass should re-declare with ONLYassign::sequenceable_by :sequence, assign: :manualkeeps the parent'sinto:/prefix:/reset:and even itstemplate:object, and finalizes into the parent's series. Declare the parent fully (includingtime_zone:) before its subclasses: a subclass that callssequenceable_bycopies the config at that moment, so a later re-declaration of the parent (a reopened class,to_prepare, an engine override) is neither propagated to it nor re-checked. -
Which counter a re-declaration uses. Its full format tuple (
prefix,template,padding,reset,scope,into,separator,start_at) is compared with each inherited owner's config, walking up the chain. An identical tuple keeps that owner's counter (also after a subclass wentCN-and back toINV-); any other tuple starts the subclass's own series.assign:andtime_zone:are not part of the tuple. The comparison is exact, not visual: a tuple that differs only inscope:,start_at:orinto:, or that repeats atemplate:as a new lambda (Procs compare by identity), is a separate series and can render the parent's numbers (INV-1twice). Give such a series a distinct prefix or usescope: :type. Siblings that declare the same format without a declaring ancestor aren't detected either. -
One zone per shared counter. Under
reset:, a class that shares an owner's counter must cut the same periods: bothtime_zone:omitted, or both explicit and the same zone (aliases such as"Kolkata"/"Asia/Calcutta"match). Anything else raisesArgumentErrorat class-load time โ including an explicit zone against an omitted one that equalsconfig.time_zonetoday, because the omitted one is resolved at use time. Classes that number over different tables (under an abstract declarer) may pick different zones. Known limit: the check compares a class with the owner the format walk finds, so two STI leaves that restate an abstract declarer's format under an STI base with a different format are not compared, although they share one table and oneMAX. Don't mix zones among classes that share a table: declaretime_zone:on the class that first declares a format, and nowhere else. - For fixed-width display (
00042), make theinto:column a string โ integer columns drop leading zeros. - Distinct from
Hashable/Tokenizable, which generate random values; reach for those when the identifier must be unguessable.
๐ 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
}
endGenerated 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 stateBulk operations
Article.draft.transition_all(:publish) # => 12 โ runs :publish across every eligible rowReturns the Integer count of records transitioned, running in a transaction; records the
guard rejects are skipped (not errors). Unlike every other batch verb in this gem,
transition_all has no single-UPDATE fast path โ it always streams per record through
the guarded <event>! method, because that path runs validations via update! while a bulk
update_all would silently skip them. On an STI table each row is judged by its own class's
declaration: a base-class transition_all skips the rows of a subclass that re-declared
stateable_by without the event (it used to abort with NoMethodError) and transitions the rows a
subclass's wider from: accepts (the base's from: used to filter them out). With lock: true each record's guard is checked under its row
lock, so a row another process moved after the batch loaded it is skipped like any other rejected row
(it used to raise InvalidTransition and roll the whole batch back); a single <event>! still raises.
Timestamps and per-event hooks
stateable_by :status, states: %i[draft review published archived], default: :draft,
timestamps: true, # or %i[published archived] โ stamps <state>_at
transitions: { publish: { from: %i[draft review], to: :published }, archive: { to: :archived } }
article.publish! # status = "published" AND published_at = Time.current, in ONE update!
article.archived! # direct setters and transition_to! stamp too; the default state on create does not
def before_publish = check_embargo! # per-event hooks, fired inside the generic pair and the
def after_publish = notify_subscribers # same transaction: before_transition โ before_publish โ
# write โ after_publish โ after_transitiontimestamps: requires the <state>_at columns (checked at class load, one typed migration hint); the column is
not affixed, and one Rails owns (created_at / updated_at) is refused. Per-event hooks follow the affixed
event name (before_status_publish with prefix: true), may be private, and โ like the generic hooks โ fire only
for guarded <event>! transitions. Model.stateable_timestamps lists the stamped states.
A hook that vetoes with raise ActiveRecord::Rollback rolls the transition back, and <event>! returns false,
even inside your own transaction. After an aborted transition, the state and its <state>_at stamp go back to
their previous in-memory values, so a retry is guarded against the real state.
Row lock โ lock: true makes each guarded <event>! lock the row (SELECT ... FOR UPDATE) and check the
guard against the row's state, closing the check-then-write race between two processes. A record without
unsaved changes is reloaded under the lock (with_lock), so hooks and the write see the committed row. One
with unsaved changes (ticket.note = "fixed"; ticket.resolve!) can't be reloaded without losing them, so only
its state column is read and adopted, and the changes save with the transition, as with lock: false; under
optimistic locking such a record raises StaleObjectError if the row changed since it was loaded.
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.
- A generated method or scope that would override one the class already has โ from ActiveRecord (an event
lockโlock!, a statevalidโvalid?) or another concern (activenext toActivatable,restorenext toSoftDeletable) โ raisesArgumentErrorat class load; useprefix:orsuffix:. The reverse order (the other concern afterstateable_by) raises too, for the affixing concerns (Activatable, Expirable, Lockable, Anonymizable, Publishable, SoftDeletable, Schedulable) and Storable. Re-declaring Stateable itself (same class or subclass) is fine. One exemption: SoftDeletable, Publishable and Schedulable define their default-named scopes when included, so a state may take one of those names (SoftDeletable's.active, say) when that concern is included (in the same class) beforestateable_byand its own macro renames its scopes withprefix:/suffix:, before or afterstateable_by. Until it does, calling the shared scope raisesArgumentError. - Re-declaring without
default:keeps the earlier default while it is still one of the declared states; otherwise (or with an explicitdefault: nil) new records fall back to the column's database default. - Re-declaring replaces the generated methods too: the states, events and scopes the new declaration no longer lists are removed (hidden in an STI subclass, which keeps its parent's untouched), so a subclass with
states: %i[open closed]no longer answers its parent'sarchive!or.draft. A method of such a name that the class defined itself is left alone. - No persistence of transition history; combine with
Publishable/Schedulablefor time-based state tracking.
๐ Addressable
Normalize and format-validate a postal address spread across several columns โ one macro for whitespace cleanup, postal-code and ISO country-code checks, required-part presence, and a full_address helper. No external geocoding service required.
class Location < ApplicationRecord
include ConcernsOnRails::Addressable
addressable_by # standard columns: line1, line2, city, state, postal_code, country
end
loc = Location.create(line1: " 1 Infinite Loop ", city: "Cupertino",
state: "ca", postal_code: "95014", country: "us")
loc.line1 # => "1 Infinite Loop" (stripped + squished)
loc.state # => "CA" (2-letter code upcased)
loc.country # => "US"
loc.full_address # => "1 Infinite Loop, Cupertino, CA, 95014, US"Map onto your own column names and tune behavior:
class Place < ApplicationRecord
include ConcernsOnRails::Addressable
addressable_by line1: :street, postal_code: :zip, country: :country_code,
required: %i[line1 city postal_code country],
default_country: "GB", # country used when the record has none
validate_state: true, # opt-in US/CA state-code check
lengths: { line1: 100, city: 50, postal_code: 5..10 }, # max (Int) or min..max (Range)
allow_blank: %i[state], # these parts skip the length check when blank
normalize_country: true, # "Canada"/"CAN" -> "CA" (off by default)
verify_with: ->(rec) { Usps.verify(rec) } # opt-in external verifier
endOptions
| Option | Default | Purpose |
|---|---|---|
line1: โฆ country:
|
same-named columns | Map each canonical part to a real column. Missing columns are skipped. |
required: |
%i[line1 city postal_code country] |
Parts (by canonical name) that must be present. Each must map to an existing column. |
default_country: |
"US" |
Postal-code format used when the country column is absent or blank. Canonicalized like a country value ("Canada"/"CAN"/"ca" โ "CA"); anything that does not resolve to an ISO alpha-2 code raises ArgumentError at load time (nil means no default: a blank country gets the permissive rules). A present-but-unrecognized country value falls back to the permissive pattern instead โ see the postal note below. |
validate_state: |
false |
When true, validates the state against US / CA code sets. |
lengths: |
{} |
Per-part length limits: { line1: 100, city: 50, postal_code: 5..10 }. An Integer is a (positive) maximum; a Range is min..max (non-negative, satisfiable; endless 3.. and beginless ..50 allowed). Only the parts you list are checked. Bad bounds raise an ArgumentError at load time. |
allow_blank: |
false |
Per-field opt-out for the length check: an Array of parts (e.g. %i[line2 state]), or true for all parts. A blank value for an allowed part skips its length check. Independent of required:. |
normalize_country: |
false |
When true, canonicalize the country to its ISO 3166-1 alpha-2 code: an English name ("Canada", "United States", diacritics optional), a 3-letter alpha-3 ("CAN", "USA") or a common alias ("UK", "United States of America", ISO official names such as "Viet Nam", former names such as "Czech Republic") maps to the alpha-2 ("CA", "US", "GB"); unrecognized values are left untouched. This also lets postal/state validation recognize a named country. |
verify_with: |
nil |
A callable for real-world verification (see below). |
fingerprint: |
nil |
A string column to store address_fingerprint in so duplicates are one indexed query away: Location.with_address(record). Stamped after normalization and every other before_validation, ahead of your own validations (so validates :address_fingerprint, uniqueness: true dedupes), and again in before_save for save(validate: false). |
if: / unless:
|
nil |
Standard Rails validation conditions (Symbol, Proc, or Array) gating the address validations โ e.g. if: :on_addresses?. Normalization still runs unconditionally. Per class: a later addressable_by (or a subclass's) replaces the condition. Procs get Rails' callback arguments for their arity (-> {}, ->(record) {}, ->(record, _) {}). |
What it normalizes (in before_validation, plus a before_save backstop for saves that skip validation)
- Text parts:
strip+squish. -
postal_code: squish + upcase, with canonical spacing for CA (A1A1A1โA1A 1A1). -
country/state: upcased when they look like a 2-letter code (full names left alone). Withnormalize_country: true, a recognized ISO country name or alpha-3 code is canonicalized to its alpha-2 ("Canada"/"CAN"โ"CA"); unrecognized values are left as-is.
What it validates
- Presence of every
required:part. -
country: a 2-letter value must be a real ISO 3166-1 alpha-2 code. -
postal_code: matched against a per-country pattern (US, CA, GB, AU, DE, FR) with a permissive fallback for everything else. A strict per-country pattern is only applied when the country is a recognized ISO alpha-2 code (ordefault_countrywhen the country column is absent/blank); a present-but-unrecognized country (e.g. a full name like"Canada") uses the permissive pattern, so valid foreign codes aren't rejected against the wrong country. -
state: only whenvalidate_state: trueand the country is US/CA. -
lengths: each listed part's length (measured on the normalized value) must fit itsmin..max(Integer = max only). Errors mirror Rails, including singular/plural:"is too short (minimum is N characters)"/"is too long (maximum is 1 character)". A blank value satisfies a max-only rule and, by default, fails a minimum greater than 0 โ list the part inallow_blank:to skip the check when blank.allow_blankis independent ofrequired:: presence is still governed solely byrequired:, so a required part with a minimum that's left blank reports both"can't be blank"and"is too short โฆ". Note length is counted after normalization, so a CApostal_codeincludes the inserted space ("A1A 1A1"is 7) โ size limits accordingly.
External verification (verify_with:) โ runs only after structural validation passes, so you never spend an API call on an obviously-broken address. The callable receives the record and may either add to record.errors itself, or return:
| Return value | Effect |
|---|---|
true / nil
|
success |
false |
adds a generic :base error |
String |
added as a :base error |
Array |
each element added as a :base error |
Dedupe helpers
loc.address_fingerprint # => "9f2cโฆ" โ SHA-256 of the normalized parts: case, whitespace, postal spacing
# and a blank country (โ default_country) don't change it; nil for a blank address
loc.same_address_as?(other) # fingerprints equal โ other is a record or a digest String (never true for two blanks)
loc.address_changed? # any mapped column dirty (address_parts_changed? when you have an `address` column)
addressable_by fingerprint: :address_fingerprint # add a string column + index
Location.with_address(loc).where.not(id: loc.id) # the duplicates of loc (or pass a fingerprint)Notes
- Scope is format/structure only โ it checks shape, not real-world deliverability. Plug a USPS/Google/Smarty client into
verify_with:for that. - Error messages are plain English strings โ no host-app i18n setup required.
- Partial schemas just work: a model without a
line2(or any other) column simply omits that part. - Pairs with Normalizable when you also have non-address fields to clean up.
๐ท๏ธ Taggable
Lightweight, dependency-free tagging stored in a single string column โ no join tables, no tagging engine. Works on any database, including SQLite.
class Article < ApplicationRecord
include ConcernsOnRails::Taggable
taggable_by :tags # default column :tags
# taggable_by :skills, downcase: true # custom column, case-folded
end
article = Article.new
article.tag_list = "Ruby, Rails, Ruby" # accepts a String or an Array
article.tag_list # => ["Ruby", "Rails"] (stripped + de-duped)
article.add_tags("api")
article.remove_tags("Rails")
article.tagged_with?("ruby") # => membership predicate
article.save!
Article.tagged_with("ruby", "rails") # records carrying BOTH tags
Article.tagged_with("ruby", "go", any: true) # records carrying ANY tag
Article.all_tags # => sorted unique tags in use
Article.published.tag_counts(limit: 20) # => { "ruby" => 12, "rails" => 7, ... } โ a tag cloud, relation-aware
Article.normalize_tags! # repair rows written around the callbacks โ count rewrittenOptions
| Option | Default | Purpose |
|---|---|---|
delimiter: |
"," |
Character joining the stored tags (a tag must not contain it; an empty delimiter raises). |
downcase: |
false |
Case-fold tags on write so matching is case-insensitive. |
Notes
- Matching is boundary-safe โ searching
raildoes not matchrails. An explicit SQLESCAPEclause makes tags containing_/%match literally on every adapter. -
tagged_withmatches case-insensitively on every adapter โLIKEon SQLite and MySQL,ILIKEon PostgreSQL โ so one call means one thing everywhere (how non-ASCII characters fold is still the database collation's business). The Ruby-side helpers (tagged_with?,all_tags,tag_counts) compare exactly, sodowncase: trueโ which folds on write โ is what makes the scope and the helpers agree. - Tags are normalized in
before_validation(plus abefore_savebackstop for saves that skip validation), so a directrecord.tags = "a, b"assignment is cleaned too. An empty list storesNULL. - An Encryptable tag column holds ciphertext, which
tagged_with'sLIKEnever matches โ declaring it raisesArgumentErrorat declaration, in either order (an undeclared default:tagscolumn is refused whentagged_withis called). -
tagged_withmatches the normalized column form ("ruby,rails") โ which every write through the model produces. A row written around the callbacks (update_column,update_all, raw SQL, an import) as"ruby, rails"is found bytagged_with?but not bytagged_with; runModel.normalize_tags!(relation-aware, oneupdate_columnsper changed row in a transaction, no validations/callbacks, returns the count;unscopedto include default-scoped-away rows) to rewrite such rows. -
tag_countsruns oneGROUP BYon the raw column โ identical tag strings ship once with their row count and are split in Ruby โ so it scales with distinct tag strings, not rows; ordered by count desc then name,limit:keeps the top N. - Reach for
acts-as-taggable-onwhen you need tag contexts, ownership, or polymorphic tags shared across models.
๐งผ Sanitizable
Opt-in HTML sanitization for string attributes โ defense-in-depth, not a replacement for Rails' default output escaping (<%= %> already escapes). Reach for it on the rare column you render as trusted HTML (raw / html_safe) or that must stay plain text. Zero extra dependencies โ it uses the rails-html-sanitizer that already ships with Action View.
class Article < ApplicationRecord
include ConcernsOnRails::Sanitizable
# DEFAULT (on: :read) โ non-destructive. The column stays raw; a
# `sanitized_<field>` reader returns the cleaned value:
sanitizable :body, with: :safe_list # => article.sanitized_body
sanitizable :summary, with: :strip # => article.sanitized_summary
sanitizable :body, with: { tags: %w[b i a], attributes: %w[href] }
# EXPLICIT destructive opt-in โ for plain-text-only columns only:
sanitizable :title, with: :strip, on: :write # overwrites in before_validation
end
article = Article.new(body: "<b>Hi</b><script>alert(1)</script>")
article.body # => "<b>Hi</b><script>alert(1)</script>" (raw, intact)
article.sanitized_body # => "<b>Hi</b>alert(1)" (script tag dropped)Presets (with:)
| Preset | Behavior |
|---|---|
:strip |
Remove all tags, keep inner text (the default). |
:safe_list |
Rails' allow-list: keep formatting tags, drop <script> / <iframe>. |
:no_links |
Strip only <a> tags, keep their text. |
:none |
No-op (declare the field / reader without transforming). |
Array |
Custom tag allow-list, e.g. with: %w[b i a]. |
Hash |
{ tags: [...], attributes: [...] } allow-list. |
Proc |
Used as-is (you own the non-String guard). |
Serialization & clean-up
article.sanitized_attributes # => { "body" => "<b>Hi</b>alert(1)", "summary" => "sum" } โ every declared (loaded) field, cleaned
article.as_json(sanitized: true) # declared fields swapped for their sanitized form, the rest raw
render json: article.as_json(sanitized: [:body], only: %i[id body]) # subset; composes with only:/except:/methods:
article.as_json(sanitized: true, include: :comments) # nested records are sanitized too
article.as_json(sanitized: true, include: { comments: { sanitized: false } }) # โฆunless a child opts out
Article.sanitize_all! # repair on: :write rows in place โ count changed (on: :read columns stay raw)
Article.sanitize_all!(:body) # name an on: :read field to overwrite it โ destroys the raw value
Article.where(legacy: true).sanitize_all!(:body) # scope-aware, subset of fieldsNotes
-
on: :read(default) is non-destructive: it adds asanitized_<field>reader and leaves the stored column untouched. -
on: :writeoverwrites the column inbefore_validation(plus abefore_savebackstop forupdate_attribute/save(validate: false)) โ lossy and irreversible (never use it on code, Markdown, math, or prices), and bypassed only byupdate_column/update_all/ raw SQL. On a persisted record only the fields this save changes are rewritten, so a non-idempotentProcwriter is never re-applied to a stored value (and an unrelated save does not repair a row written around the callbacks โsanitize_all!does). -
with: :strip, on: :writestores plain text, not HTML-escaped text:"<b>Tom</b> & Jerry"is stored as"Tom & Jerry"(so<%= %>shows it once, not asTom &amp; Jerry).</>are deliberately kept encoded โ the stored value can never become markup, even rendered withrawโ so a typeda < bis stored asa < b. An&that would read back as a character reference (a literal&copy;) also stays encoded (a static, linear-time rule:&followed by#, aname;, or an HTML5 semicolon-less legacy name), so re-saving andsanitize_all!are idempotent.sanitize_all!uses the same plain-text form, and repairs rows stored double-escaped by earlier versions. Theon: :readreader andsanitized:serialization still return HTML-escaped text. -
sanitize_all!is the repair tool for that bypass (and for rows written before the concern was added): oneupdate_columnsper row that actually changes, skipping validations/callbacks on purpose, inside a transaction. A bare call repairs theon: :writefields only โ with none declared it returns0without a query. -
sanitized:sanitizes the serialized value, so it composes with Maskable in either include order:as_json(masked: true, sanitized: true)never falls back to the raw column. - For full user-authored rich text, prefer Action Text.
๐ Maskable
Non-destructive display masking for sensitive attributes. Each declaration adds a masked_<field> reader and never writes the column โ the raw value stays in the database (masking is a presentation concern). Dependency-free.
class User < ApplicationRecord
include ConcernsOnRails::Maskable
maskable :email, with: :email # => user.masked_email "j****@example.com"
maskable :card, with: :credit_card # => user.masked_card "**** **** **** 4242"
maskable :ssn, with: :last4, mask: "โข"
maskable :token, with: ->(v) { "#{v.to_s[0, 3]}โฆ" }
endPresets (with:)
| Preset | Result |
|---|---|
:email |
j****@example.com (first char + domain) |
:phone |
***-2671 (last 4 digits) |
:credit_card |
**** **** **** 4242 (last 4 digits) |
:last4 |
keep the last 4 characters |
:all |
mask every character (the default) |
Proc |
used as-is (you own the non-String guard) |
mask: sets the mask character (default *). nil passes through; any other non-String value (an integer SSN or phone column) is stringified and then masked โ never shown raw. The presets fail closed: a String without the expected shape โ no @ for :email, four or fewer digits for :phone / :credit_card โ gets the full :all mask, never the raw value. To strip dangerous HTML instead, see Sanitizable.
Serialization โ mask in the response, not just in the view
user.masked_attributes # => { "email" => "j****@example.com", "card" => "**** **** **** 4242" } (loaded columns only, like attributes)
user.as_json(masked: true) # every declared field swapped for its masked form, the rest raw
user.as_json(masked: [:email]) # just these fields (undeclared ones raise)
user.to_json(masked: true, only: %i[id email]) # composes with only:/except:/methods:/include:
render json: users.map { |u| u.as_json(masked: true) }Plain as_json / to_json are untouched, so nothing changes until you ask.
๐ฐ Monetizable
Money handling for an integer "subunit" column (e.g. cents) โ exact and float-free via BigDecimal. monetizable :price_cents derives three methods (the _cents suffix is stripped):
class Product < ApplicationRecord
include ConcernsOnRails::Monetizable
monetizable :price_cents # => price / price= / formatted_price
monetizable :shipping_cents, as: :shipping
monetizable :total_cents, unit: "โฌ", delimiter: ".", separator: ","
end
product.price = 19.99 # stores price_cents = 1999 (rounded to whole cents)
product.price # => BigDecimal 19.99
product.formatted_price # => "$19.99"| Method | Returns |
|---|---|
price |
the amount as a BigDecimal (cents รท 100) |
price= |
assign in major units; rounded to whole cents. Strings are read as plain decimals ("19.99") or in the field's own display format ("$1,234.50", "โฌ1.234,50" with separator: ","), so formatted_price round-trips. The unit is only recognized at the start or end; in a delimiter: "." field with another separator, "1.234" means 1234 (like "โฌ1.234"), but a leading 0 group is never grouping ("0.500" is 0.5 there, and "0,500" in a "," field is nil, not 500). Garbage, NaN, Infinity and oversized input (over 64 characters, a 3-digit exponent, or โฅ 10^24 โ a parse-cost bound, far above a bigint column's ~9.2e18 subunits, so the database may still reject a large amount at save) become nil
|
formatted_price |
a display string ("$1,234.56"); accepts per-call overrides: formatted_price(unit: "โฌ", delimiter: ".", separator: ",")
|
Aggregates โ class methods that follow the current scope, exact and float-free:
Product.sum_price # => BigDecimal SUM(price_cents) / 100
Product.in_stock.average_price # average_ / minimum_ / maximum_ too โ nil on an empty set (sum is 0)
Order.paid.formatted_sum_total # => "โฌ3.500,50" every aggregate has a formatted_ twin (overrides accepted)Options: as: (explicit method name โ required when the column does not end in _cents), unit: ("$"), precision: (2, or every decimal a power-of-ten subunit_to_unit: carries โ 3 for 1000, so "KD1.999" round-trips), delimiter: (","), separator: ("."), subunit_to_unit: (100). nil stays nil across all the accessors. A unit: containing the delimiter: or separator: raises (formatted output could not be read back), and so does a separator: equal to the delimiter: (separator: "," needs delimiter: "." or another mark) or an empty separator: with precision: above 0 โ at the macro and in per-call formatted_ overrides. precision: and subunit_to_unit: are coerced ("2" works, "two" raises) at the macro and in per-call formatted_ overrides alike.
๐ Auditable
Lightweight change history ("paper_trail-lite") stored as JSON entries in one text column on the same table โ no extra tables, no versioning engine.
class Product < ApplicationRecord
include ConcernsOnRails::Auditable
auditable_by :price, :status # default column :audit_log
# auditable_by :price, into: :history,
# actor: -> { Current.user&.email }, # stamps "by" on each entry (or actor: :updated_by_id)
# max_entries: 50 # keep the newest 50
end
# Or set the actor once for every audited model โ models that omit actor: use it, actor: nil/false opts out:
ConcernsOnRails.setup { |config| config.audit_actor = -> { Current.user&.id } }
product.update!(price: 200)
product.audit_trail
# => [{"field"=>"price", "from"=>100, "to"=>200, "at"=>"2026-06-10T12:34:56.123456Z", "by"=>"admin@shop.com"}]
product.last_change_for(:price) # newest entry for one field
product.audited_changes_since(1.day.ago) # recent entries, oldest first
product.clear_audit_trail! # wipe the column (skips callbacks)One entry is recorded per changed field per save (creates record "from" => nil), appended in the same INSERT/UPDATE โ zero extra queries. The capture runs at the write boundary, after every before_* callback, so entries record the values actually written: a tracked column your own later callback sets or rewrites is recorded as stored, and a column a sibling generates in the create (a Sequenceable number, a Tokenizable/Hashable value, a slug built from one, an Encryptable blind index) is included, whatever the include or declaration order.
Options: into: (:audit_log), actor: (a lambda with no required parameter or a block is instance_exec'd on the record; a ->(record) lambda, a symbol proc or any other callable is called with the record โ bare unless its #call requires an argument; or a Symbol naming a record method such as :updated_by_id; omit it to take the gem-wide config.audit_actor, an explicit nil/false opts out of that; "by" omitted when it resolves to nil), max_entries: (200; keeps the newest N, nil = unlimited), max_value_length: (nil; truncates long String from/to values to the first N characters + โฆ).
Notes
- Writes that skip callbacks (
update_column(s),touch,increment!) are not audited;save(validate: false)is. - Values are JSON-coerced (times โ ISO8601 UTC strings,
BigDecimalโ precision-safe numeric string); a corrupt column decodes as[]and is replaced on the next tracked save. - A native
json/jsonbcolumn works too โ the trail is stored as a JSON array, and the entriesaudit_trailreturns are detached from the column value, so editing one never rewrites the stored history. Declare it without a default (t.json :audit_log) โ MySQL rejects a literalDEFAULTon a JSON column; the column staysNULLuntil a tracked field first changes, which reads as[]. -
"at"is ISO8601 UTC with microseconds, soaudited_changes_sincetells apart two edits in the same second. Second-precision entries written by 1.29.0 and earlier still parse; they only say "during that second", so they match any cutoff within it. - Per-record and bounded by design โ reach for
paper_trail/auditedwhen you need reify/undo or audit queries across models.
๐ Lockable
Failed-attempt tracking + account lockout ("Devise lockable-lite") for apps rolling their own authentication (Rails 8 auth generator / has_secure_password) โ which ships no brute-force protection out of the box. Two columns on the model's own table (plus an optional unlock-token column for self-service unlock links); no mailers.
class User < ApplicationRecord
include ConcernsOnRails::Lockable
lockable_by max_attempts: 5, unlock_in: 15.minutes
# lockable_by attempts: :failed_logins, locked_at: :locked_until_at,
# prefix: :account # => .account_locked / .account_unlocked
end
user.register_failed_attempt! # atomic SQL increment; locks at max_attempts
user.access_locked? # true while locked (lapses after unlock_in)
user.attempts_remaining # => 3 (for "3 attempts remaining" messaging)
user.reset_failed_attempts! # call on successful login
user.lock_access! # manual lock (hooks: before/after_lock)
user.unlock_access! # manual unlock (hooks: before/after_unlock)
User.locked / User.unlocked # expiry-aware scopes
User.unlock_expired # => 3 โ unlocks every row whose unlock_in window has elapsed
# Self-service unlock (Devise's :email strategy, minus the mailer) โ needs a string column:
# lockable_by max_attempts: 5, unlock_token: :unlock_token
user.lock_access!; user.unlock_token # minted in the same write as the lock โ mail it as a link
User.unlock_by_token(params[:token]) # constant-time lookup; unlocks once (hooks fire), returns the user or nilOptions: attempts: (:failed_attempts, must be an integer column), locked_at: (:locked_at, datetime column), max_attempts: (5; nil = count but never auto-lock), unlock_in: (nil = locked until manual unlock; a duration makes the lock lapse by itself), unlock_token: (nil; a string column that receives a 43-char URL-safe token on lock, is cleared by every unlock path, and is honoured only while the lock is live โ so unlock_in: doubles as the link's TTL), prefix: / suffix: (affix the scope names).
Bulk operations
unlock_expired clears locked_at and zeroes the attempts counter on every row whose
locked_at + unlock_in has passed, exactly as unlock_access! does โ returns the Integer
count, runs in a transaction. Returns 0 without querying when unlock_in is nil (manual
unlock only). Because unlock_access! persists via update_columns (which already skips
validations), the single-UPDATE fast path and the per-record path are equivalent here โ
unlike Publishable/Expirable/Activatable, this one has no validators gate. It does have the STI
rule: on an STI table unlock_expired always streams per record, so a subclass's after_unlock and lockable_by columns apply to its rows.
Notes
- The increment is SQL-side (
COALESCE(attempts, 0) + 1viaupdate_counters), so concurrent failures never lose updates and a NULL counter needs no column default; a locked account stops counting. - The lock itself is a conditional
UPDATE(only while the row is unlocked), so concurrent failures crossing the threshold lock once: the loser adopts the existing lock and unlock token and fires no hooks. - Expiry is lazy: readers and scopes treat a stale lock as unlocked but never write. The column is cleared by the next
unlock_access!or failed attempt (quietly there โ no unlock hooks fire from a failed login). -
lock_access!persists via one conditionalUPDATE(only while the row is unlocked in the database โ a concurrent loser adopts the winner's lock and fires no hooks) andunlock_access!viaupdate_columnsโ validations and AR callbacks deliberately bypassed so an otherwise-invalid record can still be locked (this also skipsupdated_at/Auditable). Thebefore/after_lock,before/after_unlockhooks run in a transaction;after_lockis the place for the "account locked" email. - Optimistic locking: the raw writes (the increment, the lock claim, the lapsed-lock reset, the unlock-token claim) bump
lock_versionin SQL and mirror the bump into the instance as Rails'increment!does, so the same instance โ and the recordunlock_by_tokenreturns โ saves afterwards withoutStaleObjectError. - Reach for Devise's
lockablewhen you need its unlock emails or per-strategy unlocks (theunlock_token:option covers a self-service unlock link; sending it is up to yourafter_lock).
๐ช Aliasable
Rails 6.1+ for the query side.
joins/where/eager_loadthrough an alias needs Rails to resolve awhere-hash key to an association, which landed in 6.1. The read/write side (readers, writers,build_/create_, the ids pair) works on 6.0.
Alias an existing association under a second name with full semantics โ read, write/assign, build/create, and the query side (joins / includes / where-hash) โ not just a delegated reader. (alias_attribute covers columns only; Rails has no built-in association aliasing.)
class Book < ApplicationRecord
include ConcernsOnRails::Aliasable
belongs_to :author
has_many :chapters
alias_association :writer, :author # alias_method order: new, old
alias_association :sections, :chapters
end
book.writer # same cached object as book.author
book.writer = user # assigns through the original association
book.build_writer(...) # build_ / create_ / create_! / reload_ (singular)
book.sections << chapter # the same CollectionProxy as book.chapters
book.section_ids # ids reader/writer (collection)
Book.joins(:sections).where(sections: { title: "Intro" })Options: alias_association new_name, source_name โ repeatable; declare it after the source association; re-declaring an existing alias with the same source (e.g. in a subclass that redefined the source) is allowed and refreshes it, while repointing an alias at a different source raises. Keyword options: only:/except: narrow the generated methods by group (:reader, :writer, :build, :reload, :ids); deprecated: true (or a String hint) makes every delegator warn through ConcernsOnRails.deprecator โ the gradual-rename story; alias_foreign_key: true (belongs_to only) also aliases <alias>_id (and <alias>_type when polymorphic) via alias_attribute.
Notes
- One loaded cache under two names:
record.association(:alias)ISrecord.association(:source), and only the source macro installs callbacks โdependent:, counter caches, autosave and validations run exactly once. -
accepts_nested_attributes_for :aliasbehaves exactly like it does for the source (create, update,_destroy,reject_if:,limit:; each child saved once). It turns autosave on for the source association, and child validation errors are keyed under the source name. Declared in a subclass, it sets autosave on the inherited source reflection, so the parent autosaves too โ the same as stock Rails nested attributes on an inherited association. - The where-hash key must match the name you joined under (stock-Rails rule):
joins(:sections).where(sections: {...})works;joins(:chapters).where(sections: {...})does not. - The
belongs_toforeign-key attribute is not aliased โ pair withalias_attribute :writer_id, :author_idif you need it. -
has_and_belongs_to_manycannot be aliased (usehas_many :through).has_many/has_one :throughcan โ the copy pinssource:so it is not re-derived from the alias name; if your classes load lazily and the through model names the source differently (e.g.belongs_to :authorbehindhas_many :authors), declaresource:explicitly on the original association. Aliases are inherited by subclasses.
โ๏ธ Storable
Typed, defaulted, optionally-validated accessors over a single JSON (or text) column ("store_attribute-lite"). Rails' native store_accessor is untyped on every supported version โ a form-submitted "true" stays the String "true" โ with no defaults and no per-key dirty tracking; that gap is why the store_attribute / jsonb_accessor gems exist.
class Account < ApplicationRecord
include ConcernsOnRails::Storable
storable_by :settings,
theme: { type: :string, default: "light", in: %w[light dark] },
notifications: { type: :boolean, default: true },
items_per_page: { type: :integer, default: 25 },
trial_ends_at: { type: :datetime }
storable_by :flags, { beta: { type: :boolean, default: false } }, prefix: :flag
end
account.theme # => "light" (virtual default; nothing persisted)
account.notifications = "0" # params arrive as stringsโฆ
account.notifications # => false (โฆand read back cast)
account.notifications? # boolean keys get a predicate
account.items_per_page_changed? # per-key dirty (and items_per_page_was)
account.reset_theme # drop the key โ the default applies again
account.flag_beta # affixed accessor
Account.where_theme("dark") # one scope per key, cast like the writer โ SQLite json_extract,
Account.active.where_flag_beta(true).where_items_per_page(50) # PostgreSQL ->>, MySQL JSON_EXTRACT
Account.where_theme(nil) # unset key, explicit null, or NULL columnOptions (per key): type: (:string default, :integer, :float, :decimal, :boolean, :date, :datetime, :json), default: (a value, or a Proc instance_exec'd per read; cast through the key's type: exactly as a stored value is when the key declares one, so default: "false" on a :boolean key reads false; a default that cannot be cast reads nil, :json defaults and keys declared without type: return the default as written โ default: false stays false, 25 stays 25), in: (inclusion validation, errors on the accessor name), query: (false skips this key's where_ scope). Macro options: prefix: / suffix: affix the generated method names (the collision escape hatch), query: sets the default for every key in the call. The macro is repeatable โ repeat calls for the same column merge keys, different columns are independent, and subclasses can add keys without affecting the parent.
Notes
- Works on a plain
textcolumn (JSON encoded/decoded internally), a nativejson/jsonbcolumn, or a column the host app alreadyserialized โ detected automatically.serializeitself is never used, so the Rails 7.1 API drift is irrelevant. - nil vs unset: a written
nil(explicit JSON null) reads back asniland does not fall back to the default;reset_<key>removes the key so the default applies again.:decimalis stored as a precision-safe string,:date/:datetimeas ISO8601 (datetime in UTC at microsecond precision). - A
:datetimekey behaves like a datetime column on the same model. Withtime_zone_aware_attributeson (every Rails app), a zone-less String (adatetime-localform value) is wall-clock time inTime.zone, aDateis midnight there, and the reader returns anActiveSupport::TimeWithZonein the currentTime.zone. Without it, both followActiveRecord.default_timezone.where_<key>parses its argument the same way, adatetime_selectvalue is wall-clock time inTime.zone, and adefault:reads back as the same value stored would. The stored UTC form is unchanged, so existing rows read back as the same instant. - Writing one key dirties (and saves) the whole column โ concurrent writers to different keys are last-write-wins on the hash. Undeclared keys are preserved.
:jsonreaders return a dup: reassign, don't mutate in place. - Generated names are collision-checked against existing methods and columns at macro time (
ArgumentError; affix to escape). Read-side casting never raises โ corrupt column JSON decodes as{}, garbage values cast tonil. -
Querying: every key gets a
where_<accessor>(value)equality scope โjson_extracton SQLite,->>on PostgreSQL (atextcolumn is cast tojsonb),JSON_UNQUOTE(JSON_EXTRACT())on MySQL/MariaDB, which also gets aJSON_TYPEpredicate so a stored JSONnullis never confused with the string"null". The value is cast exactly as the writer stores it (where_items_per_page("50")works; one that will not cast raises), andwhere_<key>(nil)matches an unset key, an explicit JSON null and aNULLcolumn on all three. Defaults are not queryable (a never-written key is absent in the DB).:jsonkeys, other adapters and a column that is alsoencryptable(ciphertext, not JSON โ either declaration order) raise;query: falseopts out, and awhere_<accessor>the model already defines is left alone with a deprecation warning rather than overwritten. A row holding blank or corrupt JSON reads as an unset key on SQLite (json_validguard) but aborts the whole query on PostgreSQL and MySQL โ there is no portable guard. Reach forstore_attribute/jsonb_accessorfor jsonb operators, ranges or containment queries.
๐งฎ CounterCacheable
Conditional, denormalized association counters ("counter_culture-lite"). Rails' built-in belongs_to ..., counter_cache: true maintains exactly one column counting every child โ it can't keep an approved_comments_count next to a comments_count, and has no way to repair drift. Declared on the child, this keeps one or many parent columns in sync, each with an optional condition.
class Comment < ApplicationRecord
include ConcernsOnRails::CounterCacheable
belongs_to :post # declare the belongs_to FIRST
belongs_to :author, class_name: "User"
counter_cacheable_by :post # posts.comments_count
counter_cacheable_by :post, count: :approved_comments_count,
if: -> { approved? } # conditional
counter_cacheable_by :author, count: :posts_count, touch: true
end
post.comments_count # maintained on create / destroy / update
Comment.recount_counter_caches! # repair drift / backfill every counter
Comment.recount_counter_caches!(:post, parents: imported_posts) # repair just these parents (ids, records or a relation)Counters are adjusted with update_counters (a single atomic SQL COALESCE(col,0) ยฑ 1) inside the record's own save transaction. The update path handles the full matrix: a foreign-key reparent moves the count from the old parent to the new one, a condition flip increments/decrements in place, and the two compose. A destroy decrements only when its DELETE actually removed the row (as Rails' native counter cache does) โ destroying a stale second instance, or a never-saved record, writes nothing โ and it reads the parent and the if: verdict from the persisted values, so an unsaved reparent or condition flip can't redirect it. A belongs_to ..., primary_key: :code is honoured: parents are addressed by that key (live adjustments and recount_counter_caches! alike), never by id. A child destroyed by its parent's own has_many ..., dependent: :destroy does not decrement that parent (as with Rails' native counter cache) โ the row is going away, and bumping it first would trip the parent's lock_version. A child destroyed through a has_one ..., dependent: :destroy still decrements (it may be a replacement, where the parent survives), but that UPDATE leaves the parent's lock_version alone, so destroying a locked parent never trips over its own child. With lock_version on the parent, each adjustment (the has_one destroy decrement excepted) bumps it in SQL, and the parent instance the child's association holds (post.comments.create!'s post, or the one passed as post:) gets the bump and the counter delta mirrored in memory (the in-memory half of native increment!; a counter you assigned and haven't saved is left pending as assigned) โ so post.update! afterwards doesn't raise StaleObjectError โ and a rolled-back save of the child takes the mirror back off the parent. One pre-existing case can still raise it: a has_many :through ..., dependent: :destroy (Rails deletes the join rows without destroyed_by_association).
Options (counter_cacheable_by association, โฆ, repeatable โ re-declaring the same association + count: replaces that rule, e.g. an STI subclass narrowing it with if:): count: (the parent column; default "<table_name>_count"), if: (a callable evaluated against the record โ counts only when truthy; the previous state is reconstructed for updates), touch: (false; also bump the parent's updated_at).
Notes
- The
belongs_tomust be declared before the macro (the reflection is validated at declaration). Polymorphic associations are not supported. - Don't also set native
counter_cache: trueon the same column โ both would fire and double-count. - Counters track the persisted record; writes that skip callbacks (
update_column(s),update_all,delete) are not tracked โ runrecount_counter_caches!to reconcile. Bare, it rewrites every parent (portable across adapters, but O(n) for conditional counters) โ a maintenance operation, run it offline. On an STI table every class's rows are tallied under that class's own rule for the column, so a repair from any class of the tree matches the live counts (types are resolved without instantiating a row; a stored type that no longer resolves counts under the base class's rule). Withparents:it zeroes and re-tallies only those parents (O(their children)) in one transaction that locks those rows before tallying, so repairing one imported post is safe on the request path. - Reach for
counter_culturewhen you need multi-level rollups, delta columns, or after-commit execution.
๐ Encryptable
Transparent per-field encryption for sensitive columns (SSN, DOB, cards) โ AES-256-GCM on stdlib OpenSSL, zero new dependencies. Reads and writes stay plaintext in memory; the DB column holds a versioned, authenticated Base64 envelope.
ConcernsOnRails.configure_encryption { |c| c.key = ENV["ENCRYPTION_KEY"] }
class Patient < ApplicationRecord
include ConcernsOnRails::Models::Encryptable
encryptable :ssn, :notes # transparent string encryption
encryptable :dob, type: :date # decrypts back to a Date
encryptable :email, blind_index: true # + a queryable fingerprint column
end
patient.ssn # => "123-45-6789" (plaintext in memory)
patient.ssn_ciphertext # => "AQEA..." (what's actually at rest)
patient.ssn_encrypted? # => true
Patient.find_by_email("a@b.com") # exact-match lookup via the blind index
Patient.where_email("a@b.com") # chainable Relation (accepts arrays too)Options (encryptable *fields, โฆ, repeatable): type: (cast the decrypted value โ :string default, :integer, :float, :decimal, :boolean, :date, :datetime; a :datetime field behaves like a datetime column on the model: under time_zone_aware_attributes zone-less input is read in Time.zone and it reads back as a TimeWithZone, while the plaintext stays UTC ISO8601), key: (per-field override; a String or lazy Proc), blind_index: (true, or { column:, expression: } โ maintains a deterministic keyed-HMAC companion column, default <field>_bidx, for equality lookups; expression: normalizes symmetrically on write and query; the digest is of the canonical plaintext, so a :datetime is found by any rendering of the instant in any Time.zone and a String finds a typed field โ rows indexed before still match the lookup that found them, and :datetime fields should be reindexed once).
Key rotation โ bump the key id, keep the old key for decrypting, re-encrypt, drop the old key:
ConcernsOnRails.configure_encryption do |c|
c.key = ENV["ENCRYPTION_KEY_V2"] # encrypts every new write
c.key_id = 1 # stamped into the envelope header (any id 0..255)
c.previous_keys = { 0 => ENV["ENCRYPTION_KEY_V1"] } # still DECRYPTS rows written before the rotation
end
Patient.needs_reencryption.count # rows still under an old key โ a prefix compare on the envelope, no decryption
Patient.reencrypt_all! # rewrite them (and their blind indexes) under the current key โ count
patient.ssn_key_id # => 1
# then remove `0 =>` from previous_keysOn the model itself both sweep the whole table, bypassing default_scope โ soft-deleted and unpublished rows are rotated too, so dropping the old key cannot strand them. On a relation (Patient.where(org_id: 1).reencrypt_all!) they cover exactly that relation; chain from unscoped to include hidden rows in a subset.
Reads pick the key by the envelope's id, so old and new rows coexist; find_by_<field> / where_<field> match blind-index digests under the current and previous keys during the window. Per-field key: fields sit outside rotation.
Notes
- Wrong key / tampered ciphertext / malformed envelope raise
Encryption::DecryptionError. Encrypted field names are auto-registered with Rails'filter_parameters(via the gem's railtie), so they're redacted from request logs. - Rotation is gem-level (
key_id/previous_keys);reencrypt_all!streams withfind_eachand rewrites each row with one UPDATE โ no validations/callbacks (only the ciphertext changes), nolock_versionbump (so records open in edit forms don't go stale), guarded on the ciphertext it read so a concurrent write is never reverted, and skipping any field with an unsaved change. A row whose key id is no longer configured raisesDecryptionErrornaming the id. - Reach for
lockboxor Rails 7+ nativeencryptswhen you need Rails-managed key infrastructure (KMS, per-record keys) or deterministic encryption.
๐ต๏ธ Anonymizable
Declarative right-to-erasure ("GDPR-lite"): each personal-data field gets an erasure strategy, and anonymize! rewrites them all in one UPDATE while stamping anonymized_at. Completes the sensitive-data suite โ Maskable masks display, Sanitizable strips HTML, Encryptable protects at rest; Anonymizable destroys.
class User < ApplicationRecord
include ConcernsOnRails::Models::Anonymizable
anonymizable :email, with: :email # unique fake address
anonymizable :first_name, :last_name, with: :redact
anonymizable :ssn, with: :nullify
anonymizable :bio, with: ->(value) { value && "removed by user request" }
end
user.anonymize! # hooks + single UPDATE + stamp, in one transaction
user.anonymized? # => true
User.not_anonymized # scope (and .anonymized)
User.where(...).anonymize_all! # batch; returns the count, skips stamped recordsStrategies (with:): :nullify, :redact ("[REDACTED]"), :hash (SHA-256 โ deterministic pseudonymization, joins keep working), :email (random unique anon-โฆ@anonymized.invalid, so NOT NULL + unique email columns survive), :random_hex, or a callable (->(value) / ->(value, record)). Presets pass nil through untouched.
Options (repeatable; field rules merge): stamp: (default :anonymized_at; false disables stamping + scopes), clear_audit_trail: (default true โ when an erased field is also auditable_by, the trail holding its plaintext history is cleared in the same UPDATE), prefix:/suffix: (scope names; last explicit value wins, per option โ a later call renames the scopes).
Notes
- Deliberately
update_columns: erasure is never blocked by validations and never runs callbacks that could copy old values elsewhere. Values still serialize through the attribute types, so anencryptablefield stores a fresh ciphertext envelope โ never plaintext. - Optimistic locking: with a
lock_versioncolumn the single UPDATE goes throughupdate_all, which bumpslock_versionin SQL (the row's value + 1). An edit form loaded before the erasure then raisesStaleObjectErrorinstead of writing the personal data back, and a stale anonymizing instance still erases. -
before_anonymize/after_anonymizehooks run inside the write's own savepoint; the record reloads afterwards (erasure is terminal for the instance). A hook that raises, or vetoes withraise ActiveRecord::Rollback, undoes the erasure and restores the in-memory values.anonymize!then returnsfalse, even inside your own transaction. -
anonymize_all!on the model itself (User.anonymize_all!) also erases the rows the gem's own default scopes hide โ SoftDeletable's trash and Publishable's drafts underdefault_scope: trueโ since a soft-deleted account is the typical erasure subject; your own default scopes (a tenant, a discriminator) still apply. On a relation (User.where(account_id: 1).anonymize_all!, an association, ascopingblock) it erases exactly that relation, default scope included like any other chain; start fromwith_deleted/unscopedto include hidden rows in a subset. -
anonymize_all!makes as much progress as it can. Each record is erased in its own savepoint, so a record whose hook vetoes (for example, one under a legal hold) is skipped and the others are still erased. The return value counts only the records actually erased. A hook that raises an exception is still an error: it propagates and rolls the batch back. - Never blocked by crypto state:
:nullifyreads nothing,:redact/:email/:random_hexonly check presence (from the stored ciphertext for an encrypted field โ no decryption), and:hash/callables write a fresh random 64-hex value (cast through the field's type) when an encrypted value cannot be decrypted โ random per row, so unique indexes survive โ and one bad row never rolls backanonymize_all!. - Slugs (
slug: :autodefault /true/false): a friendly_id slug built from an anonymized column (sluggable_byfield, or thecandidates:columns when given; a bare friendly_id model's base column) is replaced in the same UPDATE by a random slug sized to the slug column'slimit(at least 16 random hex characters, oranonymize!raises namingslug: false; a unique-index collision is retried with a fresh slug in a savepoint), and the record's friendly_id history rows are deleted in the same transaction.:autodoes not see through methods or Procs โ declareslug: truewhen your slug derives from PII that way. A rewritten slug counts as an erased field forclear_audit_trail:, so an auditedslugcolumn's trail is cleared too. - Addressable: when any mapped address column is anonymized, the
fingerprint:column is cleared (nil) in the same UPDATE, sowith_address(old_fingerprint)no longer finds the row. -
:hashis pseudonymization โ use:nullify/:random_hexfor true erasure. Backups/replicas/logs are out of scope.
๐งฌ Duplicable
Concern-aware deep copy โ the "clone this invoice / duplicate this template" feature. A bare dup copies identity-bearing columns (slug, token, invoice number, audit trail, even created_at, which AR preserves on save), so naive copies collide with unique indexes. Duplicable blanks exactly those columns and lets each sibling concern regenerate fresh values on save.
class Invoice < ApplicationRecord
include ConcernsOnRails::Models::Duplicable
has_many :line_items
duplicable_by associations: %i[line_items],
reset: %i[issued_at],
suffix: { title: " (copy)" }
end
copy = invoice.duplicate # unsaved deep copy
copy = invoice.duplicate!(title: "Q3") # saved (one transaction, autosaved children)
copy = invoice.duplicate!(except: :line_items) # this copy skips the line items
copy = invoice.duplicate!(only: []) # shallow copy โ attributes onlyAuto-reset identity columns (no configuration): created_at/updated_at, Sluggable slug, Tokenizable/Hashable tokens, Sequenceable sequence + into: columns, Auditable trail, SoftDeletable timestamp, Lockable attempts/locked_at, and โ on the top-level copy only โ the Sortable/acts_as_list position (the copy goes where add_new_at: puts new rows instead of taking the original's slot; a deep-copied child keeps its position only when its list is scoped by the copied association's foreign key โ it then goes into the new parent's own list โ otherwise it is blanked like the top-level copy). Business state (Publishable, Stateable, โฆ) is a judgment call โ list it in reset:.
Counter-cache columns start at 0: a column on the copy's class maintained by a child โ CounterCacheable rules or a native belongs_to ..., counter_cache: (polymorphic as: included), found through the class's has_many/has_one reflections โ is zeroed, and each child the copy actually carries re-increments it on save. A deep copy of a post with two comments therefore counts 2 (not 4), a shallow copy 0. Plain (non-Duplicable) child copies get the same treatment for their own counters, since their children are never copied. A has_many with no inverse (a scoped one) makes Rails bump the copy's in-memory counter as children are attached; that bump is undone before the INSERT, so the result is right with partial inserts on or off, and duplicate! re-reads the counters after saving โ together with lock_version, inside the save's transaction, since each child's counter UPDATE bumped it row-side; the returned copy โ and every Duplicable child copied below it, which its own copied children bump the same way โ saves again without StaleObjectError. (After a plain duplicate + your own save!, the in-memory counter stays at 0 until reload; the row is correct.) A counter kept by a child with no has_many/has_one on the parent is invisible to this โ set it in on_duplicate.
Associations (associations: allow-list, declared before the macro, validated at macro time): has_many/has_one children are deep-copied โ a child that also includes Duplicable copies via its own rules, so nested graphs stay declarative, and a plain child still gets its own class's identity resets (token, slug, number, trailโฆ), so it never shares a credential or trips a unique index. Children are read with only the gem's own hiding predicates removed โ those on the child's own table's SoftDeletable column and, under default_scope: true, its Publishable column โ so drafts hidden by publishable_by ..., default_scope: true are copied too, while every other default-scope predicate still applies: a tenant, a discriminator on a shared table, a joined table's same-named column (joins(:author).where(authors: { deleted_at: nil })). A SoftDeletable child's soft-deleted rows are never copied, whatever its default_scope: setting (the copy would otherwise get them back as live rows), and a has_one copies the child its reader returns (a hidden one only when the reader returns none). has_and_belongs_to_many re-links the same records, drafts included (soft-deleted ones only while the target's SoftDeletable default scope is off); belongs_to and has_many :through are rejected with an explanation.
Notes
- The macro is optional โ bare
includegivesduplicate/duplicate!with the auto resets. -
only:/except:onduplicate/duplicate!pick which of the declared associations this particular copy carries ("Duplicate with line items?" checkbox); names outside the allow-list raise. They are reserved keys โ pass overrides for attributes literally namedonly/exceptas a braced Hash. - Override
on_duplicate(copy)for custom tweaks; it receives the unsaved copy last. - Reach for
amoebawhen you need per-attribute regex/prepend rules or belongs_to graph copying.
๐ฎ 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
endArrays and other Enumerables work too. Results that never touched the database โ an
external API response, a loaded association, a hand-built list of Structs โ get the same
slicing, headers and pagination_meta. Relations still paginate in SQL (LIMIT/OFFSET);
an in-memory collection is sliced in Ruby and comes back as an Array:
def search
render json: paginated(ExternalCatalog.search(params[:q])) # Array in, current page out
endAnything answering limit/offset is treated as a relation; any other non-Hash Enumerable
(Array, Set, Range, Enumerator โ consumed once) is materialized and sliced. A Hash,
nil or a non-collection raises ArgumentError (call .to_a to paginate a Hash's pairs).
Already paginated upstream? When an external API or search service hands you page N and the
total it counted, pass total: โ nothing is sliced, limited or counted; the collection comes back
as-is and total drives X-Total-Count, X-Total-Pages and the Link header:
result = Catalog.search(params[:q], page: params[:page], per_page: params[:per_page])
render_success(data: paginated(result.hits, total: result.total_hits), meta: pagination_meta)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) |
Rename them, or speak JSON:API โ the Link header URLs follow whatever you pick:
paginate_by page_param: :p, per_page_param: :limit # ?p=2&limit=10
paginate_by style: :jsonapi # ?page[number]=2&page[size]=10
paginate_by page_param: %i[paging page], per_page_param: %i[paging per] # any nested pathPage window for a pagination bar
window: adds a pages: key to pagination_meta โ the first page, the last page, and N pages
either side of the current one, with :gap standing in for the runs left out. It is opt-in:
without window: the key is absent entirely. No extra query either way โ it is arithmetic over
the total already counted.
paginate_by per_page: 10, window: 3
pagination_meta
# => { total: 1000, page: 47, per_page: 10, total_pages: 100,
# pages: [1, :gap, 44, 45, 46, 47, 48, 49, 50, :gap, 100] }Render it straight into a 1 โฆ 44 45 46 [47] 48 49 50 โฆ 100 bar:
<% pagination_meta[:pages].each do |page| %>
<%= page == :gap ? "โฆ" : link_to(page, url_for(page: page)) %>
<% end %>| Situation | pages: |
|---|---|
?page=47 of 100 |
[1, :gap, 44, 45, 46, 47, 48, 49, 50, :gap, 100] |
?page=2 of 100 |
[1, 2, 3, 4, 5, :gap, 100] โ no leading gap once the window reaches page 1 |
?page=6 of 100 |
[1, 2, 3, 4, 5, 6, 7, 8, 9, :gap, 100] โ a one-page gap is filled, never 1 โฆ 3
|
| 5 pages total |
[1, 2, 3, 4, 5] โ the window spans everything |
| empty collection | key absent |
window: 0 |
[1, :gap, 47, :gap, 100] โ first, current and last only |
?page= past the last page windows around the last page (as rel="prev" already does), and
window: must be a non-negative Integer or nil/false โ validated at declaration.
A relation's total is a COUNT with ORDER/LIMIT/OFFSET stripped; a DISTINCT relation with its own SELECT list (Membership.select(:group_id).distinct, or DISTINCT written into the list: select("DISTINCT category")) is counted as a raw SELECT COUNT(*) FROM (โฆ) subquery (STI-safe), so the total is the number of distinct rows it returns, not the underlying row count. MySQL alone rejects repeated output names in that subquery (select("a.id, b.id")); there the count is retried with the list re-aliased positionally, and a list that cannot be re-aliased (table.*) falls back to the plain over-count. A model class (paginated(Article)) is counted through .all.
Response headers: X-Total-Count, X-Page, X-Per-Page, X-Total-Pages, and an RFC 8288 Link
header with first / prev / next / last URLs rebuilt from the current request (other query params
preserved; prev/next only when such a page exists; nothing for an empty collection) โ the GitHub
convention, so clients follow links instead of computing page numbers. Appended to any Link header
already set (Deprecatable, CDN hints). paginate_by link_header: false turns it off.
๐งญ CursorPaginatable
Cursor (keyset) pagination โ the constant-time complement to Paginatable: no COUNT query, stable under concurrent inserts, ideal for infinite scroll and sync feeds.
class ArticlesController < ApplicationController
include ConcernsOnRails::Controllers::CursorPaginatable
cursor_paginate_by order: { created_at: :desc }, per_page: 25, max_per_page: 200
def index
render json: cursor_paginated(Article.all) # bad cursors are rescued to a 400 automatically
end
endURL params
| Param | Default | Notes |
|---|---|---|
?cursor= |
โ | The opaque token from X-Next-Cursor (omit for page 1) |
?per_page= |
25 |
Capped at max_per_page (default 200; 0 disables the configured cap, but never the absolute 1,000,000 ceiling it shares with Paginatable) |
?order= |
first preset | With order_presets: only โ selects a named ordering from the allow-list (unknown names โ 400 invalid_order_preset) |
Response headers: X-Per-Page, X-Count (rows on this page โ totals are deliberately not computed), X-Has-More, X-Next-Cursor (only while more pages exist). With bidirectional: true: also X-Has-Prev, X-Prev-Cursor. Plus an RFC 8288 Link header: rel="next" carries the next-cursor URL, rel="prev" the prev-cursor URL (bidirectional), rel="first" the current URL with the cursor dropped (once a cursor is in play); per_page and the order preset are preserved. cursor_paginate_by link_header: false turns it off.
Notes
- The primary key is always appended as a tiebreaker, so duplicate values never skip or repeat rows; ordering columns are chosen in code (never from params), must be selected by the relation, unaliased, and may be nullable: a column the schema allows NULL in paginates with its NULLs last in either direction on every adapter, returning every row exactly once with no extra queries. PostgreSQL uses
NULLS LAST; other adapters use aCASE WHEN col IS NULLsort key, and the keyset WHERE uses the OR-expansion. NOT NULL columns keep the plain, index-friendly SQL, so prefer them on large tables. Boundary values are the stored column values (read_attribute), so an overridden attribute reader cannot derail the walk (a binary one โ e.g. abinary(16)UUID primary key โ travels as Base64; atimecolumn's is its stored time of day,"10:30:15.500000", which every Rails line reads back exactly); a boundary column the relation did not select raisesActiveModel::MissingAttributeError, and a stored non-NULL value that its attribute type casts to nil (an unparseable datetime string) raisesArgumentErrorโ neither is minted as a NULL boundary. A select alias that shadows an ordering column (select(:id, "UPPER(name) AS name")) is not detected: the cursor would carry the alias's value while the WHERE compares the column, repeating or skipping rows โ alias under another name instead. Re-declaringcursor_paginate_by(e.g. in a subclass) changes only the options passed โ the rest,signed:included, are inherited. - Cursors are opaque, table/order-pinned tokens โ a malformed, cross-endpoint, or stale-config cursor renders a 400 (
invalid_cursor; overriderender_invalid_cursorto customize, delegates to Respondable'srender_errorwhen present). Unsigned by default: a client can mint different boundary values, but values are cast through the model's attribute types and bound by Arel (no injection); a value the column cannot hold โ a timestamp outside years 0001..9999, a String that is invalid UTF-8 or carries a NUL byte (accepted on MySQL, which stores and escapes one), or is a number beyond a decimal's precision or too long/wide to read (bounded like Filterable's operands), included โ is the same 400, never a database error and the relation's own scoping still applies โ treat a cursor as a page position, never an authorization boundary.signed: true(or a String key, or a callable for rotating keys) appends a URL-safe HMAC-SHA256 to every cursor and rejects tampered or unsigned tokens with the same 400, so clients can no longer hand-craft positions at all;trueusesRails.application.secret_key_base. Turning it on invalidates in-flight cursors (clients restart from page one). -
cursor_paginatedusesreorder(replaces anydefault_scopeORDER BY) and returns a loaded Array. Don't wrap it with the controller Sortable'ssortedโ passorder:per call instead. - Forward-only by default โ
bidirectional: true(macro or per call) adds prev cursors andX-Has-Prev/X-Prev-Cursor; direction is pinned in the token, so prev tokens replayed on forward-only endpoints 400 and old direction-less tokens stay valid.order_presets: { newest: {...}, top: {...} }(+default_preset:,order_param:) lets clients pick a named ordering from an allow-list.predicate: :autoupgrades the keyset WHERE to a row-value tuple(a, b, id) > (x, y, z)on PostgreSQL/MySQL/SQLite when directions are uniform โ composite-index friendly โ falling back to the portable OR-expansion (:row/:orforce a strategy). - Use Paginatable when you need page numbers and totals.
๐ 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}%") }
filter_by :price, :created_at, operators: true # ?price_gte=10&created_at_lt=2026-01-01
filter_by :min_stock, type: :integer, with: ->(rel, v) { rel.where(rel.model.arel_table[:stock].gteq(v)) }
def index
render json: filtered(Article.all)
end
endOperators (opt-in per filter, direct-where mode only โ operators: true or a subset like %i[gte lte]),
accepted as a suffix ?price_gte=10 or in bracket form ?price[gte]=10&price[lte]=50:
| Operator | Param | SQL |
|---|---|---|
not |
?status_not=draft |
status != 'draft' |
gt gte lt lte
|
?price_gte=10 |
price >= 10 (cast through the column type) |
in not_in
|
?status_in=a,b or ?status_in[]=a
|
status IN ('a','b') |
null |
?deleted_at_null=true |
deleted_at IS NULL (false โ IS NOT NULL) |
contains starts_with
|
?title_contains=rails |
title LIKE '%rails%' (wildcards escaped; ILIKE on PostgreSQL; string/text columns only) |
Comparison values are cast through the column's own type, or through type: (any ActiveModel type
name) โ except numbers, which are read exactly rather than through ActiveModel's lossy numeric casts
(see below). type: :datetime reads a zone-less operand the way the target column does: in Time.zone (the
per-request zone included) on a zone-aware column โ every Rails app's default โ and in
ActiveRecord.default_timezone (UTC by default) otherwise; :date/:time use ActiveRecord's own types.
Never the server's system time zone. type: also pre-casts the value handed to a with: lambda (a :datetime
one, which has no column, in Time.zone while the model's time_zone_aware_attributes is on โ the Rails default โ else
in default_timezone); a numeric type: does it with the same strict reader, so ?min_stock=1e1 hands the lambda 10 (not 1), and a value the type cannot
represent exactly (abc, or 5.5 for :integer) fails the filter closed without calling the lambda.
Range limits only ever come from a real numeric column, never from a declared type:. Blank values
are skipped and unknown operators / non-scalar values ignored. A gt/gte/lt/lte value the type
cannot represent (?price_gte=abc) matches nothing rather than silently comparing against 0 โ
nothing raises at request time. So does any operand carrying a NUL byte (?title_gte=a%00b,
?title_contains=%00, ?title=a%00b, a NUL member of an in list; binary columns excepted): it used to
cut the SQL short, a StatementInvalid 500. For strict, validated contracts reach for Permittable.
On an enum column a comparison operand is a label (?status_gte=review is status >= 1). On an
integer-backed enum anything else is read as a number, exactly like an integer column's operand, so a
threshold need not be a mapped value: with { low: 0, medium: 5, high: 10 }, ?priority_gte=2 is
priority >= 2 and ?priority_lt=1.5 is priority <= 1. An unknown non-numeric label (or a value that is not
a number) matches nothing, never a 500.
On an enum column an unknown label (?status=drfat) equals nothing โ it drops out of an in list โ where
Rails 7.0+ turned it into status IS NULL and returned the rows with no status.
Numeric columns are read strictly, never truncated, in every form (direct ?stock=, suffix, bracket,
in/not_in lists):
- Every operand is read as an exact decimal, then bound through the column (whatever numeric
type:is declared). On an integer column?stock_gt=1e3isstock > 1000and?stock=1e1isstock = 10โ they used to runstock > 1/stock = 1. - A value finer than the column's scale โ
5.5on an integer column,99.985on a scale-2 decimal โ compares exactly (?stock_gt=5.5isstock >= 6,?stock_lte=5.5isstock <= 5;?price_gt=99.985keeps the99.99row) and equals nothing:?stock=5.5matches no row, drops out of aninlist, andnot/not_inon it exclude nothing but NULLs. - Only non-numeric garbage (
abc,0x10,1_000) fails closed, as do operands longer than 100 characters or with an exponent beyond ยฑ1000 (a bounded read โ1e99999999on an unconstrainednumericcolumn would otherwise expand to hundreds of megabytes). - A value beyond what the column can hold is answered per operator:
gt/gteabove the maximum (orlt/ltebelow the minimum) match nothing, the opposite direction matches every non-NULL row; for equality it matches nothing (and drops out of aninlist), fornot/not_init excludes nothing but NULLs. - Dates and times get the same per-operator answer outside the portable range
0001-01-01..9999-12-31(the SQL standard's, which every adapter can store and order):?happened_at_lt=300000-01-01is every non-NULL row,_gtnone, and such a value equals nothing. It used to reach the database: PostgreSQL raisedDatetimeFieldOverflow(a 500) and SQLite, comparing text, sorted year 10000 before 2026. (Rows PostgreSQL holds beyond year 9999 are outside what these filters consider.)
contains/starts_with apply to string/text columns only; on any other column (integer, decimal,
datetime, uuid, a PostgreSQL array, โฆ) they match nothing โ PostgreSQL has no LIKE for those types. Matching is
case-insensitive on PostgreSQL (ILIKE), on SQLite (ASCII letters only) and on MySQL under the default
_ci collations; a MySQL column with a _bin/_cs collation compares case-sensitively.
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.
-
filteredaccepts a relation or a bare model class (filtered(Article)filtersArticle.all). - Passing both
:scopeand:withraisesArgumentError; so dooperators:on ascope:/with:filter, an unknown operator name, or an unknowntype:โ all at class load. - 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,
author: { column: "authors.name", joins: :author }, # an association column
price: { nulls: :last }, # NULLs after the values
default: :created_at, direction: :desc
def index
render json: sorted(Article.all)
end
endURL params: ?sort=-created_at,title or ?sort=title&direction=asc
-
params[:sort]is a comma-separated list of sort keys, each optionally prefixed with-(descending) or+(ascending) โ the JSON:API convention. A+must be percent-encoded as%2B, since a raw+in a query string decodes to a space. Non-whitelisted keys are dropped and a repeated key collapses to its first occurrence; when nothing valid remains the declared default applies. - Un-prefixed keys take
params[:direction](asc/desc, case-insensitive), then the declared default direction. The macro'sdirection:is case-insensitive too ("DESC"works); anything but asc/desc raisesArgumentErrorat class load. - A plain Symbol sorts by that column of the relation's own table. A
key: { ... }rule can point elsewhere:column: "table.column"plusjoins:(anythingleft_outer_joinsaccepts โ LEFT OUTER by default so rows without the association are kept;join: :innerdrops them), and/ornulls: :first | :last(Rails 6.1+). Joins are added only when that key is requested. -
sortedusesreorder, so the requested columns replace any priorORDER BY(including a modeldefault_scopeorder). Likefilteredand the paginators, it accepts a bare model class (sorted(Article)sortsArticle.all). -
nulls:uses PostgreSQL's nativeNULLS FIRST/LAST; on every other adapter it emits the portableCASE WHEN col IS NULLequivalent, so the row order is the same and nothing sends MySQL syntax it rejects. - If no
default:is given, the first declared key is used. Adefault:that is not in the allow-list is legal โ it orders the relation but stays unselectable by clients.
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 intent-revealing render methods, plus an opt-in app-wide switch to RFC 9457 problem details.
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_created(data: article, location: article_url(article)) # 201 + Location
else
render_invalid(article) # 422 record_invalid + full_messages
end
end
endResponse shapes
RFC 9457 problem details โ switch the error format once and every 4xx the gem's concerns render
through render_error (ErrorHandleable, Throttleable, Idempotentable, CursorPaginatable, Deprecatable,
WebhookVerifiable, Authorizable) becomes an application/problem+json document; render_success is untouched:
respondable_by error_format: :problem_details, problem_type_base: "https://api.example.com/problems"
# 422 application/problem+json
# { "type": "https://api.example.com/problems/record_invalid", "title": "Unprocessable Content", "status": 422,
# "detail": "Validation failed", "instance": "/api/articles", "code": "record_invalid", "errors": ["Name can't be blank"] }type is problem_type_base/<code> (or about:blank without a base or code), title the status reason phrase,
instance the request path; code and errors ride along as extension members.
API
| Method | Signature |
|---|---|
render_success |
render_success(data: nil, status: :ok, meta: {}, location: nil, headers: {}) โ location: sets the Location header (a String, or anything url_for resolves); headers: sets extra response headers (names and values are coerced to String and stripped of CR/LF, so caller data cannot split the response; Link and Vary are appended to what is already there, so Localizable's Accept-Language / Timezoneable's Time-Zone survive a "Vary" => "Origin") |
render_created |
render_created(data: nil, location: nil, meta: {}, headers: {}) โ render_success with status: :created
|
render_invalid |
render_invalid(record_or_errors, message: "Validation failed", status: :unprocessable_entity, code: "record_invalid") โ render_error with errors.full_messages as details (omitted when empty, and when an app's render_error override cannot take an errors: keyword); same shape as ErrorHandleable's RecordInvalid handler, problem-details aware |
render_error |
render_error(message:, status: :unprocessable_entity, code: nil, errors: nil) |
respondable_by |
respondable_by(error_format: :envelope, problem_type_base: nil) โ class-level; error_format: is :envelope (default) or :problem_details
|
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 controller exceptions a JSON API meets in practice and render them as the same JSON envelope used by Respondable.
class Api::BaseController < ApplicationController
include ConcernsOnRails::Controllers::Respondable # recommended
include ConcernsOnRails::Controllers::ErrorHandleable
handle_errors except: :stale_object # optional โ let some propagate
endHandled exceptions
Key (= code) |
Exception | Status | details |
|---|---|---|---|
not_found |
ActiveRecord::RecordNotFound |
404 | โ |
parameter_missing |
ActionController::ParameterMissing |
400 | โ |
record_invalid |
ActiveRecord::RecordInvalid |
422 | errors.full_messages |
validation_error |
ActiveModel::ValidationError |
422 | model.errors.full_messages |
record_not_saved |
ActiveRecord::RecordNotSaved |
422 | record errors, if any |
record_not_destroyed |
ActiveRecord::RecordNotDestroyed |
422 | record errors, if any |
stale_object |
ActiveRecord::StaleObjectError |
409 | โ |
record_not_unique |
ActiveRecord::RecordNotUnique |
409 | โ |
foreign_key_violation |
ActiveRecord::InvalidForeignKey |
409 | โ |
unpermitted_parameters |
ActionController::UnpermittedParameters |
400 | the parameter names |
invalid_authenticity_token |
ActionController::InvalidAuthenticityToken |
422 | โ |
bad_request |
ActionController::BadRequest |
400 | โ |
parse_error |
ActionDispatch::Http::Parameters::ParseError |
400 | โ |
unknown_format |
ActionController::UnknownFormat |
406 | โ |
Statuses follow Rails' own rescue_responses wherever Rails has an opinion; the two database-constraint
races Rails leaves as 500s (RecordNotUnique, InvalidForeignKey) get the REST-conventional 409. Messages
for database- and parser-level errors are deliberately generic ("Resource already exists",
"Malformed request body", โฆ): the raw messages carry SQL fragments, table/column names, model class
names or the offending input, none of which belongs in an API response. details is present only when
there is something to list. A malformed query string (bad %-escape, invalid UTF-8) also gets the
bad_request envelope, although Rails raises it while logging the parameters, before rescue_from is in
scope. ErrorHandleable hands that one exception to the handler chain itself.
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
endTrimming the map
handle_errors except: :stale_object # let optimistic-lock conflicts reach the error tracker
handle_errors except: %i[record_not_unique foreign_key_violation] # calls accumulate
handle_errors only: %i[not_found parameter_missing record_invalid] # just the original triohandle_errors removes only the concern's own registrations (matched on exception and handler), so a
rescue_from you declared yourself for the same exception is untouched, and it never re-adds โ your later
declarations keep precedence. Unknown keys raise ArgumentError listing the valid ones;
error_handleable_keys returns the keys still active on a controller.
Reporting โ every handled error instruments handled_error.concerns_on_rails (controller, action, code, status, message, exception, exception_class) via the public on_handled_error(key, error, status:, message:) override point, so the 409s reach your error tracker while the 404s stay quiet. error is the exception the handler actually matched โ the #cause when a wrapper (ActionView::Template::Error, your own raise ServiceError) was rescued โ and nil for a handler called directly:
def on_handled_error(key, error, **)
Sentry.capture_exception(error) if %i[record_not_unique foreign_key_violation stale_object].include?(key)
super # keep the event
endNotes
- When
Respondableis also included, the handlers delegate torender_errorso the envelope shape stays in one place. Otherwise they render the same envelope inline. - Exceptions are registered by name (string), so a class your Rails version lacks is simply never matched.
-
ActionController::UnpermittedParametersis only raised withconfig.action_controller.action_on_unpermitted_parameters = :raise. -
RecordInvalid.detailsare populated fromerror.record.errors.full_messages.
๐ Includable
Whitelisted association sideloading + sparse fieldsets for JSON APIs โ zero arbitrary .includes from user input, nested paths included.
class ArticlesController < ApplicationController
include ConcernsOnRails::Controllers::Includable
includable :author, comments: :author, # flat + nested, like `includes` arguments
fields: { articles: %i[id title published_at], authors: %i[id name] },
default: :author, # loaded when the client sends no ?include at all
strategy: :preload # :includes (default) | :preload | :eager_load
def index
render json: with_includes(Article.all),
include: requested_includes(as: :json),
fields: requested_fields
end
endURL params
GET /articles?include=author,comments.author&fields[articles]=id,title&fields[authors]=id,name
API
| Method | What it does |
|---|---|
with_includes(rel) |
Parses params[:include], keeps only allow-listed paths, applies them with the configured strategy |
requested_includes(as:) |
:query (default) โ [:author, { comments: :author }] for includes/preload; :paths โ ["author", "comments.author"] for JSON:API serializers; :json โ [:author, { comments: { include: :author } }] for as_json/render json:
|
requested_include_paths |
The sanitized dotted paths in request order (what as: :paths returns) |
requested_fields |
Returns { articles: [:id, :title] } sanitized map (pass to your serializer) |
Notes
- A path is kept only if every segment follows the allow-list tree (
comments.authorneedscomments: :author); anything else is silently dropped โ no error, no arbitrary eager-loading. -
default:applies only when?includeis absent;?include=(blank) means "nothing" and is honoured. Defaults are validated against the allow-list at class load, stored frozen andduped per request. -
A typo in an option name becomes an association. Inline nested Hashes arrive as
**nested, so any keyword the macro does not name is registered as an allow-listed association instead of raising:feilds:silently leavesincludable_fieldsempty โ i.e. no sparse-fieldset allow-list at all. - 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.
๐ก๏ธ SecureHeadable
Modern security response headers + a thin wrapper over Rails' native Content-Security-Policy DSL. Defense-in-depth on top of output escaping โ it does not scrub request params and never re-enables the deprecated X-XSS-Protection auditor. Zero extra dependencies.
class ApplicationController < ActionController::Base
include ConcernsOnRails::Controllers::SecureHeadable
# Preset headers, plus any custom "Header-Name" => "value" pairs:
secure_headers :nosniff, :sameorigin_frame, :no_referrer_leak, :disable_legacy_xss
secure_headers "Permissions-Policy" => "geolocation=()"
# ...or the break-nothing baseline in one line, then relax what you must (later wins):
secure_headers :recommended
secure_headers :sameorigin_frame
# Delegates to Rails' native CSP DSL โ roll out report-only FIRST:
content_security_policy_for(report_only: true) do |policy|
policy.default_src :self
policy.script_src :self
policy.object_src :none
end
endHeader presets (secure_headers)
| Preset | Header |
|---|---|
:nosniff |
X-Content-Type-Options: nosniff |
:sameorigin_frame |
X-Frame-Options: SAMEORIGIN |
:deny_frame |
X-Frame-Options: DENY |
:no_referrer_leak |
Referrer-Policy: strict-origin-when-cross-origin |
:no_cross_domain |
X-Permitted-Cross-Domain-Policies: none |
:disable_legacy_xss |
X-XSS-Protection: 0 (the only correct modern value) |
:hsts |
Strict-Transport-Security: max-age=31536000; includeSubDomains (no preload โ opt in via a custom pair) |
:same_origin_opener / :same_origin_opener_allow_popups
|
Cross-Origin-Opener-Policy: same-origin / same-origin-allow-popups
|
:require_corp_embedder |
Cross-Origin-Embedder-Policy: require-corp |
:same_origin_resource |
Cross-Origin-Resource-Policy: same-origin |
:no_sensitive_permissions |
Permissions-Policy denying camera, microphone, geolocation, payment, usb and motion sensors to everyone, your own pages included |
:self_sensitive_permissions |
The same list scoped to (self) โ denies third-party frames, keeps first-party use |
Bundles (expand to presets in place, so a later preset or custom pair still wins): :recommended = nosniff, deny_frame, no_referrer_leak, no_cross_domain, disable_legacy_xss, same_origin_opener_allow_popups, self_sensitive_permissions โ deliberately without COEP/CORP (they block cross-origin embeds of your resources and CDN assets lacking CORP headers) and HSTS (belongs with force_ssl); relax deny_frame with :sameorigin_frame if the app frames itself; :cross_origin_isolation = same_origin_opener + require_corp_embedder + same_origin_resource (what SharedArrayBuffer / high-resolution timers require).
Notes
- Headers are applied in an
after_action, so they reinforce Rails' middleware defaults; latersecure_headersdeclarations win on a colliding name. -
content_security_policy_forforwardsreport_only:and per-actiononly:/except:/if:/unless:straight to Rails โ it never re-implements CSP. Per-controller CSP overrides the global initializer for that controller.report_only:istrue/false/:inherit(the default): omitted keeps the report-only flag a parent controller orconfig.content_security_policy_report_onlyset (so adding a CDN toimg_srcnever flips a rollout to enforcing),truereports, and an explicitfalseenforces, even over an inherited report-only flag. - CSP nonce generation (
content_security_policy_nonce_generator) is app-wide initializer config and intentionally stays out of the concern. - These headers mitigate clickjacking / MIME-sniffing and (via CSP) XSS as defense-in-depth โ output escaping remains the primary defense.
๐ Localizable
Per-request locale selection from the request params and/or the Accept-Language header, wrapped in an around_action so I18n.locale is set for the action and restored afterwards. Dependency-free.
class ApplicationController < ActionController::Base
include ConcernsOnRails::Controllers::Localizable
localizable available: %i[en fr de], default: :en
# localizable param: :lang, header: false # params[:lang] only
endResolution order: params[param] โ first match in Accept-Language โ default โ I18n.default_locale. The chosen locale is always validated against I18n.available_locales, so a stray param or a mismatched available: list can never raise I18n::InvalidLocale.
Every response carries Content-Language: <resolved locale> (BCP 47 form โ pt_BR โ pt-BR) and, when Accept-Language is a locale source, Vary: Accept-Language appended to any existing Vary (de-duplicated) so shared caches key on the header. Both are written before the action runs, so a rescue_from-rendered error still carries them; response_headers: false turns them off. rescue_from handlers โ which Rails runs after the around_action has already unwound โ also render under the resolved locale (so an ErrorHandleable 404 or a CursorPaginatable 400 is localized to match its Content-Language), and the previous locale is always restored afterwards, even when the handler raises. Accept-Language tags that share a q-value keep header order, the q parameter is read case-insensitively, and a weight that is not an RFC 9110 qvalue (0โ1, at most three decimals โ so not 0x10, 1_0, Infinity or 2) is treated as q=0: that tag is dropped. Each tag is looked up as RFC 4647 ยง3.4 does โ the full tag, then one subtag shorter at a time (zh-Hant-TW โ zh-Hant โ zh, a singleton left exposed by the cut dropped with it) โ before the next tag is tried, matching - and _ alike and case-insensitively, and the allow-list's own symbol is returned.
Options: available: (allow-list for matching; defaults to I18n.available_locales), default:, param: (default :locale), header: (default true), response_headers: (default true).
๐ Authorizable
A declarative, block-only per-action authorization gate. Each rule is a predicate; the first one that applies to the current action and returns falsey halts the request with 403. Deliberately small โ not a Pundit/CanCan replacement.
class Api::BaseController < ApplicationController
include ConcernsOnRails::Controllers::Authorizable
authorize_by(name: :signed_in) { current_user.present? } # every action
authorize_by(only: %i[update destroy]) { |_action, user| user.admin? }
require_role :admin, :editor, only: :publish # role sugar
end
class Api::PagesController < Api::BaseController
skip_authorization only: %i[index show] # public pages โ even the inherited rules skip
helper_method :authorized? # <%= link_to "Delete", ... if authorized?(:destroy) %>
endThe predicate runs via instance_exec, so current_user (and any helper) resolves on the controller. It is arity-safe โ write it with zero, one (|action|), or two (|action, user|) parameters.
API
| Method | Signature |
|---|---|
authorize_by |
authorize_by(only: nil, except: nil, status: :forbidden, message: "Forbidden", name: nil, &block) |
require_role |
require_role(*roles, via: :current_user, role_method: :role, only:, except:, status:, message:, name:) |
skip_authorization |
skip_authorization(only: %i[index show]) / skip_authorization(except: %i[destroy]) / bare โ exempt actions from every rule, inherited ones included; bare form exempts all |
authorized? |
authorized?(action = action_name) โ evaluate the rules without rendering (for views / conditional UI) |
on_authorization_denied |
on_authorization_denied(rule) โ override point; instruments authorization_denied.concerns_on_rails (call super to keep the event) |
Notes
- Rules run in declaration order; the first failing rule renders and halts.
- Every denial emits
authorization_denied.concerns_on_railswithcontroller,action,actor_id,actor_type,rule(thename:),status,messageโ subscribe for audit logs or alerting on repeated denials. The actor is reduced to scalars on purpose: notification payloads aren't filtered byconfig.filter_parameters. - When
Respondableis also included, denials delegate torender_error(envelope{ success: false, error: { message:, code: "forbidden" } }); otherwise the same envelope is rendered inline. Thecodefollowsstatus::"unauthorized"for 401,"not_found"for 404,"forbidden"for 403 and anything else. Astatus: :not_found, message: "Resource not found"concealment rule then answers exactly like ErrorHandleable's missing-record 404. -
only:/except:are mutually exclusive (passing both raisesArgumentError);authorize_byrequires a block andrequire_rolerequires at least one role. -
skip_authorizationvalidates its arguments at class-load time, because every mistake there fails open: a nilonly:/except:, or an empty/non-actionexcept:([],false,""โ whatexcept: Rails.env.production? && :destroycollapses to), raises instead of exempting every action of the controller and its subclasses. Only the bare form grants a blanket skip. The skip is inherited and outranks rules a subclass declares afterwards;skip_authorization only: []switches it back off. -
Non-goals: no policy objects, no ability DSL, no resource inference โ reach for
pundit/cancancanwhen you outgrow a predicate per action.
๐ฆ Throttleable
Per-request rate limiting with a store-agnostic, injectable backend โ no rack-attack needed. When a rule's limit is exceeded the request is halted with 429 plus Retry-After and X-RateLimit-Limit / X-RateLimit-Remaining / X-RateLimit-Reset.
class Api::BaseController < ApplicationController
include ConcernsOnRails::Controllers::Throttleable
self.throttleable_store = Rails.cache # must support atomic #increment
throttle_by limit: 100, period: 1.minute # by IP (default)
throttle_by limit: 5, period: 1.minute, only: :create,
by: -> { current_user&.id || request.remote_ip }
throttle_by limit: 1000, period: 1.hour, unless: :staff? # skip conditions
endFixed-window counter: the key embeds a floored time bucket (epoch / period) so each window starts clean and X-RateLimit-Reset is exact.
Options: limit: (positive integer), period: (a Duration or seconds), by: (discriminator โ a lambda with no required parameter or a block is instance_exec'd on the controller; a lambda taking an argument, a symbol proc or any other callable is passed the controller; default per-IP), only: / except: (mutually exclusive action scoping), if: / unless: (a Symbol naming a controller method or a callable, evaluated per request โ staff accounts, internal IPs, feature flags; both must pass when both given), name: (the counter's identity โ rules sharing a name share one budget, even across controllers; defaults to "<DeclaringController>#rule<n>", so unrelated controllers never share a counter while subclasses share their parent's).
When several rules apply to one request the X-RateLimit-* headers describe the tightest one (fewest requests remaining, ties going to the rule that resets last), so a client sees the budget that runs out first. A throttled request instruments rate_limited.concerns_on_rails (payload: rule, discriminator, count, limit, period, reset_at, retry_after, controller, action) via the public on_rate_limited(rule, result) hook before the 429 is rendered โ subscribe to alert on abusive clients, or override it (call super to keep the event). Note discriminator is the raw client IP (or user id) โ personal data that filter_parameters does not reach, so hash, truncate or drop it in on_rate_limited if subscribers persist it.
Notes
- The store MUST support atomic increment-with-expiry (
Rails.cachewith#increment, or Redis) โ a non-atomic store under-counts under concurrency. - There is no in-process default store on purpose: the first throttled request raises
ArgumentErroruntil you setthrottleable_store(or the gem-wide fallbackConcernsOnRails.setup { |c| c.cache_store = -> { Rails.cache } }), so you never silently rate-limit per-process. - When
Respondableis included, the 429 body delegates torender_error(code: "rate_limited"). - Backports the essentials of Rails 7.2's
rate_limit(with standardized headers) to Rails 6.0+. For richer rules (fail2ban, allow/deny lists, exponential backoff) reach forrack-attack.
๐ Timezoneable
Per-request Time.zone selection wrapped in an around_action (Time.use_zone) โ the time analogue of Localizable. Dependency-free.
class ApplicationController < ActionController::Base
include ConcernsOnRails::Controllers::Timezoneable
timezoneable available: ["UTC", "Eastern Time (US & Canada)"], default: "UTC"
# timezoneable param: :tz, header: false, cookie: :time_zone
# timezoneable cookie: :time_zone, persist: true # ?time_zone=London sticks for a year
# timezoneable response_header: true # X-Time-Zone: London (+ Vary: Time-Zone)
endResolution order: params[param] โ Time-Zone header โ cookie (if enabled) โ default โ the current Time.zone. Every value โ the configured available: / default: and each request candidate โ is resolved through ActiveSupport::TimeZone[...], so a zone accepted at boot can never be rejected at request time.
Options: available: (allow-list applied to param/header/cookie matching; default: bypasses it, mirroring Localizable), default:, param: (default :time_zone), header: (default true, reads the Time-Zone header), cookie: (default false; true reads the :time_zone cookie, or pass a cookie name), persist: (default false; true or a Hash of cookie options โ writes a param-chosen zone into the cookie: so a settings link makes it stick; needs cookie:), response_header: (default false; true emits X-Time-Zone, or pass a header name โ Vary: Time-Zone is appended when the header source is on).
Notes
- An unknown
available:/default:zone raisesArgumentErrorat declaration time (fail-fast on misconfiguration); so doespersist:withoutcookie:. - The action always runs inside
Time.use_zoneโ also when the resolved zone is already the current one โ so aTime.zone = โฆinside the action (or in a gem it calls) never outlives the request; it used to leak to the next request on the thread whenever the client asked for the current zone. -
rescue_fromhandlers render under the resolved zone too (Rails runs them after thearound_actionhas unwound), and the previous zone is always restored afterwards โ even when the handler raises. -
time_zone_sourcetells you which source won (:param,:header,:cookie,:default,:current) โ handy for a "times shown in London (from your browser)" hint. - Pairs naturally with the model concerns that read the clock (
Schedulable,Publishable,Expirable,SoftDeletable).
๐ Idempotentable
Stripe-style Idempotency-Key support for mutating endpoints, with a store-agnostic, injectable backend. The first request with a key runs the action and caches the response; a retry replays the cached response; a concurrent duplicate gets 409.
class PaymentsController < ApplicationController
include ConcernsOnRails::Controllers::Idempotentable
self.idempotency_store = Rails.cache # must support #read / #write(expires_in:, unless_exist:) / #delete
idempotent_actions :create, ttl: 24.hours, required: true
endPer-key lifecycle: claim atomically (write unless_exist, TTL lock_ttl:) โ run action โ cache 2xxโ4xx responses for ttl:; 5xx and raised exceptions release the claim so the client can retry. Replays carry the original's exact Content-Type (an RFC 9457 application/problem+json stays charset-free), X-Idempotency-Replayed: true and the original's Location / Content-Location / ETag / Last-Modified / Link headers (captured with the cached response โ a replayed 201 still says where the resource lives; tune the allow-list with headers:, [] to capture none); duplicates in flight get 409 + Retry-After; reusing a key with a different payload gets 422 (idempotency_key_reuse, fingerprint overridable via idempotency_fingerprint).
Options: *actions (allow-list, required), ttl: (24.hours), lock_ttl: (1.minute), header: ("Idempotency-Key"), required: (false), headers: (response headers replayed with the cached response; default %w[Location Content-Location ETag Last-Modified Link] โ an allow-list on purpose: Set-Cookie, Date, request ids and rate-limit headers describe the original exchange and are never replayed), on_store_unavailable: (:reject default | :proceed).
Notes
- Cache keys are scoped per
controller#actionand the client key is SHA256-hashed, so the same key on different endpoints never collides. - The scope carries no principal: with client-chosen keys, two users sending the same key and payload to one endpoint share a record (the second is served the first's response,
Locationincluded). Overrideidempotency_scopeโdef idempotency_scope = "#{super}:#{current_user&.id}". - There is no in-process default store on purpose: the first keyed request raises
ArgumentErroruntil you setidempotency_store(or the gem-wide fallbackConcernsOnRails.setup { |c| c.cache_store = -> { Rails.cache } }). -
Fails closed when the store is unreachable. Rails' Redis and memcached stores swallow connection errors (
#writeโ false,#readโ nil); a lost claim with nothing to read back is retried once (so a lock that merely expired is won), and if it fails again the request gets 503idempotency_store_unavailablewithRetry-After: 5โ the action does not run. Payments are the use case: with no store there is no way to know whether a retry's original already ran, and a 503 costs latency where a duplicate costs a double charge.on_store_unavailable: :proceedruns the action without deduplication instead. "Unreachable" means both claims failed and both reads returned nil, so a key whose stored value cannot be deserialized (the store reads it as nil but refuses the claim) also gets 503 until that entry's TTL expires. - When
Respondableis included, the 400/409/422/503 bodies delegate torender_error. - Declare halting filters (authentication,
Throttleable) before including this concern โ a 401/403 rendered by an inner filter would be cached and replayed for the full TTL. Responses rendered byrescue_fromhandlers are never cached. - Keys must be โค255 chars with no control characters (the raw key is echoed in
X-Idempotency-Key); setlock_ttl:above the slowest declared action's worst case.
๐ช WebhookVerifiable
HMAC signature verification for inbound webhooks โ the receiving side of Stripe/GitHub/Shopify-style integrations. The action runs only when the signature over the raw request body verifies; otherwise a 401/400 is rendered and the action never executes.
class WebhooksController < ApplicationController
include ConcernsOnRails::Controllers::WebhookVerifiable # declare BEFORE Idempotentable
verify_webhook :stripe, secret: -> { ENV["STRIPE_WEBHOOK_SECRET"] }, scheme: :stripe
verify_webhook :github, secret: -> { ENV["GITHUB_WEBHOOK_SECRET"] }, scheme: :github
verify_webhook :shopify, secret: [ENV["NEW_SECRET"], ENV["OLD_SECRET"]], scheme: :shopify # rotation
verify_webhook :custom, secret: "s3cr3t", scheme: :hex, header: "X-Acme-Signature"
# verify_webhook secret: ... # no actions = catch-all (used only when no rule names the action)
def stripe
event = JSON.parse(request.raw_post) # parse the raw body โ it is what was signed
# ...
end
end| Scheme | Header (default) | Format |
|---|---|---|
:github |
X-Hub-Signature-256 |
sha256=<hex> |
:shopify |
X-Shopify-Hmac-Sha256 |
strict Base64 of the binary HMAC |
:stripe |
Stripe-Signature |
t=<unix>,v1=<hex>[,v1=โฆ] โ signs "#{t}.#{body}", every v1 tried, tolerance: rejects stale and future timestamps |
:hex / :base64
|
โ (header: required) |
plain hex / strict Base64 HMAC of the body |
Options: *actions (none = catch-all). Lookup is by specificity: a rule naming the action always beats a catch-all, whichever class declared either; ties go to the most-derived declaring class, then declaration order. So a parent's catch-all never shadows a subclass rule, and a subclass catch-all for its new actions never takes over (or strips the replay:/tolerance: of) an action its parent named. secret: (String, callable resolved per request โ a lambda with no required parameter or a block is instance_exec'd; a lambda taking an argument, a symbol proc or any other callable is passed the controller โ or Array for rotation, any match passes), scheme: (:hex), header: (overrides the preset), tolerance: (Stripe only, 300s default), digest: (:sha256; :sha1/:sha512 for :hex/:base64 only), replay: (true = the gem-wide cache_store, a store object, or false/nil for off) + replay_ttl: (24.hours) โ replay protection for the schemes that carry no timestamp.
Notes
- Comparison is constant-time and the attacker-controlled header is never decoded โ garbage (including invalid UTF-8 bytes) just fails with 401, it cannot raise.
- A secret that resolves blank at request time raises
ArgumentErrorโ a misconfigured endpoint should page you, not 401 into the provider's silent retry loop. -
Replay protection (
replay:): GitHub, Shopify and plain-HMAC signatures carry no timestamp, so a captured delivery verifies forever. After a signature verifies, a SHA256 of what identifies the delivery โ the signature header, or for Stripe the signed"#{t}.#{body}"payload, since unknownv0=keys and stray whitespace let a captured Stripe header be mutated without invalidating it โ is written to the store withunless_exist:(atomic โ memcachedadd/ RedisSET NXviaRails.cache); a second delivery with the same signature withinreplay_ttl:is rejected with 409webhook_replayed. Forged traffic never consumes a slot; the key is scoped per controller action. The store must answer#write(key, value, expires_in:, unless_exist:)and#read(key)(#delete(key)is optional but recommended) โRails.cachedoes. The marker is a short 60-second claim until the action finishes, then promoted toreplay_ttl:(anafter_action). When the delivery was not processed the claim is released at once so the provider's retry (identical body, identical signature) gets through: a handler that 5xxs, one that raises (even whenrescue_fromrenders a 4xx), and a laterbefore_actionthat halts โ the last two skip everyafter_action, so anaround_action(guard_webhook_replay_claim) releases whatever claim is still pending. Without#deleteโ or if#deleteraises, which is logged and never masks the original outcome โ the 60-second claim simply expires.skip_after_action :commit_webhook_replay_claimdisables replay protection (every claim is then released). If the store is unreachable the check fails open: Rails' Redis and memcached stores return false from#writeon a connection error, and rejecting every inbound delivery during a cache blip would be worse than accepting a rare duplicate. A per-processMemoryStoregives no protection across workers. Stripe's own retries re-sign with a newt=, so they pass; a manual GitHub redelivery has the identical signature and is treated as a replay โ overridewebhook_verification_failedif you'd rather answer 200. - Failure codes:
webhook_signature_missing/webhook_signature_invalid/webhook_timestamp_staleโ 401;webhook_signature_malformed(unparseable Stripe header) โ 400;webhook_replayedโ 409. WithRespondable, bodies delegate torender_error; overridewebhook_verification_failedto customize. - Declare before
Idempotentable(a 401 cached by its around filter would be replayed) and beforeThrottleable(forged traffic shouldn't burn rate budget). Webhook endpoints also needskip_before_action :verify_authenticity_token. - In tests:
skip_before_action :verify_webhook_signature!, or sign payloads for real withOpenSSL::HMAC. After a pass,webhook_verified?is true.
๐ Deprecatable
Standards-based API endpoint deprecation: the RFC 9745 Deprecation and RFC 8594 Sunset headers, Link rels pointing at the migration docs and the successor endpoint, an instrumentation hook to measure who still calls the endpoint, and optional 410 Gone enforcement once the sunset instant passes. This is how Stripe/GitHub/Zalando retire API versions โ and nothing native exists on any Rails version.
class Api::V1::OrdersController < ApplicationController
include ConcernsOnRails::Controllers::Deprecatable
deprecate_actions :index, :show,
deprecated_at: "2026-06-01",
sunset_at: "2026-12-31T00:00:00Z",
link: "https://docs.example.com/v1-migration",
successor: "https://api.example.com/v2/orders",
after_sunset: :gone, # default :headers โ announce, never block
notify: -> { StatsD.increment("api.v1.orders.deprecated") }
end
# Every matching response then carries:
# Deprecation: @1780272000
# Sunset: Thu, 31 Dec 2026 00:00:00 GMT
# Link: <https://docs.example.com/v1-migration>; rel="deprecation", <https://api.example.com/v2/orders>; rel="successor-version"Options: deprecated_at: (required; Time/Date/String โ parsed eagerly, normalized to UTC), sunset_at: (optional, must be โฅ deprecated_at; a bare date means 00:00 UTC that day โ sunset is an instant, not end-of-day), link: / successor: (URLs), after_sunset: (:headers default | :gone โ 410 with code endpoint_sunset at/after the sunset instant), header_format: (:rfc9745 default, @<unix> | :legacy, the widely-deployed draft literal true), notify: (callable run per matching request โ a lambda with no required parameter or a block is instance_exec'd; a lambda taking an argument, a symbol proc or any other callable is passed the controller; a raising notify propagates on purpose). No positional actions = catch-all for the whole controller. The last matching rule wins, so an action-specific declaration naturally overrides a base controller's catch-all.
Notes
- Headers go out on every matching response โ including the 410 itself, so the cut-off self-documents.
Linkvalues are appended to any existingLinkheader (pagination, CDN), never clobbered. - Each hit instruments
deprecated_endpoint.concerns_on_rails(ActiveSupport::Notifications) with{controller:, action:, deprecated_at:, sunset_at:}โ subscribe to count stragglers before flippingafter_sunset: :gone. Overrideon_deprecated_access(rule)to replace the default instrumentation. - 410 bodies delegate to
Respondable'srender_errorwhen present (inline JSON envelope otherwise).skip_before_action :apply_api_deprecationsopts an action out;deprecation_active?/sunset_passed?are available for serializers/response bodies. - Flipping
:goneis a deliberate, customer-facing cut-off โ coordinate it withnotify:-driven outreach, and mind CDN-cached responses that may outlive the headers.
๐๏ธ Cacheable
HTTP conditional GET + declarative Cache-Control ("fresh_when/stale?-lite" for JSON APIs). The method names are deliberately distinct from Rails' ActionController::ConditionalGet, so including it never shadows fresh_when / stale? / expires_in.
class Api::ArticlesController < ApplicationController
include ConcernsOnRails::Controllers::Cacheable
http_cache_actions :index, :show, max_age: 5.minutes,
visibility: :public, vary: "Accept"
etag_with :locale # the body depends on I18n.locale โ folded into the ETag, Vary: Accept-Language
def show
@article = Article.find(params[:id])
return unless stale_resource?(@article) # 304 + halt when the client copy is fresh
render json: @article
end
end
# A matching response then carries:
# Cache-Control: public, max-age=300
# Vary: Accept-Language, Accept
# ETag: W/"โฆ"
# Last-Modified: Thu, 01 Jan 2026 12:00:00 GMThttp_cache_actions declares the Cache-Control/Vary policy (emitted via after_action โ it rides a 304 too); stale_resource? sets the ETag/Last-Modified validators and, on a safe request whose precondition matches, sends 304 Not Modified and returns false.
Options (http_cache_actions *actions, โฆ, repeatable; no actions = catch-all; last matching rule wins): visibility: (:private default | :public), max_age: (Integer/Duration), must_revalidate:, no_store: (overrides everything โ bare no-store, on every method and status), stale_while_revalidate:, vary: (String or Array, appended to any existing Vary, de-duplicated case-insensitively; Vary: * is left alone).
Positive freshness is GET/HEAD-only. Every policy other than no_store (public/private, max-age, must-revalidate, stale-while-revalidate) is emitted only on a GET or HEAD whose status is 200, 203, 204, 206 or 304 โ so a catch-all rule never tells a shared cache to store a POST's 201 or the 404 / 500 / redirect an action rendered itself (Cacheable leaves Cache-Control unset on them, so whatever the rest of the stack adds applies โ in a stock Rails app, Rack::ETag's max-age=0, private, must-revalidate on a body it digests, its no-cache otherwise). RFC 9111 lets caches store more statuses heuristically, but an explicit max-age on an error or redirect is almost never what the rule meant.
ETag context (etag_with, repeatable โ the analogue of Rails' class-level etag { }): when the representation depends on more than the record โ the locale, the requested fields, the caller's role โ declare it and the values are folded into the ETag so two representations of one resource never share a validator. Sources are presets (:locale โ also Vary: Accept-Language, :format โ Vary: Accept, :query), Symbols naming controller methods, or a block (instance_exec'd); vary: overrides the implied header(s), vary: false suppresses them; nil values are ignored. Per call: stale_resource?(@article, extras: [params[:fields]]). An explicit etag: stays verbatim only when there is no context to fold in.
A source with no Vary forces private. A controller method, a block, or a preset with vary: false folds a dimension into the ETag that no cache can key on โ Vary has no way to say who is asking โ so the response is not shareable and Cache-Control is emitted as private whatever the rule declared. (:query is exempt: the URL already carries it.)
http_cache_actions :show, max_age: 30, visibility: :public
etag_with { current_user&.role } # => Cache-Control: private, max-age=30Conditional-GET correctness
- Weak ETag
W/"<md5>"from the resource's cache key (collections fold their members' keys + size);If-None-Matchis matched with weak comparison, honours*, and accepts a comma-separated list. -
Last-Modifiedis an IMF-fixdate viaTime#httpdate(not hand-rolled ISO 8601);If-Modified-Sinceis compared at whole-second granularity. It comes fromupdated_at: an ActiveRecord relation's SQLMAX(updated_at), any other collection's newest member timestamp (members withoutupdated_at, or with a nil one, are skipped โ none at all means noLast-Modified, just the ETag). - When both are sent,
If-None-Matchwins and the date is ignored (RFC 7232 ยง3.3); a 304 is only sent for safe (GET/HEAD) requests, and still carries the validators and theCache-Controlpolicy. - Override
cache_etag_for/cache_last_modified_forto customise validator derivation. For write-side preconditions (If-Matchโ 412), reach for Rails' own helpers.
๐ Permittable
Declarative, typed params contracts โ what strong parameters would be if it also knew types, bounds, defaults, and why a request was bad. params.permit (and Rails 8's params.expect) only answer "which keys may pass"; a Permittable contract additionally casts each field, validates it, applies defaults, optionally reshapes the output, and turns every failure into a machine-readable 422. Because the contract is class-level data (not code inside the action), it has readers beyond the validator: a boot-time schema-drift guard, an OpenAPI 3.1 exporter, RSpec matchers, a route-set coverage audit, a draft generator, and a standalone Contract for any Hash.
Heads up โ two things to know before relying on this section
- Your lockfile, not this page, decides which features you have. The pin is
>= 0.8, < 1, so everything up to 0.8 (monitor mode,:json,nullable:,message:, field groups, matchers, generator, OpenAPI) is guaranteed, and an app that locked 0.8 keeps it untilbundle update permittable. permittable 0.9+ requires ActiveSupport >= 6.1, so on Rails 6.0 Bundler stops at permittable 0.8. That version lacksformat:presets,Permittable.check_column_typesand the enum rule,Permittable.error_format = :problem,permittable:audit,params.expectscanning in the generator, and the 0.10 canonical-numeric tightening ("1_8"and" 99 "still cast there). Everything else here applies. To make the newer surface a hard requirement, pingem "permittable", ">= 0.10"in the app's Gemfile.- Two RFC 9457 switches exist โ use one. With Respondable included, Permittable's 422s render through
render_error, sorespondable_by error_format: :problem_detailsturns them into problem documents together with every other concern. The gem's ownPermittable.error_format = :problemalso renders RFC 9457 but deliberately bypassesrender_error, so a controller with both set follows the gem's setting and ignores Respondable's envelope customisations.
class UsersController < ApplicationController
include ConcernsOnRails::Controllers::Permittable
permit_params :create, :update, root: :user, model: User do
required :name, :string, length: 1..80, normalize: :squish
required :email, :string, format: :email, normalize: :email
optional :age, :integer, in: 18..120
optional :ssn, :string, sensitive: true # auto-redacted from logs
optional :plan, :string, in: %w[free pro], default: "free"
optional :nickname, :string, nullable: true # explicit null clears the column
optional :metadata, :json, max_depth: 3, length: 0..32
array :tag_names, of: :string, length: 0..10
optional :address do
required :city, :string
optional :zip, :string, format: /\A\d{5}\z/
end
end
def create
user = User.create!(permitted_params) # cast, validated, defaulted
end
end
# POST { user: { name: "Jo", email: "JO@x.com ", age: "30" } }
# โ permitted_params == { "name" => "Jo", "email" => "jo@x.com", "age" => 30, "plan" => "free" }
# POST { user: { age: "12" } }
# โ 422 { error: { code: "invalid_parameters",
# details: [{ param: "user.name", code: "missing" },
# { param: "user.age", code: "inclusion" }] } }The schema-drift guard is the headline: with model: (a class, or true to infer from the controller name), every non-virtual: scalar field is checked against the model's columns at controller class load. A column dropped by a migration fails the deploy (production eager-loads controllers) with a teaching error โ the missing-column message plus a ready-to-paste migration command plus the virtual: true escape hatch. Nested/array fields are implicitly virtual; the check skips gracefully when the schema is unreachable (db:create, assets:precompile). Opt into the type half with Permittable.check_column_types = true (families: text / numeric incl. boolean / temporal; a Rails enum must be declared :string, in: Model.statuses.keys). In CI, one Rails.application.eager_load! spec exercises every contract in the app.
Options (permit_params *actions, โฆ, repeatable; no actions = catch-all; last matching rule wins, subclasses inherit copy-on-write): root: (key to unwrap, require(:user)-style; missing root โ 400; default false = top-level params), model: (schema-drift guard), unknown: (:ignore default / :log / :error โ undeclared keys at every nesting level; Rails' router/form/CSRF keys, the route's path params and ParamsWrapper's copy are exempt at the top level), enforce: (false = validate lazily on first permitted_params call, memoized per action; true = validate in a before_action), mode: (:monitor reports instead of rejecting โ see below), desc: (OpenAPI operation description).
Field DSL: required/optional name, type (:string default, :integer, :float, :decimal, :boolean, :date, :datetime, and :json โ a free-form hash for jsonb columns, bounded by length: keys and max_depth:) with in: (Range, or a list snapshotted and cast at load โ in: Post.statuses works), format: (Regexp or a preset: :email/:uuid/:url/:slug/:hostname), length:/normalize: (:squish/:strip/:downcase/:upcase/:email or a Proc; string fields only), default: (validated against the field's own contract at load time), nullable: (an explicit null/"" yields nil instead of counting as absent โ the one way to clear a column; wins over default:), message: (per-field copy, String or code โ String; app-wide copy via I18n permittable.errors.<code>), validate: (Proc โ falsy fails as "invalid", a returned Symbol becomes the violation code), transform: (see below), virtual:, sensitive: (registers with the gem-wide filter_parameters registry, same pipe as Encryptable; cascades into nested blocks, sensitive: false opts a sub-field out), desc:/example: (docs). Nested hashes via a block; array :name, of: :type (or a block for arrays of hashes) with length: as element count โ short-circuiting, and there is no default cap โ and per-index violation paths (items[1]). Reusable field groups: Permittable.fields { โฆ } + use Group (optional: true turns a create contract into a PATCH contract; only:/except:).
Output reshaping โ the safe replacement for params-mutating before_actions, operating on the validated copy only (the request's params is never touched). transform: applies a callable after cast + validation to reshape that field's output โ transform: ->(v) { v.split(",") } turns a validated delimited String into an Array (absent fields stay absent, a default: on a transformed field is handed out as authored, a partially-invalid array is never transformed). finalize do |p| โฆ end (once per contract, top level only) runs after every field validated cleanly, receives the result hash, and returns the final shape โ zip parallel fields into value objects, drop scaffolding keys. It executes on a bare runner, not the controller, so contracts stay pure; its one extra verb, violate!(param, code, message: nil), records a violation and halts the block immediately โ doubling as the cross-field validation seam ("ends_at after starts_at").
Coercion is strict โ deliberately not ActiveModel::Type ("abc".to_i == 0 and Boolean.cast("abc") == true silently corrupt untrusted input): a value the type can't faithfully represent is an invalid_type violation, numeric strings must be canonical ("1_8" and " 99 " are refused), dates must be complete (never filled in from today), booleans accept only true/false/"true"/"false"/"1"/"0"/1/0, and array/hash type-confusion (?age[]=1) is rejected instead of 500ing. nil and "" are both absent: absent optional fields are omitted from the result (partial updates never nil-out columns), absent required fields violate, default: fills absence.
Adopting on a live API: bin/rails permittable:generate drafts a contract per controller from the model's columns and the params.permit/params.expect calls already in the source (in monitor mode, every guess a visible TODO); mode: :monitor / Permittable.mode = :monitor runs the pipeline but reports instead of rejecting (notification with mode: :monitor, raw params passed through, permittable_violations for the action to branch on); bin/rails permittable:audit lists every routed action with its effective mode and flags write actions that accept a body with no contract (audit[strict] exits 1 โ a CI gate); then flip to enforce controller by controller.
Beyond the controller: require "permittable/rspec" gives permit_param(:age).for_action(:create).as(:integer).within(18..120) (the declaration) and accept_params(payload).returning(hash) / reject_params(payload).with_violation("user.age", :inclusion) (the behaviour) without dispatching a request; Permittable::Contract.define(root: :user) { โฆ } runs the same DSL on any Hash (call โ a Result, call! raises); bin/rails permittable:openapi[path] exports an OpenAPI 3.1 document from the same frozen data the server enforces โ anything JSON Schema can't express stays visible as an x-permittable-* extension instead of being mistranslated, and the output is deterministic so it can be committed.
Failure surface: violations raise Permittable::InvalidParameters (carries details + status; a missing/malformed root: is 400, field violations 422), auto-rescue_fromd into the shared error envelope โ Respondable#render_error when included (so respondable_by error_format: :problem_details makes them RFC 9457 along with the rest of the app), the identical inline JSON otherwise; the gem's own Permittable.error_format = :problem renders RFC 9457 too but bypasses render_error, so pick one switch. Instrumented as invalid_parameters.permittable (controller, action, mode, details), exactly once per action per request. Introspect contracts via permittable_contracts / permit_rule_for(action). Naming note: legacy InheritedResources controllers also define permitted_params โ don't mix the two on one controller.
Permittable ships as the standalone
permittablegem (a runtime dependency,>= 0.8, < 1; this section describes 0.10);ConcernsOnRails::Controllers::Permittableis an alias for::Permittable, withsensitive:registrations pooled into this gem's shared filter_parameters registry. Full reference: the gem README ยท docs page.
๐๏ธ 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::NormalizableController concerns live under ConcernsOnRails::Controllers::* (no short form, to disambiguate from Models::Sortable):
include ConcernsOnRails::Controllers::Paginatable
include ConcernsOnRails::Controllers::SortableBoth forms reference the same module, so you can freely mix them.
๐งญ Philosophy & when to reach for a dedicated gem
concerns_on_rails aims to cover the common 80% of each behavior with one include + one declarative macro, schema-validated configuration, no-surprise defaults, and lean dependencies (only acts_as_list and friendly_id; controller concerns have none). It is deliberately not a re-implementation of the category leaders โ reach for a dedicated gem when you outgrow the basics:
| Need | Use instead |
|---|---|
Complex state machines โ guard clauses, multi-state events, a full transition audit log (Stateable has per-event hooks and <state>_at stamps, but records no transition history) |
aasm |
Sentinel-aware unique indexes on soft-deleted rows (deleted_at in the index) |
paranoia or discard
|
| Tagging with contexts, ownership, or tag clouds | acts-as-taggable-on |
Indexed full-text search โ stemming, tsvector/GIN, typo tolerance (Searchable ranks LIKE matches, but never builds an index) |
pg_search / Elasticsearch |
| Versioned audit trails with undo/reify, who-dunnit queries, or association tracking |
paper_trail / audited
|
| Field encryption with KMS-backed / per-record keys or Rails-native key infrastructure |
lockbox / Rails 7+ native encrypts
|
| Deep clone with per-attribute regex/prepend rules or belongs_to graph copying | amoeba |
Sluggable wraps friendly_id and Sortable wraps acts_as_list, so you get those leaders' engines behind the declarative macro.
๐ค Using this gem with AI assistants
Working with Claude, Cursor, Copilot, or another AI coding agent? The docs are AI-ready โ every page is plain Markdown, fetchable without JavaScript:
-
llms.txtโ a machine-readable index of every concern document, following the llms.txt convention -
llms-full.txtโ all 43 concern docs concatenated into one ~400 KB plain-text file, for one-shot context loading -
Per-concern Markdown โ
https://vsn2015.github.io/concerns_on_rails/concerns/<slug>.md(e.g.concerns/sluggable.md) when your agent only needs one concern's docs - This README also ships inside the packaged gem, so an agent reading your bundle already has the full reference locally
Point your agent at llms.txt for an overview, or paste a single concern's .md URL for focused context.
๐ ๏ธ Development
bundle install # install dev dependencies
bundle exec rspec # run the test suite (3,420 examples)
gem build concerns_on_rails.gemspec # build the gem
gem install ./concerns_on_rails-1.33.0.gem # install locally
# Preview the docs site locally (GitHub Pages serves docs/ as-is):
cd docs && python3 -m http.server 8000 # โ http://localhost:8000The 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.
- ๐ Open an issue โ a failing spec is the fastest path to a fix
- ๐ Send a PR โ run
bundle exec rspecfirst; every concern keeps its spec underspec/concerns/ - ๐ Docs count too โ each concern's page lives in
docs/concerns/and redeploys automatically - โญ๏ธ If this gem saved you an afternoon of boilerplate, a star helps other devs find it
๐ Links
| Resource | Where |
|---|---|
| ๐ Documentation site | vsn2015.github.io/concerns_on_rails |
| ๐ Gem page โ live download count, all released versions | rubygems.org/gems/concerns_on_rails |
| ๐ Changelog | CHANGELOG.md |
๐ค AI/LLM docs index (llms.txt) |
vsn2015.github.io/concerns_on_rails/llms.txt |
| ๐ Issue tracker | github.com/VSN2015/concerns_on_rails/issues |
| ๐ Permittable (extracted sibling gem) | github.com/VSN2015/permittable |
๐ License
MIT โ see LICENSE.
๐ป๐ณ Hoร ng Sa and Trฦฐแปng Sa belong to Viแปt Nam.