The project is in a healthy, maintained state
A collection of plug-and-play ActiveSupport concerns for Rails models and Rails controllers
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

>= 0.7.5, < 2
>= 6.0, < 9
>= 6.0, < 9
>= 6.0, < 9
>= 0.8, < 1
 Project Readme

๐Ÿงฉ ConcernsOnRails

Plug-and-play ActiveSupport concerns for Rails models & controllers.
One include, one declarative macro โ€” done.

Gem Version Total Downloads Latest Version Downloads CI Docs Ruby Rails License: MIT

๐Ÿ“– 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) and friendly_id (Sluggable), and both load lazily: an app that never includes those concerns never loads them. Depends on activerecord/actionpack/activesupport, not the full rails meta-gem; controller concerns have zero extra deps
  • Schema-validated configuration โ€” every macro checks that the configured columns exist and raises ArgumentError early โ€” listing every missing column at once, with one ready-to-paste rails generate migration command 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 install

Optional: 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) }
end

A 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
end

That's it. The sections below document each concern individually.


๐Ÿงฑ Model Concerns

All model concerns are independent โ€” include only what you need.

๐Ÿ“ Sluggable

URL-friendly slugs via friendly_id โ€” auto-updates when the source attribute changes.

class Post < ApplicationRecord
  include ConcernsOnRails::Sluggable

  sluggable_by :title
end

post = Post.create!(title: "Hello World")
post.slug              # => "hello-world"
post.update!(title: "Hello, World!")
post.slug              # => "hello-world"  (regenerates on title change)

Post.friendly.find("hello-world")

Options

# Keep old slugs resolvable after a title change (needs a friendly_id_slugs migration)
sluggable_by :title, history: true
Post.friendly.find("old-slug")   # still resolves to the renamed post

# Unique slug only within a scope column (same slug allowed in different accounts)
sluggable_by :title, scope: :account_id
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 slug

Notes

  • Schema must have a slug column (string).
  • history: true requires a friendly_id_slugs table โ€” generate with rails generate friendly_id or add a manual migration.
  • scope: :col (or an Array of columns) requires each column to exist in the same table; a belongs_to association 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 raises ArgumentError, 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 own slug_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, or nil (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 โ€” Sanitizable on: :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 sibling before_save sees it. A slug sourced from a column generated in before_create (Sequenceable's into:, a Hashable code) is built in the same INSERT instead of staying nil until 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_s if 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 error

Configuration

sortable_by :priority                       # ascending priority
sortable_by priority: :desc                 # descending priority
sortable_by :position, use_acts_as_list: false   # just the default_scope ordering, no acts_as_list
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 / :desc raises at declaration.
  • scope: can't be an Encryptable column (a Symbol โ€” resolved as acts_as_list does, so scope: :list means list_id or 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 raises ArgumentError, 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's scope: 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 (one update_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?                          # => false

Opt-in default scope

publishable_by :published_at, default_scope: true
# Article.all now returns only published records automatically
# Article.unscoped reaches everything

Bulk 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 count

Both 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_at is set and in the past โ€” so future-dated posts stay unpublished until their time arrives.
  • On a date publish column (publishable_by :published_on), published? / scheduled? compare against today in Time.zone, as .published / .scheduled do.
  • publish_at! casts its argument through the column's type. A value that is not a time raises ArgumentError before 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, although Time.zone.parse reads them as June 1st and today), and 42 or 1.hour never cast. It used to write NULL and return true. nil still writes NULL (and fires the publish hooks).
  • No default_scope is added by default; chain .published explicitly (or opt in with default_scope: true). An explicit default_scope: false on 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 .published scope 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 with default_scope: true a new record still starts unpublished, and so does Post.published.new. The predicate stays in the index-friendly = TRUE form, so a partial index WHERE published = true still matches. On Rails 6.0, which cannot unscope a Grouping, the scope uses is_published <> FALSE instead; it selects the same rows. unpublish! / unpublish_all write false into a boolean column (NULL into a timestamp one), so a null: false column 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 else

destroy_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:

  1. 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 whose before_remove aborts answers nil); only the class form returns a count.
  2. A record that is already soft-deleted is destroyed for real (as paranoia does) ONLY when you reached the trash on purpose, through one of that model's own trash scopes: only_deleted, soft_deleted, deleted_within(...) or with_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_all soft-deletes the live posts that have trashed comments and purges no post. So User.only_deleted.destroy_all and User.deleted_within(30.days).destroy_all purge 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, including where(...), all, destroy_by, post.comments and unscoped on a default_scope: false model, whose relations already contain the trashed rows. The trashed set is decided when the call starts: a row this call soft-deletes (a self-referential cascade:, an after_soft_delete hook) is never purged by a later batch (it stays in the trash; as with soft_delete_all, a stale copy that a self-referential cascade: already trashed can be re-stamped, so the parent's restore! 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 plain EXISTS, not a row lock: a restore committed concurrently between that check and the DELETE is still lost). The real destroy is Rails' own (callbacks and dependent: :destroy run); a trashed parent first purges its trashed cascade: children, whatever their timestamp (live ones are left to dependent:), because dependent: :destroy cannot 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 own limit/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 raises RecordNotDestroyed for it, as Rails' destroy! does.
  3. Rails' own association maintenance keeps Rails semantics. has_many :groups, through: :memberships, dependent: :destroy on a destroyed owner, user.groups.destroy(group) and user.groups.destroy_all remove 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 :through association's destroy_all removes the links, never the targets. Only the ONE destroy_all Rails issues for that is hard: a destroy callback of the join model that calls destroy_all itself 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 a delete_all instead, so a destroy_all on 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 year

Dependents 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
end

The 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 it

Generators

Type length means Example
:hex byte count (output is length * 2 chars) "a3f7c9b1e2d40859"
:uuid ignored "550e8400-e29b-41d4-a716-446655440000"
:integer digit count 483921
:custom output length, samples from alphabet: "K7M3PQ9A"
hashable_by :token,       type: :hex,     length: 16
hashable_by :external_id, type: :uuid
hashable_by :code,        type: :integer, length: 6
hashable_by :code,        type: :custom,  length: 8,
            alphabet: "ABCDEFGHJKMNPQRSTUVWXYZ23456789"   # Crockford-style, no ambiguous chars

# 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_create only 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 by find_by_<field>), a Sluggable slug from the field is built, and an Auditable creation entry includes it. With encryptable :field, blind_index: true, unique: true prechecks 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: true overrides to_param to return the hashed field, falling back to the id while it is blank โ€” pair it with find_by!(field: params[:id]), or with find_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 overrides to_param too.
  • unique: true prechecks 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 a RecordNotUnique from that index in its own savepoint, so it also works inside your transaction on PostgreSQL.
  • A :custom alphabet needs at least 2 distinct characters (ArgumentError otherwise). A repeated character would silently be drawn more often, so duplicates are removed and ConcernsOnRails.deprecator warns โ€” 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 to before_validation in your model โ€” it uses before_create by 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 predicate

Configuration

# Custom column names
schedulable_by starts_at: :starts_on, ends_at: :ends_on

# Open-ended start (only an end / expiry)
schedulable_by starts_at: nil, ends_at: :expires_at

Mutators

promo.start!                                                # sets starts_at = now
promo.finish!                                               # sets ends_at   = now
promo.reschedule!(starts_at: 1.day.from_now,
                  ends_at:   2.days.from_now)

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 exactly ends_at.
  • A nil end means "never expires"; a nil start means "not yet started".
  • start! / finish! / reschedule! cast each time through its column's type. A value that is not a time raises ArgumentError and 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, although Time.zone.parse reads them as June 1st and today), and 42 or 1.hour never cast. nil still clears a side.
  • No default_scope; chain .current explicitly.
  • date columns work (schedulable_by starts_at: :starts_on, ends_at: :ends_on): the predicates cast the instant through the date column's type, as the scopes' binds are, so current? / upcoming? / expired? / active_at? / overlaps? compare against the date in Time.zone and 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. A datetime column 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 day

Mutators

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_at is nil or in the past โ†’ new value is now + by
  • If expires_at is still in the future โ†’ by is 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 ArgumentError if an affixed predicate would shadow a column's query method. For example, activatable_by :active, prefix: :flag on a table that has a flag_active column 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 method account_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 ArgumentError naming that concern. For example, expirable_by prefix: :term followed by activatable_by prefix: :term would generate term_active? / .term_active twice, and activatable_by suffix: :at would 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_all and unpublish_all build their own predicates, so whichever concern owns .active cannot change which rows they write.

Bulk operations

ApiToken.expiring_within(1.day).expire_all   # => 12

expire_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_until

Expirable vs. Schedulable: Expirable is the ergonomic choice when you just care about expiry; Schedulable adds a start time. They overlap (schedulable_by starts_at: nil, ends_at: :expires_at is similar) โ€” pick whichever reads better in your domain.


โœจ Normalizable

Auto-normalize attribute values in before_validation (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 a before_save backstop for saves that skip validation (update_attribute, save(validate: false)); update_column(s)/update_all bypass it. A rule runs once per save, even a non-idempotent Proc under save if valid? (the second validation skips a field still holding what the first left, also after a Sanitizable on: :write rule rewrote it; reload forgets the earlier validation). The backstop is prepended to the save callbacks, so a sibling's before_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 their before_validation order, 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.
  • nil values are skipped โ€” no nil โ†’ "" coercion (use :nullify_blank for the opposite direction).
  • Preset normalizers pass non-string values through unchanged.
  • "Whitespace" is Unicode whitespace: :strip/:whitespace/:email/:url strip a pasted no-break space, em space or ideographic space from both ends (plain String#strip does not), :squish collapses them, and :nullify_blank treats 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 scope

Options

# mode: :any (default) โ€” any term in any field matches (OR)
# mode: :all           โ€” every whitespace-separated term must match somewhere (AND per term, OR across fields)
searchable_by :title, :body, mode: :all
Article.search("ruby framework")  # title OR body must contain "ruby" AND "framework"

# match: :contains (default) โ€” %term% (substring)
# match: :prefix           โ€” term%  (starts with)
# match: :exact            โ€” term   (full match)
searchable_by :sku, match: :prefix

# 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 emits ILIKE on Postgres (case-insensitive) and LIKE elsewhere.
  • 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 LIKE never matches โ€” naming one in searchable_by raises ArgumentError at declaration, in either order (it used to return nothing, silently).
  • ranked: uses reorder, so relevance leads โ€” until something reorders again. Controllers::Sortable#sorted and CursorPaginatable both reorder unconditionally, so chain .search after 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).count is 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+), so Article.joins(:tags).distinct.search(q) is safe โ€” order it yourself if it needs an order. Apply distinct/group BEFORE search, or pass ranked: false: a DISTINCT/GROUP added after search still carries the rank ORDER BY, which PostgreSQL and MySQL reject. Under mode: :all the 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 NULL

Bulk operations

Subscription.inactive.activate_all     # => 12
Subscription.active.deactivate_all     # => 3

Both 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

  • NULL is 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 with raise ActiveRecord::Rollback, rolls the flip back and the verb returns false, even inside your own transaction. activate_all / deactivate_all then raise ActiveRecord::RecordNotSaved and roll back. A failed update (validation) skips the after-hook, returns false, and rolls back the before-hook's side effects. toggle_active! and the batch verbs go through the same path.
  • timestamps: true stamps activated_at on activate and deactivated_at on 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 with nil. The stamp columns must already exist โ€” activatable_by checks that at declaration and raises ArgumentError otherwise; the datetime type itself is not enforced, it only types the migration hint in that error.
  • The configured column must exist; activatable_by raises ArgumentError otherwise.
  • SoftDeletable and Expirable also define a .active scope, and Expirable also defines an active? predicate. On a model that combines them, pass prefix:/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 plain active? / 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 owns active?.

๐Ÿ”‘ 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 unique DB index for real safety, especially for short alphanumeric/numeric codes; regenerate_<field>! retries a RecordNotUnique from that index in its own savepoint, so it also works inside your transaction on PostgreSQL.
  • .authenticate_by_<field> uses ActiveSupport::SecurityUtils.secure_compare to avoid leaking partial matches via response timing, and returns nil once an expires_in: token has expired. Only a String or Integer is looked up: a crafted non-scalar param (?token[a]=b) answers nil (as consume_<field> does) instead of raising TypeError.
  • .consume_<field>(value) (every field) is the single-use verb โ€” password resets, invite codes, magic links: it authenticates, then revokes with a conditional UPDATE keyed on the token still being present, so two concurrent consumers cannot both succeed; the loser gets nil. 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 is nil never expires โ€” rotate with regenerate_<field>! (which stamps a fresh expiry) or set <field>_expires_at yourself.
  • 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 โ€” still secure_compared โ€” and consume_ clears the index too. Encrypted without a blind index, the token is still generated and rotated, but authenticate_by_/consume_ raise ArgumentError when called (they used to return nil for 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>) + 1 within the scope (and period), so numbering is dense and ordered โ€” not random.
  • A scope: column can't be Encryptable: the scoped MAX would compare ciphertext, match no row and number every record start_at (two invoices of one region both 1, which a unique index over the ciphertext never rejects). Declaring one raises ArgumentError, in either order.
  • Caller-supplied values are respected: Invoice.create!(sequence: 100) is not overwritten (and its into: string is still formatted from 100).
  • The number is drawn in before_create, then reported to the siblings that already ran, whatever the include order: sluggable_by :number gets "inv-1" in the same INSERT (not nil until a later save), an Auditable creation entry includes a tracked number, 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); with reset: the period is taken from the row's created_at by default โ€” so a draft finalized in a later period numbers into the period it was created in. Pass period_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 a period_column: is safe: a numbered row whose column is still NULL keeps counting by its created_at. A date column is stamped with the calendar day in the field's time_zone:.
  • Generation reads MAX then inserts, so two concurrent inserts can race. It's best-effort โ€” add a scoped unique index on <field> (and on into:) for a real guarantee, the same way you would for any MAX-based numbering, and wrap the write in ConcernsOnRails::Support::UniqueRetry.with_retries { โ€ฆ } (pass savepoint: Invoice when 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 retried assign_<field>! draws a fresh one. A create that rolls back (a lost race, a caller's transaction rolling back) restores the number and into: string too, so with_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, use scope: :type. Declared on an abstract class, each concrete table gets its own counter. next_<field> previews exactly what the receiving class's next create! gets in each of these setups. Per-subclass series share one integer column, so index (type, <field>) rather than the column alone.
  • reset: requires a created_at column; the period is taken from each row's creation time, in one fixed zone (time_zone:, default config.time_zone, else UTC). It never follows the per-request Time.zone (Timezoneable, Time.use_zone): requests in different zones would disagree on which day it is and issue the same number, and formatted_<field> without into: would render a different date per reader. Apps that never change Time.zone per request see no change. With into: (and no template:), the MAX also counts rows whose stored value already carries this period's prefix + token + separator. So a number stamped under a request zone before the upgrade is continued, never reissued; the cost is at most a gap. Without into:, 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/or time_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 bare sequenceable_by :sequence with no options โ€” restates the format: every option it omits takes its default, exactly as in a first declaration โ€” so a subclass switching to prefix: "CN-" repeats into:, reset:, padding: โ€ฆ if it wants them. A draft/manual subclass should re-declare with ONLY assign:: sequenceable_by :sequence, assign: :manual keeps the parent's into: / prefix: / reset: and even its template: object, and finalizes into the parent's series. Declare the parent fully (including time_zone:) before its subclasses: a subclass that calls sequenceable_by copies 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 went CN- and back to INV-); any other tuple starts the subclass's own series. assign: and time_zone: are not part of the tuple. The comparison is exact, not visual: a tuple that differs only in scope:, start_at: or into:, or that repeats a template: as a new lambda (Procs compare by identity), is a separate series and can render the parent's numbers (INV-1 twice). Give such a series a distinct prefix or use scope: :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: both time_zone: omitted, or both explicit and the same zone (aliases such as "Kolkata" / "Asia/Calcutta" match). Anything else raises ArgumentError at class-load time โ€” including an explicit zone against an omitted one that equals config.time_zone today, 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 one MAX. Don't mix zones among classes that share a table: declare time_zone: on the class that first declares a format, and nowhere else.
  • For fixed-width display (00042), make the into: 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
               }
end

Generated API

article = Article.new          # status defaults to "draft"
article.draft?                 # => true   (predicate per state)
article.published?             # => false

Article.draft                  # scope: WHERE status = 'draft'
Article.published              # scope: WHERE status = 'published'

article.published!             # direct setter โ€” updates regardless of current state
article.publish!               # guarded transition โ€” raises InvalidTransition if not allowed
article.may_publish?           # => true  (guard check without raising)

article.transition_to!(:archived)  # generic move to any declared state

Bulk operations

Article.draft.transition_all(:publish)   # => 12 โ€” runs :publish across every eligible row

Returns 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_transition

timestamps: 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 ArgumentError if the column, states, default, or transition config is invalid.
  • Raises Stateable::InvalidTransition at 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 state valid โ†’ valid?) or another concern (active next to Activatable, restore next to SoftDeletable) โ€” raises ArgumentError at class load; use prefix: or suffix:. The reverse order (the other concern after stateable_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) before stateable_by and its own macro renames its scopes with prefix:/suffix:, before or after stateable_by. Until it does, calling the shared scope raises ArgumentError.
  • Re-declaring without default: keeps the earlier default while it is still one of the declared states; otherwise (or with an explicit default: 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's archive! or .draft. A method of such a name that the class defined itself is left alone.
  • No persistence of transition history; combine with Publishable / Schedulable for 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
end

Options

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). With normalize_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 (or default_country when 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 when validate_state: true and the country is US/CA.
  • lengths: each listed part's length (measured on the normalized value) must fit its min..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 in allow_blank: to skip the check when blank. allow_blank is independent of required:: presence is still governed solely by required:, 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 CA postal_code includes 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 rewritten

Options

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 rail does not match rails. An explicit SQL ESCAPE clause makes tags containing _ / % match literally on every adapter.
  • tagged_with matches case-insensitively on every adapter โ€” LIKE on SQLite and MySQL, ILIKE on 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, so downcase: true โ€” which folds on write โ€” is what makes the scope and the helpers agree.
  • Tags are normalized in before_validation (plus a before_save backstop for saves that skip validation), so a direct record.tags = "a, b" assignment is cleaned too. An empty list stores NULL.
  • An Encryptable tag column holds ciphertext, which tagged_with's LIKE never matches โ€” declaring it raises ArgumentError at declaration, in either order (an undeclared default :tags column is refused when tagged_with is called).
  • tagged_with matches 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 by tagged_with? but not by tagged_with; run Model.normalize_tags! (relation-aware, one update_columns per changed row in a transaction, no validations/callbacks, returns the count; unscoped to include default-scoped-away rows) to rewrite such rows.
  • tag_counts runs one GROUP BY on 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-on when 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 fields

Notes

  • on: :read (default) is non-destructive: it adds a sanitized_<field> reader and leaves the stored column untouched.
  • on: :write overwrites the column in before_validation (plus a before_save backstop for update_attribute / save(validate: false)) โ€” lossy and irreversible (never use it on code, Markdown, math, or prices), and bypassed only by update_column / update_all / raw SQL. On a persisted record only the fields this save changes are rewritten, so a non-idempotent Proc writer 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: :write stores plain text, not HTML-escaped text: "<b>Tom</b> & Jerry" is stored as "Tom & Jerry" (so <%= %> shows it once, not as Tom &amp;amp; Jerry). &lt; / &gt; are deliberately kept encoded โ€” the stored value can never become markup, even rendered with raw โ€” so a typed a < b is stored as a &lt; b. An &amp; that would read back as a character reference (a literal &amp;copy;) also stays encoded (a static, linear-time rule: & followed by #, a name;, or an HTML5 semicolon-less legacy name), so re-saving and sanitize_all! are idempotent. sanitize_all! uses the same plain-text form, and repairs rows stored double-escaped by earlier versions. The on: :read reader and sanitized: serialization still return HTML-escaped text.
  • sanitize_all! is the repair tool for that bypass (and for rows written before the concern was added): one update_columns per row that actually changes, skipping validations/callbacks on purpose, inside a transaction. A bare call repairs the on: :write fields only โ€” with none declared it returns 0 without 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]}โ€ฆ" }
end

Presets (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/jsonb column works too โ€” the trail is stored as a JSON array, and the entries audit_trail returns 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 literal DEFAULT on a JSON column; the column stays NULL until a tracked field first changes, which reads as [].
  • "at" is ISO8601 UTC with microseconds, so audited_changes_since tells 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 / audited when 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 nil

Options: 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) + 1 via update_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 conditional UPDATE (only while the row is unlocked in the database โ€” a concurrent loser adopts the winner's lock and fires no hooks) and unlock_access! via update_columns โ€” validations and AR callbacks deliberately bypassed so an otherwise-invalid record can still be locked (this also skips updated_at/Auditable). The before/after_lock, before/after_unlock hooks run in a transaction; after_lock is 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_version in SQL and mirror the bump into the instance as Rails' increment! does, so the same instance โ€” and the record unlock_by_token returns โ€” saves afterwards without StaleObjectError.
  • Reach for Devise's lockable when you need its unlock emails or per-strategy unlocks (the unlock_token: option covers a self-service unlock link; sending it is up to your after_lock).

๐Ÿชž Aliasable

Rails 6.1+ for the query side. joins / where / eager_load through an alias needs Rails to resolve a where-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) IS record.association(:source), and only the source macro installs callbacks โ€” dependent:, counter caches, autosave and validations run exactly once.
  • accepts_nested_attributes_for :alias behaves 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_to foreign-key attribute is not aliased โ€” pair with alias_attribute :writer_id, :author_id if you need it.
  • has_and_belongs_to_many cannot be aliased (use has_many :through). has_many/has_one :through can โ€” the copy pins source: 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 :author behind has_many :authors), declare source: 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 column

Options (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 text column (JSON encoded/decoded internally), a native json/jsonb column, or a column the host app already serialized โ€” detected automatically. serialize itself is never used, so the Rails 7.1 API drift is irrelevant.
  • nil vs unset: a written nil (explicit JSON null) reads back as nil and does not fall back to the default; reset_<key> removes the key so the default applies again. :decimal is stored as a precision-safe string, :date/:datetime as ISO8601 (datetime in UTC at microsecond precision).
  • A :datetime key behaves like a datetime column on the same model. With time_zone_aware_attributes on (every Rails app), a zone-less String (a datetime-local form value) is wall-clock time in Time.zone, a Date is midnight there, and the reader returns an ActiveSupport::TimeWithZone in the current Time.zone. Without it, both follow ActiveRecord.default_timezone. where_<key> parses its argument the same way, a datetime_select value is wall-clock time in Time.zone, and a default: 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. :json readers 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 to nil.
  • Querying: every key gets a where_<accessor>(value) equality scope โ€” json_extract on SQLite, ->> on PostgreSQL (a text column is cast to jsonb), JSON_UNQUOTE(JSON_EXTRACT()) on MySQL/MariaDB, which also gets a JSON_TYPE predicate so a stored JSON null is 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), and where_<key>(nil) matches an unset key, an explicit JSON null and a NULL column on all three. Defaults are not queryable (a never-written key is absent in the DB). :json keys, other adapters and a column that is also encryptable (ciphertext, not JSON โ€” either declaration order) raise; query: false opts out, and a where_<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_valid guard) but aborts the whole query on PostgreSQL and MySQL โ€” there is no portable guard. Reach for store_attribute / jsonb_accessor for 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_to must be declared before the macro (the reflection is validated at declaration). Polymorphic associations are not supported.
  • Don't also set native counter_cache: true on 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 โ€” run recount_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). With parents: 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_culture when 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_keys

On 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 with find_each and rewrites each row with one UPDATE โ€” no validations/callbacks (only the ciphertext changes), no lock_version bump (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 raises DecryptionError naming the id.
  • Reach for lockbox or Rails 7+ native encrypts when 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 records

Strategies (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 an encryptable field stores a fresh ciphertext envelope โ€” never plaintext.
  • Optimistic locking: with a lock_version column the single UPDATE goes through update_all, which bumps lock_version in SQL (the row's value + 1). An edit form loaded before the erasure then raises StaleObjectError instead of writing the personal data back, and a stale anonymizing instance still erases.
  • before_anonymize/after_anonymize hooks run inside the write's own savepoint; the record reloads afterwards (erasure is terminal for the instance). A hook that raises, or vetoes with raise ActiveRecord::Rollback, undoes the erasure and restores the in-memory values. anonymize! then returns false, 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 under default_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, a scoping block) it erases exactly that relation, default scope included like any other chain; start from with_deleted/unscoped to 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: :nullify reads nothing, :redact/:email/:random_hex only 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 back anonymize_all!.
  • Slugs (slug: :auto default / true / false): a friendly_id slug built from an anonymized column (sluggable_by field, or the candidates: 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's limit (at least 16 random hex characters, or anonymize! raises naming slug: 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. :auto does not see through methods or Procs โ€” declare slug: true when your slug derives from PII that way. A rewritten slug counts as an erased field for clear_audit_trail:, so an audited slug column's trail is cleared too.
  • Addressable: when any mapped address column is anonymized, the fingerprint: column is cleared (nil) in the same UPDATE, so with_address(old_fingerprint) no longer finds the row.
  • :hash is pseudonymization โ€” use :nullify/:random_hex for 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 only

Auto-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 include gives duplicate/duplicate! with the auto resets.
  • only: / except: on duplicate / 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 named only/except as a braced Hash.
  • Override on_duplicate(copy) for custom tweaks; it receives the unsaved copy last.
  • Reach for amoeba when 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
end

Arrays 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
end

Anything 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 path

Page 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
end

URL 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 a CASE WHEN col IS NULL sort 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. a binary(16) UUID primary key โ€” travels as Base64; a time column'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 raises ActiveModel::MissingAttributeError, and a stored non-NULL value that its attribute type casts to nil (an unparseable datetime string) raises ArgumentError โ€” 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-declaring cursor_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; override render_invalid_cursor to customize, delegates to Respondable's render_error when 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; true uses Rails.application.secret_key_base. Turning it on invalidates in-flight cursors (clients restart from page one).
  • cursor_paginated uses reorder (replaces any default_scope ORDER BY) and returns a loaded Array. Don't wrap it with the controller Sortable's sorted โ€” pass order: per call instead.
  • Forward-only by default โ€” bidirectional: true (macro or per call) adds prev cursors and X-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: :auto upgrades 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/:or force 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
end

Operators (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=1e3 is stock > 1000 and ?stock=1e1 is stock = 10 โ€” they used to run stock > 1 / stock = 1.
  • A value finer than the column's scale โ€” 5.5 on an integer column, 99.985 on a scale-2 decimal โ€” compares exactly (?stock_gt=5.5 is stock >= 6, ?stock_lte=5.5 is stock <= 5; ?price_gt=99.985 keeps the 99.99 row) and equals nothing: ?stock=5.5 matches no row, drops out of an in list, and not/not_in on 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 โ€” 1e99999999 on an unconstrained numeric column would otherwise expand to hundreds of megabytes).
  • A value beyond what the column can hold is answered per operator: gt/gte above the maximum (or lt/lte below the minimum) match nothing, the opposite direction matches every non-NULL row; for equality it matches nothing (and drops out of an in list), for not/not_in it 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-01 is every non-NULL row, _gt none, and such a value equals nothing. It used to reach the database: PostgreSQL raised DatetimeFieldOverflow (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.
  • filtered accepts a relation or a bare model class (filtered(Article) filters Article.all).
  • Passing both :scope and :with raises ArgumentError; so do operators: on a scope:/with: filter, an unknown operator name, or an unknown type: โ€” 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
end

URL 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's direction: is case-insensitive too ("DESC" works); anything but asc/desc raises ArgumentError at class load.
  • A plain Symbol sorts by that column of the relation's own table. A key: { ... } rule can point elsewhere: column: "table.column" plus joins: (anything left_outer_joins accepts โ€” LEFT OUTER by default so rows without the association are kept; join: :inner drops them), and/or nulls: :first | :last (Rails 6.1+). Joins are added only when that key is requested.
  • sorted uses reorder, so the requested columns replace any prior ORDER BY (including a model default_scope order). Like filtered and the paginators, it accepts a bare model class (sorted(Article) sorts Article.all).
  • nulls: uses PostgreSQL's native NULLS FIRST/LAST; on every other adapter it emits the portable CASE WHEN col IS NULL equivalent, 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. A default: 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 via acts_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
end

Response shapes

// Success
{ "success": true, "data": {...}, "meta": {...}  /* meta omitted when empty */ }

// Error
{ "success": false, "error": { "message": "...", "code": "...", "details": [...] } }

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
end

Handled 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
end

Trimming 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 trio

handle_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
end

Notes

  • When Respondable is also included, the handlers delegate to render_error so 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::UnpermittedParameters is only raised with config.action_controller.action_on_unpermitted_parameters = :raise.
  • RecordInvalid.details are populated from error.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
end

URL 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.author needs comments: :author); anything else is silently dropped โ€” no error, no arbitrary eager-loading.
  • default: applies only when ?include is absent; ?include= (blank) means "nothing" and is honoured. Defaults are validated against the allow-list at class load, stored frozen and duped 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 leaves includable_fields empty โ€” 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_fields to your serializer (e.g. AMS / Blueprinter) โ€” Includable itself 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
end

Header 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; later secure_headers declarations win on a colliding name.
  • content_security_policy_for forwards report_only: and per-action only: / except: / if: / unless: straight to Rails โ€” it never re-implements CSP. Per-controller CSP overrides the global initializer for that controller. report_only: is true / false / :inherit (the default): omitted keeps the report-only flag a parent controller or config.content_security_policy_report_only set (so adding a CDN to img_src never flips a rollout to enforcing), true reports, and an explicit false enforces, 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
end

Resolution 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) %>
end

The 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_rails with controller, action, actor_id, actor_type, rule (the name:), status, message โ€” subscribe for audit logs or alerting on repeated denials. The actor is reduced to scalars on purpose: notification payloads aren't filtered by config.filter_parameters.
  • When Respondable is also included, denials delegate to render_error (envelope { success: false, error: { message:, code: "forbidden" } }); otherwise the same envelope is rendered inline. The code follows status:: "unauthorized" for 401, "not_found" for 404, "forbidden" for 403 and anything else. A status: :not_found, message: "Resource not found" concealment rule then answers exactly like ErrorHandleable's missing-record 404.
  • only: / except: are mutually exclusive (passing both raises ArgumentError); authorize_by requires a block and require_role requires at least one role.
  • skip_authorization validates its arguments at class-load time, because every mistake there fails open: a nil only:/except:, or an empty/non-action except: ([], false, "" โ€” what except: Rails.env.production? && :destroy collapses 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 / cancancan when 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
end

Fixed-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.cache with #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 ArgumentError until you set throttleable_store (or the gem-wide fallback ConcernsOnRails.setup { |c| c.cache_store = -> { Rails.cache } }), so you never silently rate-limit per-process.
  • When Respondable is included, the 429 body delegates to render_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 for rack-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)
end

Resolution 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 raises ArgumentError at declaration time (fail-fast on misconfiguration); so does persist: without cookie:.
  • The action always runs inside Time.use_zone โ€” also when the resolved zone is already the current one โ€” so a Time.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_from handlers render under the resolved zone too (Rails runs them after the around_action has unwound), and the previous zone is always restored afterwards โ€” even when the handler raises.
  • time_zone_source tells 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
end

Per-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#action and 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, Location included). Override idempotency_scope โ€” def idempotency_scope = "#{super}:#{current_user&.id}".
  • There is no in-process default store on purpose: the first keyed request raises ArgumentError until you set idempotency_store (or the gem-wide fallback ConcernsOnRails.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 503 idempotency_store_unavailable with Retry-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: :proceed runs 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 Respondable is included, the 400/409/422/503 bodies delegate to render_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 by rescue_from handlers are never cached.
  • Keys must be โ‰ค255 chars with no control characters (the raw key is echoed in X-Idempotency-Key); set lock_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 unknown v0= keys and stray whitespace let a captured Stripe header be mutated without invalidating it โ€” is written to the store with unless_exist: (atomic โ€” memcached add / Redis SET NX via Rails.cache); a second delivery with the same signature within replay_ttl: is rejected with 409 webhook_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.cache does. The marker is a short 60-second claim until the action finishes, then promoted to replay_ttl: (an after_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 when rescue_from renders a 4xx), and a later before_action that halts โ€” the last two skip every after_action, so an around_action (guard_webhook_replay_claim) releases whatever claim is still pending. Without #delete โ€” or if #delete raises, which is logged and never masks the original outcome โ€” the 60-second claim simply expires. skip_after_action :commit_webhook_replay_claim disables replay protection (every claim is then released). If the store is unreachable the check fails open: Rails' Redis and memcached stores return false from #write on a connection error, and rejecting every inbound delivery during a cache blip would be worse than accepting a rare duplicate. A per-process MemoryStore gives no protection across workers. Stripe's own retries re-sign with a new t=, so they pass; a manual GitHub redelivery has the identical signature and is treated as a replay โ€” override webhook_verification_failed if 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. With Respondable, bodies delegate to render_error; override webhook_verification_failed to customize.
  • Declare before Idempotentable (a 401 cached by its around filter would be replayed) and before Throttleable (forged traffic shouldn't burn rate budget). Webhook endpoints also need skip_before_action :verify_authenticity_token.
  • In tests: skip_before_action :verify_webhook_signature!, or sign payloads for real with OpenSSL::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. Link values are appended to any existing Link header (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 flipping after_sunset: :gone. Override on_deprecated_access(rule) to replace the default instrumentation.
  • 410 bodies delegate to Respondable's render_error when present (inline JSON envelope otherwise). skip_before_action :apply_api_deprecations opts an action out; deprecation_active? / sunset_passed? are available for serializers/response bodies.
  • Flipping :gone is a deliberate, customer-facing cut-off โ€” coordinate it with notify:-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 GMT

http_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=30

Conditional-GET correctness

  • Weak ETag W/"<md5>" from the resource's cache key (collections fold their members' keys + size); If-None-Match is matched with weak comparison, honours *, and accepts a comma-separated list.
  • Last-Modified is an IMF-fixdate via Time#httpdate (not hand-rolled ISO 8601); If-Modified-Since is compared at whole-second granularity. It comes from updated_at: an ActiveRecord relation's SQL MAX(updated_at), any other collection's newest member timestamp (members without updated_at, or with a nil one, are skipped โ€” none at all means no Last-Modified, just the ETag).
  • When both are sent, If-None-Match wins 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 the Cache-Control policy.
  • Override cache_etag_for / cache_last_modified_for to 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

  1. 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 until bundle update permittable. permittable 0.9+ requires ActiveSupport >= 6.1, so on Rails 6.0 Bundler stops at permittable 0.8. That version lacks format: presets, Permittable.check_column_types and the enum rule, Permittable.error_format = :problem, permittable:audit, params.expect scanning 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, pin gem "permittable", ">= 0.10" in the app's Gemfile.
  2. Two RFC 9457 switches exist โ€” use one. With Respondable included, Permittable's 422s render through render_error, so respondable_by error_format: :problem_details turns them into problem documents together with every other concern. The gem's own Permittable.error_format = :problem also renders RFC 9457 but deliberately bypasses render_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 permittable gem (a runtime dependency, >= 0.8, < 1; this section describes 0.10); ConcernsOnRails::Controllers::Permittable is an alias for ::Permittable, with sensitive: 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::Normalizable

Controller concerns live under ConcernsOnRails::Controllers::* (no short form, to disambiguate from Models::Sortable):

include ConcernsOnRails::Controllers::Paginatable
include ConcernsOnRails::Controllers::Sortable

Both forms reference the same module, so you can freely mix them.


๐Ÿงญ 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:8000

The test suite uses an in-memory SQLite database and a lightweight FakeController harness for controller-concern specs โ€” no Rails routes or boot required.


๐Ÿค Contributing

Bug reports and pull requests are welcome at github.com/VSN2015/concerns_on_rails.

  • ๐Ÿ› Open an issue โ€” a failing spec is the fastest path to a fix
  • ๐Ÿ”€ Send a PR โ€” run bundle exec rspec first; every concern keeps its spec under spec/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.