๐Ÿงฉ ConcernsOnRails

๐Ÿ‡ป๐Ÿ‡ณ Hoร ng Sa and Trฦฐแปng Sa belong to Viแป‡t Nam.

A plug-and-play collection of reusable ActiveSupport concerns for Rails models and controllers โ€” slugs, soft delete, scheduled publish, expiry, pagination, filtering, JSON envelopes, and more. One include, one declarative macro, done.

class Article < ApplicationRecord
  include ConcernsOnRails::Sluggable
  include ConcernsOnRails::Publishable
  include ConcernsOnRails::SoftDeletable

  sluggable_by :title
end

Article.published.without_deleted.find("hello-world")

๐Ÿ“š Table of Contents


โœจ Why this gem?

  • Eight model concerns + four controller concerns, all production-ready
  • One include, one macro โ€” no boilerplate, no glue code
  • Lean dependencies โ€” only acts_as_list (Sortable) and friendly_id (Sluggable); controller concerns have zero extra deps
  • Schema-validated configuration โ€” every macro checks that the configured column exists and raises ArgumentError early
  • Composable โ€” concerns are independent; mix and match per model

๐Ÿ“ฆ Installation

Add to your application's Gemfile:

gem "concerns_on_rails", "~> 1.6"

Or pull the latest from GitHub:

gem "concerns_on_rails", github: "VSN2015/concerns_on_rails"

Then run:

bundle install

๐Ÿงช Compatibility

  • Ruby: 3.2+
  • Rails: 5.0 through 8.x

๐Ÿš€ Quick Start

# A model that's sluggable, publishable, and soft-deletable
class Article < ApplicationRecord
  include ConcernsOnRails::Sluggable
  include ConcernsOnRails::Publishable
  include ConcernsOnRails::SoftDeletable
  include ConcernsOnRails::Normalizable

  sluggable_by    :title
  normalizable    :title, with: :squish
end

# A controller that paginates, filters, sorts, and renders JSON envelopes
class ArticlesController < ApplicationController
  include ConcernsOnRails::Controllers::Paginatable
  include ConcernsOnRails::Controllers::Filterable
  include ConcernsOnRails::Controllers::Sortable
  include ConcernsOnRails::Controllers::Respondable

  filter_by   :published, scope: :published
  sortable_by :created_at, :title, default: :created_at, direction: :desc

  def index
    render_success(data: paginated(sorted(filtered(Article.all))))
  end
end

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


๐Ÿงฑ Model Concerns

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

๐Ÿ“ Sluggable

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

class Post < ApplicationRecord
  include ConcernsOnRails::Sluggable

  sluggable_by :title
end

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

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

Notes

  • Schema must have a slug column (string).
  • Falls back to to_s if the configured source field doesn't respond.
  • Uses friendly_id's :slugged strategy under the hood.

๐Ÿ”ข Sortable

List ordering via acts_as_list โ€” adds a default_scope ordering plus move_higher, move_lower, move_to_top, move_to_bottom, and automatic position reordering on destroy.

class Task < ApplicationRecord
  include ConcernsOnRails::Sortable

  sortable_by :position           # default โ€” ascending
end

Task.create!(name: "A")
Task.create!(name: "B")
Task.last.move_higher

Configuration

sortable_by :priority                       # ascending priority
sortable_by priority: :desc                 # descending priority
sortable_by :position, use_acts_as_list: false   # just the default_scope ordering, no acts_as_list

Notes

  • The configured field must exist as a column.
  • Direction values other than :asc / :desc silently fall back to :asc.

๐Ÿ“ค Publishable

Manage published / unpublished records via a published_at timestamp.

class Article < ApplicationRecord
  include ConcernsOnRails::Publishable

  publishable_by :published_at   # default โ€” call is optional
end

article = Article.create!(title: "Draft")
article.published?      # => false
article.publish!
article.published?      # => true
article.unpublish!

Article.published       # WHERE published_at <= NOW()
Article.unpublished     # WHERE published_at IS NULL OR published_at > NOW()

Notes

  • "Published" means published_at is set and in the past โ€” so scheduled posts (future published_at) stay unpublished until their time arrives.
  • No default_scope is added; chain .published explicitly.

โŒ SoftDeletable

Soft delete records using a timestamp field (default: deleted_at). Includes a default_scope that hides deleted records and overrides destroy_all to soft-delete in bulk.

class User < ApplicationRecord
  include ConcernsOnRails::SoftDeletable

  soft_deletable_by :deleted_at, touch: true   # both args optional
end

user = User.create!(email: "alice@example.com")
user.soft_delete!
user.deleted?              # => true
user.restore!
user.deleted?              # => false

user.really_delete!        # bypasses callbacks, hard deletes from DB

Scopes

User.active           # alias of .without_deleted โ€” non-deleted records
User.without_deleted  # same
User.soft_deleted     # only deleted records
User.all              # default scope: non-deleted only
User.unscoped         # everything (deleted + non-deleted)

Bulk operations

User.destroy_all          # soft-deletes all matching records
User.really_destroy_all   # hard-deletes all matching records

Lifecycle hooks โ€” override these methods on the model:

class User < ApplicationRecord
  include ConcernsOnRails::SoftDeletable

  def before_soft_delete; end
  def after_soft_delete;  end
  def before_restore;     end
  def after_restore;      end
end

Aliases: soft_deleted? and is_soft_deleted? both delegate to deleted?.


๐Ÿ” Hashable

Auto-generate random values on create โ€” tokens, codes, UUIDs, or anything from a custom alphabet.

class Order < ApplicationRecord
  include ConcernsOnRails::Hashable

  hashable_by :token   # default: type: :hex, length: 16 โ†’ 32-char hex string
end

order = Order.create!
order.token              # => "a3f7c9b1e2d40859e2f1c9b73d40a857"
order.regenerate_token!  # rolls a new value and persists it

Generators

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

Notes

  • Auto-assigns in before_create only when the field is blank โ€” callers can pass an explicit value.
  • A regenerate_<field>! instance method is defined dynamically.
  • For fixed-width numeric codes (e.g. 000042), use a string column โ€” integer columns drop leading zeros.
  • No uniqueness retry is built in. For collision-prone configs (short integer codes), add a unique index and rescue at the app level.
  • If your model has validates :<field>, presence: true, switch this concern's hook 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

Configuration

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

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

Mutators

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

Notes

  • Boundary semantics: inclusive start, exclusive end โ€” active at exactly starts_at, not at exactly ends_at.
  • A nil end means "never expires"; a nil start means "not yet started".
  • No default_scope; chain .current explicitly.

โณ Expirable

Single-timestamp expiry โ€” tokens, sessions, password-reset links, invitations.

class ApiToken < ApplicationRecord
  include ConcernsOnRails::Expirable

  expirable_by   # default field: :expires_at
end

token = ApiToken.create!(expires_at: 1.hour.from_now)
token.active?               # => true
token.expired?              # => false
token.time_until_expiry     # => ActiveSupport::Duration (~1.hour)

ApiToken.active                  # nil expiry OR future expiry
ApiToken.expired                 # past expiry
ApiToken.expiring_within(1.day)  # future expiry within the next 1 day

Mutators

token.expire!                       # expires_at = now
token.expire!(2.hours.from_now)     # explicit time
token.extend_expiry!(by: 1.day)     # pushes expiry forward

extend_expiry! is smart about the base:

  • If expires_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

Custom field name

expirable_by :valid_until

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


โœจ Normalizable

Auto-normalize attribute values in before_validation โ€” strip whitespace, downcase emails, dedupe spaces, run any custom transform.

class User < ApplicationRecord
  include ConcernsOnRails::Normalizable

  normalizable :email,                  with: :email                       # strip + downcase
  normalizable :phone,                  with: :phone                       # digits only
  normalizable :first_name, :last_name, with: :whitespace                  # strip โ€” same rule, multiple fields
  normalizable :slug,                   with: ->(v) { v.to_s.parameterize } # custom lambda
end

User.create(email: "  ALICE@Example.com  ").email   # => "alice@example.com"
User.create(phone: "+1 (415) 555-1234").phone       # => "14155551234"

Built-in presets

Preset Transform
:email strip + downcase
:phone digits only (gsub(/\D/, ""))
:whitespace strip
:squish squish (collapse inner whitespace)
:downcase downcase
:upcase upcase

Notes

  • Runs in before_validation, so DB constraints and AR validations see the normalized value.
  • nil values are skipped โ€” no nil โ†’ "" coercion.
  • Preset normalizers pass non-string values through unchanged.
  • Works on Rails 5+ (no dependency on Rails 7.1's built-in normalizes).

๐ŸŽฎ Controller Concerns

Pure ActionController + ActiveRecord โ€” zero extra runtime dependencies (no Kaminari, Pundit, or Ransack).

๐Ÿ“„ Paginatable

Offset-based pagination with standard response headers โ€” no Kaminari needed.

class ArticlesController < ApplicationController
  include ConcernsOnRails::Controllers::Paginatable

  paginate_by per_page: 25, max_per_page: 200    # optional โ€” these are the defaults

  def index
    render json: paginated(Article.all)
  end
end

URL params

Param Default Notes
?page= 1 Page numbers below 1 are clamped to 1
?per_page= 25 Capped at max_per_page (default 200)

Response headers: X-Total-Count, X-Page, X-Per-Page, X-Total-Pages.


๐Ÿ”Ž Filterable

Declarative URL-param filtering with three modes per filter.

class ArticlesController < ApplicationController
  include ConcernsOnRails::Controllers::Filterable

  filter_by :status, :category                                       # ?status=draft โ†’ .where(status: 'draft')
  filter_by :published, scope: :published                            # ?published=1 โ†’ Article.published
  filter_by :q, with: ->(rel, v) { rel.where("title ILIKE ?", "%#{v}%") }

  def index
    render json: filtered(Article.all)
  end
end

Modes

Mode Declaration What it does
Direct filter_by :status relation.where(status: params[:status])
Scope filter_by :published, scope: :published relation.published (when param is present)
Custom filter_by :q, with: ->(rel, v) { ... } Calls your lambda with (relation, value)

Notes

  • Blank params are skipped โ€” unset filters don't narrow the relation.
  • Passing both :scope and :with raises ArgumentError.
  • Scope mode pairs naturally with Publishable.published, SoftDeletable.active, Expirable.active, etc.

โ†•๏ธ Sortable (controller)

URL-param-driven ordering with a strict allow-list โ€” never orders by an arbitrary user-supplied column.

class ArticlesController < ApplicationController
  include ConcernsOnRails::Controllers::Sortable

  sortable_by :created_at, :title, :published_at,
              default: :created_at, direction: :desc

  def index
    render json: sorted(Article.all)
  end
end

URL params: ?sort=title&direction=asc

  • params[:sort] selects the column; non-whitelisted values fall back to the declared default.
  • params[:direction] accepts asc / desc (case-insensitive); invalid values fall back to the declared default direction.
  • If no default: is given, the first declared field is used.

Distinct from Models::Sortable (which manages list position via acts_as_list). Both can coexist on a model + its controller.


๐Ÿ“ฆ Respondable

Standardized JSON envelopes for API controllers โ€” two methods, zero state.

class Api::ArticlesController < ApplicationController
  include ConcernsOnRails::Controllers::Respondable

  def show
    article = Article.find_by(id: params[:id])
    return render_error(message: "Not found", status: :not_found) unless article

    render_success(data: article)
  end

  def create
    article = Article.new(article_params)
    if article.save
      render_success(data: article, status: :created)
    else
      render_error(message: "Invalid", errors: article.errors.full_messages)
    end
  end
end

Response shapes

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

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

API

Method Signature
render_success render_success(data: nil, status: :ok, meta: {})
render_error render_error(message:, status: :unprocessable_entity, code: nil, errors: nil)

data: is a keyword arg (not positional) on purpose โ€” it sidesteps Ruby 3's behavior of treating hash literals as kwargs when a method declares any keyword params.


๐Ÿ—‚๏ธ Module paths & namespacing

Every concern is available under two paths:

# Short form (recommended for brevity):
include ConcernsOnRails::Sluggable
include ConcernsOnRails::Normalizable

# Fully-qualified form:
include ConcernsOnRails::Models::Sluggable
include ConcernsOnRails::Models::Normalizable

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

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

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


๐Ÿ› ๏ธ Development

bundle install                                  # install dev dependencies
bundle exec rspec                               # run the test suite
gem build concerns_on_rails.gemspec             # build the gem
gem install ./concerns_on_rails-1.6.0.gem       # install locally

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


๐Ÿค Contributing

Bug reports and pull requests are welcome at github.com/VSN2015/concerns_on_rails. โญ๏ธ stars and ๐Ÿด forks appreciated.


๐Ÿ“„ License

MIT โ€” see LICENSE.


๐Ÿ‡ป๐Ÿ‡ณ Hoร ng Sa and Trฦฐแปng Sa belong to Viแป‡t Nam.