concerns_on_rails 1.19.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 191acd17664d56588e54832b2377ede09cf836fe481a0e4c27246adae9f2eb76
4
- data.tar.gz: 95dc3faff52552da6688961fd51e926d4eb2a041f9974f05ea77a491b698d434
3
+ metadata.gz: 7eae55cba50d5c8a671187f6aba31e3b33e25eab1349054a45346400f64c70d1
4
+ data.tar.gz: e1369be961046f7f3926280083ee1f07fa027ad7c93d2161422f2b6dac687f57
5
5
  SHA512:
6
- metadata.gz: f472e83548c6ccd7850c3d3c9994cbb1efac4637210f07f35f69e16ca700aa7f131bed7d24737e825896aa8301cd2786dfe70aa0f563002f1bc8d199056fae28
7
- data.tar.gz: 6c231481577b2db38df1730cb16ad72c2f06ea7bba9d9dc0730e0c8068dce94597b26b514241e1ebd6be51b91caf4ae75dbb8ea32aa11f1aef9109cd45f3d38b
6
+ metadata.gz: 82eff9b0770e1d464418fb68ccf10b2564e48cf8305de882d644edef0134e1e07a90cec0f4a43cc6fdaa50945a663452e57bb0a4a20382be978b1a6204ad545d
7
+ data.tar.gz: ab30c390449a59a83b263393e2b0f1b66eb1d1e2a06d3e054354814f4ef2b916d5739bd6f4b3b48bca4b3772b1f75389cdaf5e1b6ef6014e55e8c3051deff52a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.20.0 (2026-06-18)
4
+
5
+ Two new concerns — the conditional counter cache the prior design panel held back as "too risky for one release" (1.19.0's notes), now shipped with the full update matrix specified and tested, plus the HTTP conditional-GET layer the API-oriented controller suite was missing. 933 examples, 0 failures.
6
+
7
+ ### Added
8
+ - **Models::CounterCacheable**: conditional, denormalized association counters ("counter_culture-lite"). Rails' native `counter_cache: true` counts *every* `belongs_to` child into one column — it can't keep an `approved_comments_count` beside a `comments_count`, and offers no drift repair. Declared on the CHILD, `counter_cacheable_by :post` (repeatable; rules reassigned-not-mutated so subclasses inherit; `count:` defaults to `"<table_name>_count"`, `if:` an optional Ruby condition, `touch:` to bump the parent's `updated_at`) keeps one or many parent columns in sync. `after_create`/`after_update`/`after_destroy` adjust via `update_counters` — a single SQL `COALESCE(col,0) ± 1`, atomic under concurrency — inside the record's own save transaction (a rolled-back save rolls back the counter). The update path resolves the full matrix: a foreign-key reparent decrements the old parent and increments the new one; a condition flip (`if:` result changed) increments/decrements in place; a simultaneous reparent + flip composes both; a no-op save writes nothing. The previous condition state is reconstructed by transiently restoring the changed attributes to their pre-save values (`saved_changes`, `previous_changes` on 5.0). `recount_counter_caches!(association = nil)` repairs drift / backfills portably (no adapter-specific SQL: unconditional counters via `group(fk).count`, conditional via a Ruby tally), rewriting every parent. Macro-time `ArgumentError` validation: the `belongs_to` must be declared first, must not be polymorphic, the parent counter column must exist (deferred when the parent class isn't yet loadable — load-order tolerant), `:if` callable, `:touch` boolean, unknown options rejected. Zero new runtime dependencies.
9
+ - **Controllers::Cacheable**: HTTP conditional GET + declarative `Cache-Control` ("fresh_when/stale?-lite" for JSON APIs), with method names chosen NOT to shadow Rails' `ActionController::ConditionalGet`. `http_cache_actions :index, :show, max_age: 5.minutes, visibility: :public, must_revalidate:, no_store:, stale_while_revalidate:, vary:` declares a policy (repeatable; no actions = catch-all; LAST matching rule wins — the Deprecatable override convention) emitted via `after_action`; `no_store` overrides `max_age`, `Vary` is appended (deduped) to any existing header, and the policy rides a 304. `stale_resource?(resource, etag:, last_modified:)` sets the validators and, for a safe (GET/HEAD) request whose precondition matches, sends `304 Not Modified` and returns false (mirrors `stale?`); `set_cache_validators` / `request_matches_cache?` / overridable `cache_etag_for` / `cache_last_modified_for` round out the surface. Standards-correct: weak ETag `W/"<md5>"` from the resource's cache key (collections fold member keys + size), `Last-Modified` via `Time#httpdate` (not the hand-rolled-ISO8601 bug), `If-None-Match` weak comparison honouring `*` and lists, `If-Modified-Since` compared at whole-second granularity, and `If-None-Match` taking precedence over `If-Modified-Since` (RFC 7232 §3.3). Every `request`/`response` touch is guarded so it runs on bare objects. Macro-time `ArgumentError` validation for `:visibility`, durations, `:vary`, and the booleans. Zero new runtime dependencies.
10
+
3
11
  ## 1.19.0 (2026-06-13)
4
12
 
5
13
  Two new concerns, selected by a unanimous three-judge design panel over six independently-proposed candidates (typed-JSON-settings and RFC-deprecation-headers each beat conditional counter caches, deep cloning, anonymization, params contracts, feature gates, and maintenance mode on value × shippability), then hardened in review (an `ActiveSupport::TimeWithZone` passed as `deprecated_at:`/`sunset_at:` — i.e. `Time.current` — was spuriously rejected as unparseable because `Module#===` ignores TimeWithZone's `is_a?(Time)` lie; a bare `Date` written to a `:datetime` key now anchors to midnight UTC instead of the host zone via `Date#to_time`; an unknown `header_format:` now raises at declaration time instead of silently emitting the RFC form). 894 examples, 0 failures.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 Ethan Nguyen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md CHANGED
@@ -1,5 +1,21 @@
1
+ <div align="center">
2
+
1
3
  # 🧩 ConcernsOnRails
2
4
 
5
+ **Plug-and-play ActiveSupport concerns for Rails models &amp; controllers.**<br/>
6
+ One `include`, one declarative macro — done.
7
+
8
+ [![Gem Version](https://img.shields.io/gem/v/concerns_on_rails?logo=rubygems&logoColor=white&color=CC342D)](https://rubygems.org/gems/concerns_on_rails)
9
+ [![Downloads](https://img.shields.io/gem/dt/concerns_on_rails?color=1f6feb)](https://rubygems.org/gems/concerns_on_rails)
10
+ [![CI](https://github.com/VSN2015/concerns_on_rails/actions/workflows/ci.yml/badge.svg)](https://github.com/VSN2015/concerns_on_rails/actions/workflows/ci.yml)
11
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org)
12
+ [![Rails](https://img.shields.io/badge/rails-5.0--8.x-CC0000?logo=rubyonrails&logoColor=white)](https://rubyonrails.org)
13
+ [![License: MIT](https://img.shields.io/badge/license-MIT-3fb950.svg)](#-license)
14
+
15
+ 🧩 **23 model concerns** &nbsp;·&nbsp; 🎮 **16 controller concerns** &nbsp;·&nbsp; 🪶 **lean deps** &nbsp;·&nbsp; ✅ **schema-validated**
16
+
17
+ </div>
18
+
3
19
  > 🇻🇳 **Hoàng Sa and Trường Sa belong to Việt Nam.**
4
20
 
5
21
  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.
@@ -20,59 +36,62 @@ Article.published.without_deleted.find("hello-world")
20
36
 
21
37
  ## 📚 Table of Contents
22
38
 
23
- - [Why this gem?](#-why-this-gem)
24
- - [Installation](#-installation)
25
- - [Compatibility](#-compatibility)
26
- - [Quick Start](#-quick-start)
27
- - **Model concerns**
28
- - [Sluggable](#-sluggable) — URL-friendly slugs
29
- - [Sortable](#-sortable) list ordering via `acts_as_list`
30
- - [Publishable](#-publishable) `published_at` timestamp publishing
31
- - [SoftDeletable](#-softdeletable) soft delete with scopes & hooks
32
- - [Hashable](#-hashable) auto-generate tokens / UUIDs / codes
33
- - [Schedulable](#-schedulable) `starts_at` / `ends_at` time windows
34
- - [Expirable](#-expirable) single-timestamp expiry
35
- - [Normalizable](#-normalizable) attribute normalization (`:email`, `:phone`, …)
36
- - [Searchable](#-searchable) LIKE/ILIKE search across configured columns
37
- - [Activatable](#-activatable) boolean active/inactive toggle
38
- - [Tokenizable](#-tokenizable) security tokens with timing-safe lookup
39
- - [Sequenceable](#-sequenceable) ordered, human-friendly reference numbers
40
- - [Stateable](#-stateable) lightweight string-backed state machine
41
- - [Addressable](#-addressable) postal address normalization + format validation
42
- - [Taggable](#-taggable) lightweight tagging over a single column
43
- - [Sanitizable](#-sanitizable) opt-in HTML sanitization (XSS defense-in-depth)
44
- - [Maskable](#-maskable) non-destructive display masking of sensitive fields
45
- - [Monetizable](#-monetizable) integer-cents money columns (BigDecimal)
46
- - [Auditable](#-auditable) single-column change history ("paper_trail-lite")
47
- - [Lockable](#-lockable) failed-attempt tracking + account lockout
48
- - [Aliasable](#-aliasable) full read/write/query aliases for associations
49
- - [Storable](#-storable) typed accessors over one JSON settings column ("store_attribute-lite")
50
- - **Controller concerns**
51
- - [Paginatable](#-paginatable) offset pagination with headers
52
- - [CursorPaginatable](#-cursorpaginatable) — cursor (keyset) pagination with headers
53
- - [Filterable](#-filterable) declarative URL-param filters
54
- - [Sortable (controller)](#-sortable-controller) — URL-param ordering with allow-list
55
- - [Respondable](#-respondable) standardized JSON envelopes
56
- - [ErrorHandleable](#-errorhandleable) — JSON `rescue_from` handlers for common controller errors
57
- - [Includable](#-includable) whitelisted association sideloading + sparse fieldsets
58
- - [SecureHeadable](#-secureheadable) security response headers + native CSP DSL
59
- - [Localizable](#-localizable) per-request locale from params / Accept-Language
60
- - [Authorizable](#-authorizable) per-action 403 authorization gate (block-based)
61
- - [Throttleable](#-throttleable) rate limiting with 429 + `X-RateLimit-*` headers
62
- - [Timezoneable](#-timezoneable) per-request `Time.zone` from params / header / cookie
63
- - [Idempotentable](#-idempotentable) `Idempotency-Key` request replay (409 on concurrent duplicates)
64
- - [WebhookVerifiable](#-webhookverifiable) HMAC verification for inbound webhooks (Stripe/GitHub/Shopify)
65
- - [Deprecatable](#-deprecatable) RFC `Deprecation`/`Sunset` headers + 410 sunset enforcement
66
- - [Module paths & namespacing](#-module-paths--namespacing)
67
- - [Development](#-development)
68
- - [Contributing](#-contributing)
69
- - [License](#-license)
39
+ [Why this gem?](#-why-this-gem) &nbsp;·&nbsp; [Installation](#-installation) &nbsp;·&nbsp; [Compatibility](#-compatibility) &nbsp;·&nbsp; [Quick Start](#-quick-start) &nbsp;·&nbsp; [Module paths](#-module-paths--namespacing) &nbsp;·&nbsp; [Development](#-development) &nbsp;·&nbsp; [Contributing](#-contributing) &nbsp;·&nbsp; [License](#-license)
40
+
41
+ ### 🧱 Model concerns
42
+
43
+ | Concern | What it does |
44
+ |---------|--------------|
45
+ | [📝 Sluggable](#-sluggable) | URL-friendly slugs |
46
+ | [🔢 Sortable](#-sortable) | List ordering via `acts_as_list` |
47
+ | [📤 Publishable](#-publishable) | `published_at` timestamp publishing |
48
+ | [❌ SoftDeletable](#-softdeletable) | Soft delete with scopes &amp; hooks |
49
+ | [🔐 Hashable](#-hashable) | Auto-generate tokens / UUIDs / codes |
50
+ | [🗓️ Schedulable](#-schedulable) | `starts_at` / `ends_at` time windows |
51
+ | [⏳ Expirable](#-expirable) | Single-timestamp expiry |
52
+ | [✨ Normalizable](#-normalizable) | Attribute normalization (`:email`, `:phone`, …) |
53
+ | [🔍 Searchable](#-searchable) | LIKE / ILIKE search across columns |
54
+ | [✅ Activatable](#-activatable) | Boolean active / inactive toggle |
55
+ | [🔑 Tokenizable](#-tokenizable) | Security tokens with timing-safe lookup |
56
+ | [🧾 Sequenceable](#-sequenceable) | Ordered, human-friendly reference numbers |
57
+ | [🔄 Stateable](#-stateable) | Lightweight string-backed state machine |
58
+ | [🏠 Addressable](#-addressable) | Postal address normalization + validation |
59
+ | [🏷️ Taggable](#-taggable) | Lightweight tagging over a single column |
60
+ | [🧼 Sanitizable](#-sanitizable) | Opt-in HTML sanitization (XSS defense) |
61
+ | [🙈 Maskable](#-maskable) | Non-destructive display masking |
62
+ | [💰 Monetizable](#-monetizable) | Integer-cents money columns (BigDecimal) |
63
+ | [📜 Auditable](#-auditable) | Single-column change history ("paper_trail-lite") |
64
+ | [🔐 Lockable](#-lockable) | Failed-attempt tracking + account lockout |
65
+ | [🪞 Aliasable](#-aliasable) | Full read / write / query association aliases |
66
+ | [⚙️ Storable](#-storable) | Typed accessors over one JSON column ("store_attribute-lite") |
67
+ | [🧮 CounterCacheable](#-countercacheable) | Conditional denormalized counters ("counter_culture-lite") |
68
+
69
+ ### 🎮 Controller concerns
70
+
71
+ | Concern | What it does |
72
+ |---------|--------------|
73
+ | [📄 Paginatable](#-paginatable) | Offset pagination with headers |
74
+ | [🧭 CursorPaginatable](#-cursorpaginatable) | Cursor (keyset) pagination with headers |
75
+ | [🔎 Filterable](#-filterable) | Declarative URL-param filters |
76
+ | [↕️ Sortable (controller)](#-sortable-controller) | URL-param ordering with allow-list |
77
+ | [📦 Respondable](#-respondable) | Standardized JSON envelopes |
78
+ | [🛟 ErrorHandleable](#-errorhandleable) | JSON `rescue_from` handlers |
79
+ | [🔗 Includable](#-includable) | Association sideloading + sparse fieldsets |
80
+ | [🛡️ SecureHeadable](#-secureheadable) | Security response headers + native CSP DSL |
81
+ | [🌐 Localizable](#-localizable) | Per-request locale from params / `Accept-Language` |
82
+ | [🔒 Authorizable](#-authorizable) | Per-action 403 authorization gate |
83
+ | [🚦 Throttleable](#-throttleable) | Rate limiting (429 + `X-RateLimit-*`) |
84
+ | [🕒 Timezoneable](#-timezoneable) | Per-request `Time.zone` from params / header / cookie |
85
+ | [🔁 Idempotentable](#-idempotentable) | `Idempotency-Key` request replay |
86
+ | [🪝 WebhookVerifiable](#-webhookverifiable) | HMAC verification for inbound webhooks |
87
+ | [🌅 Deprecatable](#-deprecatable) | RFC `Deprecation` / `Sunset` headers + 410 |
88
+ | [🗄️ Cacheable](#-cacheable) | HTTP conditional GET (ETag / 304) + `Cache-Control` |
70
89
 
71
90
  ---
72
91
 
73
92
  ## ✨ Why this gem?
74
93
 
75
- - **Twenty-one model concerns + fourteen controller concerns**, all production-ready
94
+ - **Twenty-three model concerns + sixteen controller concerns**, all production-ready
76
95
  - **One include, one macro** — no boilerplate, no glue code
77
96
  - **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
78
97
  - **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
@@ -1077,6 +1096,39 @@ account.flag_beta # affixed accessor
1077
1096
 
1078
1097
  ---
1079
1098
 
1099
+ ## 🧮 CounterCacheable
1100
+
1101
+ 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.
1102
+
1103
+ ```ruby
1104
+ class Comment < ApplicationRecord
1105
+ include ConcernsOnRails::CounterCacheable
1106
+
1107
+ belongs_to :post # declare the belongs_to FIRST
1108
+ belongs_to :author, class_name: "User"
1109
+
1110
+ counter_cacheable_by :post # posts.comments_count
1111
+ counter_cacheable_by :post, count: :approved_comments_count,
1112
+ if: -> { approved? } # conditional
1113
+ counter_cacheable_by :author, count: :posts_count, touch: true
1114
+ end
1115
+
1116
+ post.comments_count # maintained on create / destroy / update
1117
+ Comment.recount_counter_caches! # repair drift / backfill every counter
1118
+ ```
1119
+
1120
+ 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.
1121
+
1122
+ **Options** (`counter_cacheable_by association, …`, repeatable): `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`).
1123
+
1124
+ **Notes**
1125
+ - The `belongs_to` must be declared **before** the macro (the reflection is validated at declaration). Polymorphic associations are not supported.
1126
+ - Don't also set native `counter_cache: true` on the same column — both would fire and double-count.
1127
+ - Counters track the **persisted** record; writes that skip callbacks (`update_column(s)`, `update_all`, `delete`) are not tracked — run `recount_counter_caches!` to reconcile. It rewrites every parent (portable across adapters, but O(n) for conditional counters) — a maintenance operation, run it offline.
1128
+ - Reach for [`counter_culture`](https://github.com/magnusvk/counter_culture) when you need multi-level rollups, delta columns, or after-commit execution.
1129
+
1130
+ ---
1131
+
1080
1132
  # 🎮 Controller Concerns
1081
1133
 
1082
1134
  Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
@@ -1575,6 +1627,43 @@ end
1575
1627
 
1576
1628
  ---
1577
1629
 
1630
+ ## 🗄️ Cacheable
1631
+
1632
+ 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`.
1633
+
1634
+ ```ruby
1635
+ class Api::ArticlesController < ApplicationController
1636
+ include ConcernsOnRails::Controllers::Cacheable
1637
+
1638
+ http_cache_actions :index, :show, max_age: 5.minutes,
1639
+ visibility: :public, vary: "Accept"
1640
+
1641
+ def show
1642
+ @article = Article.find(params[:id])
1643
+ return unless stale_resource?(@article) # 304 + halt when the client copy is fresh
1644
+ render json: @article
1645
+ end
1646
+ end
1647
+
1648
+ # A matching response then carries:
1649
+ # Cache-Control: public, max-age=300
1650
+ # Vary: Accept
1651
+ # ETag: W/"…"
1652
+ # Last-Modified: Thu, 01 Jan 2026 12:00:00 GMT
1653
+ ```
1654
+
1655
+ `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`.
1656
+
1657
+ **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`), `stale_while_revalidate:`, `vary:` (String or Array, appended to any existing `Vary`).
1658
+
1659
+ **Conditional-GET correctness**
1660
+ - 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.
1661
+ - `Last-Modified` is an IMF-fixdate via `Time#httpdate` (not hand-rolled ISO 8601); `If-Modified-Since` is compared at whole-second granularity.
1662
+ - 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.
1663
+ - Override `cache_etag_for` / `cache_last_modified_for` to customise validator derivation. For write-side preconditions (`If-Match` → 412), reach for Rails' own helpers.
1664
+
1665
+ ---
1666
+
1578
1667
  ## 🗂️ Module paths & namespacing
1579
1668
 
1580
1669
  Every concern is available under two paths:
@@ -0,0 +1,318 @@
1
+ require "active_support/concern"
2
+ require "digest/md5"
3
+ require "time"
4
+
5
+ module ConcernsOnRails
6
+ module Controllers
7
+ # HTTP conditional GET + declarative Cache-Control ("fresh_when/stale?-lite"
8
+ # for JSON APIs). Two layers:
9
+ #
10
+ # 1. A declarative `Cache-Control`/`Vary` POLICY per action (the macro).
11
+ # 2. Per-action validators (ETag / Last-Modified) with an automatic
12
+ # `304 Not Modified` short-circuit (the `stale_resource?` helper).
13
+ #
14
+ # class Api::ArticlesController < ApplicationController
15
+ # include ConcernsOnRails::Controllers::Cacheable
16
+ #
17
+ # http_cache_actions :index, :show, max_age: 5.minutes,
18
+ # visibility: :public, vary: "Accept"
19
+ #
20
+ # def show
21
+ # @article = Article.find(params[:id])
22
+ # return unless stale_resource?(@article) # 304 + halt when client copy is fresh
23
+ # render json: @article
24
+ # end
25
+ # end
26
+ #
27
+ # The method names are deliberately distinct from Rails'
28
+ # `ActionController::ConditionalGet` (`fresh_when` / `stale?` / `expires_in`)
29
+ # so including this concern in a real controller never shadows them.
30
+ #
31
+ # Conditional-GET correctness (the value over a hand-rolled version):
32
+ # * ETag is a WEAK validator `W/"<md5>"` derived from the resource's cache
33
+ # key — appropriate for serialized representations (semantic, not
34
+ # byte-for-byte, equivalence). `If-None-Match` is matched with weak
35
+ # comparison, honours `*`, and accepts a comma-separated list.
36
+ # * `Last-Modified` is an IMF-fixdate via `Time#httpdate` (NOT ISO 8601 —
37
+ # the classic bug). `If-Modified-Since` is compared at whole-second
38
+ # granularity (HTTP dates carry no sub-second part).
39
+ # * When BOTH `If-None-Match` and `If-Modified-Since` are sent, the ETag
40
+ # wins and the date is ignored (RFC 7232 §3.3).
41
+ # * The 304 is only sent for safe requests (GET/HEAD) and still carries the
42
+ # validators AND the policy headers (Cache-Control rides the 304, like
43
+ # Deprecatable's headers ride the 410).
44
+ #
45
+ # Notes:
46
+ # * `no_store: true` overrides everything (emits the lone `no-store`).
47
+ # * `Vary` is appended to any existing `Vary` header, de-duplicated.
48
+ # * No positional actions = catch-all; the LAST matching rule wins (the
49
+ # Deprecatable convention — caching policy is an override).
50
+ # * Works on bare objects (every `request`/`response` touch is guarded), so
51
+ # it is testable without the full Rails stack.
52
+ # * For write-side preconditions (`If-Match` / `If-Unmodified-Since` → 412)
53
+ # reach for Rails' own conditional-GET helpers; this concern covers the
54
+ # read path.
55
+ module Cacheable
56
+ extend ActiveSupport::Concern
57
+
58
+ LABEL = "ConcernsOnRails::Controllers::Cacheable".freeze
59
+ VALID_VISIBILITY = %i[public private].freeze
60
+ SAFE_METHODS = %w[GET HEAD].freeze
61
+
62
+ included do
63
+ class_attribute :cacheable_rules, instance_accessor: false, default: []
64
+ after_action :apply_http_cache_headers
65
+ end
66
+
67
+ module ClassMethods
68
+ # Declare a Cache-Control/Vary policy for the given actions (none =
69
+ # catch-all). Repeatable; rules accumulate (reassigned, never mutated, so
70
+ # subclasses inherit) and the LAST one matching the action wins.
71
+ def http_cache_actions(*actions, visibility: :private, max_age: nil, must_revalidate: false,
72
+ no_store: false, stale_while_revalidate: nil, vary: nil)
73
+ actions = actions.flatten.map(&:to_s)
74
+ visibility = visibility.to_sym
75
+ validate_http_cache!(visibility: visibility, max_age: max_age, no_store: no_store,
76
+ must_revalidate: must_revalidate, stale_while_revalidate: stale_while_revalidate)
77
+
78
+ rule = {
79
+ actions: actions, visibility: visibility,
80
+ max_age: http_cache_seconds(max_age),
81
+ must_revalidate: must_revalidate ? true : false,
82
+ no_store: no_store ? true : false,
83
+ stale_while_revalidate: http_cache_seconds(stale_while_revalidate),
84
+ vary: http_cache_vary(vary)
85
+ }
86
+ self.cacheable_rules = cacheable_rules + [rule]
87
+ end
88
+
89
+ private
90
+
91
+ def validate_http_cache!(visibility:, max_age:, no_store:, must_revalidate:, stale_while_revalidate:)
92
+ unless VALID_VISIBILITY.include?(visibility)
93
+ raise ArgumentError, "#{LABEL}: :visibility must be one of #{VALID_VISIBILITY.join(', ')}"
94
+ end
95
+ raise ArgumentError, "#{LABEL}: :max_age must be a positive Integer/Duration or nil" unless http_cache_duration_ok?(max_age)
96
+ unless http_cache_duration_ok?(stale_while_revalidate)
97
+ raise ArgumentError, "#{LABEL}: :stale_while_revalidate must be a positive Integer/Duration or nil"
98
+ end
99
+ raise ArgumentError, "#{LABEL}: :no_store must be true or false" unless [true, false].include?(no_store)
100
+ return if [true, false].include?(must_revalidate)
101
+
102
+ raise ArgumentError, "#{LABEL}: :must_revalidate must be true or false"
103
+ end
104
+
105
+ def http_cache_duration_ok?(value)
106
+ value.nil? || ((value.is_a?(Integer) || value.is_a?(ActiveSupport::Duration)) && value.to_i.positive?)
107
+ end
108
+
109
+ def http_cache_seconds(value)
110
+ value&.to_i
111
+ end
112
+
113
+ # Normalize + validate :vary into an Array of header names (or nil).
114
+ def http_cache_vary(value)
115
+ case value
116
+ when nil then nil
117
+ when String then http_cache_vary_list([value])
118
+ when Array then http_cache_vary_list(value)
119
+ else raise ArgumentError, "#{LABEL}: :vary must be a String or Array of Strings"
120
+ end
121
+ end
122
+
123
+ def http_cache_vary_list(values)
124
+ list = values.map { |v| v.to_s.strip }
125
+ if list.empty? || list.any?(&:empty?)
126
+ raise ArgumentError, "#{LABEL}: :vary must be a non-blank String or Array of non-blank Strings"
127
+ end
128
+
129
+ list
130
+ end
131
+ end
132
+
133
+ # after_action entry point. Public so host apps can `skip_after_action` it
134
+ # or override it. Emits the matching action's Cache-Control + Vary.
135
+ def apply_http_cache_headers
136
+ rule = http_cache_rule_for_action
137
+ return unless rule
138
+ return unless respond_to?(:response) && response
139
+
140
+ value = http_cache_control_value(rule)
141
+ response.set_header("Cache-Control", value) if value
142
+ response.set_header("Vary", http_cache_merge_vary(rule[:vary])) if rule[:vary]
143
+ nil
144
+ end
145
+
146
+ # Set ETag/Last-Modified validators for the resource, then — for a safe
147
+ # request whose precondition matches — send 304 and return false. Returns
148
+ # true when the client must be sent a fresh body. Mirrors Rails `stale?`.
149
+ def stale_resource?(resource = nil, etag: nil, last_modified: nil)
150
+ validators = set_cache_validators(resource, etag: etag, last_modified: last_modified)
151
+ return true unless http_cache_safe_request?
152
+ return true unless request_matches_cache?(etag: validators[:etag], last_modified: validators[:last_modified])
153
+
154
+ http_cache_send_not_modified
155
+ false
156
+ end
157
+
158
+ # Set the ETag/Last-Modified response headers (no short-circuit). Returns
159
+ # the computed { etag:, last_modified: } pair.
160
+ def set_cache_validators(resource = nil, etag: nil, last_modified: nil)
161
+ etag ||= cache_etag_for(resource) unless resource.nil?
162
+ last_modified ||= cache_last_modified_for(resource) unless resource.nil?
163
+ http_cache_write_validators(etag, last_modified)
164
+ { etag: etag, last_modified: last_modified }
165
+ end
166
+
167
+ # Side-effect-free: does the request's precondition match these validators?
168
+ def request_matches_cache?(etag: nil, last_modified: nil)
169
+ if_none_match = http_cache_request_header("If-None-Match")
170
+ if_modified_since = http_cache_request_header("If-Modified-Since")
171
+
172
+ # If-None-Match takes precedence when present (RFC 7232 §3.3).
173
+ if if_none_match
174
+ etag && http_cache_etag_matches?(if_none_match, etag)
175
+ elsif if_modified_since
176
+ last_modified ? http_cache_not_modified_since?(if_modified_since, last_modified) : false
177
+ else
178
+ false
179
+ end
180
+ end
181
+
182
+ # Override points for deriving validators from a resource.
183
+ def cache_etag_for(resource)
184
+ http_cache_weak_etag(http_cache_key_for(resource))
185
+ end
186
+
187
+ def cache_last_modified_for(resource)
188
+ http_cache_timestamp_for(resource)
189
+ end
190
+
191
+ private
192
+
193
+ def http_cache_write_validators(etag, last_modified)
194
+ return unless respond_to?(:response) && response
195
+
196
+ response.set_header("ETag", etag) if etag
197
+ response.set_header("Last-Modified", last_modified.httpdate) if last_modified
198
+ end
199
+
200
+ def http_cache_rule_for_action
201
+ action = http_cache_action_name
202
+ return nil unless action
203
+
204
+ self.class.cacheable_rules.reverse_each.find do |rule|
205
+ rule[:actions].empty? || rule[:actions].include?(action)
206
+ end
207
+ end
208
+
209
+ def http_cache_control_value(rule)
210
+ return "no-store" if rule[:no_store]
211
+
212
+ parts = [rule[:visibility].to_s]
213
+ parts << "max-age=#{rule[:max_age]}" if rule[:max_age]
214
+ parts << "must-revalidate" if rule[:must_revalidate]
215
+ parts << "stale-while-revalidate=#{rule[:stale_while_revalidate]}" if rule[:stale_while_revalidate]
216
+ parts.join(", ")
217
+ end
218
+
219
+ def http_cache_merge_vary(vary_list)
220
+ values = []
221
+ existing = response.headers["Vary"]
222
+ values.concat(existing.split(",").map(&:strip)) unless existing.to_s.empty?
223
+ values.concat(vary_list)
224
+ values.uniq.join(", ")
225
+ end
226
+
227
+ def http_cache_send_not_modified
228
+ response.status = 304 if respond_to?(:response) && response
229
+
230
+ if respond_to?(:head)
231
+ head :not_modified
232
+ else
233
+ render(status: :not_modified)
234
+ end
235
+ end
236
+
237
+ def http_cache_safe_request?
238
+ return true unless respond_to?(:request) && request
239
+
240
+ if request.respond_to?(:get?) && request.respond_to?(:head?)
241
+ request.get? || request.head?
242
+ elsif request.respond_to?(:request_method)
243
+ SAFE_METHODS.include?(request.request_method.to_s.upcase)
244
+ else
245
+ true
246
+ end
247
+ end
248
+
249
+ def http_cache_request_header(name)
250
+ return nil unless respond_to?(:request) && request.respond_to?(:headers) && request.headers
251
+
252
+ request.headers[name]
253
+ end
254
+
255
+ def http_cache_etag_matches?(header, etag)
256
+ candidates = header.to_s.split(",").map(&:strip)
257
+ return true if candidates.include?("*")
258
+
259
+ target = http_cache_normalize_etag(etag)
260
+ candidates.any? { |candidate| http_cache_normalize_etag(candidate) == target }
261
+ end
262
+
263
+ # Weak comparison: ignore the "W/" prefix (RFC 7232 §2.3.2).
264
+ def http_cache_normalize_etag(value)
265
+ value.to_s.strip.sub(%r{\AW/}, "")
266
+ end
267
+
268
+ def http_cache_not_modified_since?(header, last_modified)
269
+ since = http_cache_parse_http_date(header)
270
+ return false unless since
271
+
272
+ last_modified.to_i <= since.to_i
273
+ end
274
+
275
+ def http_cache_parse_http_date(value)
276
+ Time.httpdate(value.to_s)
277
+ rescue ArgumentError
278
+ nil
279
+ end
280
+
281
+ def http_cache_weak_etag(key)
282
+ %(W/"#{Digest::MD5.hexdigest(key.to_s)}")
283
+ end
284
+
285
+ # cache_key_with_version (Rails 5.2+) → cache_key → a manual key; a
286
+ # relation/array folds its members' keys (+ size) so a changed collection
287
+ # changes the ETag.
288
+ def http_cache_key_for(resource)
289
+ if resource.respond_to?(:cache_key_with_version)
290
+ resource.cache_key_with_version
291
+ elsif resource.respond_to?(:cache_key)
292
+ resource.cache_key
293
+ elsif resource.is_a?(String)
294
+ resource
295
+ elsif resource.respond_to?(:to_a)
296
+ members = resource.to_a
297
+ "#{members.size}-#{members.map { |member| http_cache_key_for(member) }.join('/')}"
298
+ else
299
+ resource.to_s
300
+ end
301
+ end
302
+
303
+ def http_cache_timestamp_for(resource)
304
+ if resource.respond_to?(:updated_at)
305
+ resource.updated_at
306
+ elsif resource.respond_to?(:maximum)
307
+ resource.maximum(:updated_at)
308
+ elsif !resource.is_a?(String) && resource.respond_to?(:map)
309
+ resource.map { |member| member.respond_to?(:updated_at) ? member.updated_at : nil }.compact.max
310
+ end
311
+ end
312
+
313
+ def http_cache_action_name
314
+ respond_to?(:action_name) ? action_name.to_s : nil
315
+ end
316
+ end
317
+ end
318
+ end
@@ -24,4 +24,5 @@ module ConcernsOnRails
24
24
  Lockable = Models::Lockable
25
25
  Aliasable = Models::Aliasable
26
26
  Storable = Models::Storable
27
+ CounterCacheable = Models::CounterCacheable
27
28
  end
@@ -0,0 +1,279 @@
1
+ require "active_support/concern"
2
+
3
+ module ConcernsOnRails
4
+ module Models
5
+ # Conditional, denormalized association counters ("counter_culture-lite").
6
+ # Rails' native `belongs_to ..., counter_cache: true` maintains exactly one
7
+ # column counting *every* child — it cannot keep an `approved_comments_count`
8
+ # next to a `comments_count`, and it has no way to repair drift after a
9
+ # backfill or a counter_cache-less write. This concern, declared on the
10
+ # CHILD, keeps one or many parent columns in sync, each with an optional
11
+ # `if:` condition, and ships a `recount_counter_caches!` repair method.
12
+ #
13
+ # class Comment < ApplicationRecord
14
+ # include ConcernsOnRails::CounterCacheable
15
+ # belongs_to :post # declare the belongs_to FIRST
16
+ # belongs_to :author, class_name: "User"
17
+ #
18
+ # counter_cacheable_by :post # posts.comments_count
19
+ # counter_cacheable_by :post, count: :approved_comments_count,
20
+ # if: -> { approved? } # conditional counter
21
+ # counter_cacheable_by :author, count: :posts_count, touch: true
22
+ # end
23
+ #
24
+ # Post.find(1).comments_count # maintained on create/destroy/update
25
+ # Comment.recount_counter_caches! # repair/backfill every counter
26
+ #
27
+ # Behaviour:
28
+ # * create/destroy adjust the counter by ±1 when the foreign key is present
29
+ # and the `if:` condition holds for the record's current state.
30
+ # * update handles BOTH a foreign-key reparent (the row moved to another
31
+ # parent) AND a condition flip (the `if:` result changed): the old parent
32
+ # is decremented if it used to count the row, the new parent incremented
33
+ # if it counts it now. A no-op save writes nothing.
34
+ # * Adjustments use `update_counters` — a single SQL `COALESCE(col,0) ± 1`,
35
+ # atomic under concurrency — and run inside the record's own save
36
+ # transaction, so a rolled-back save rolls back the counter too.
37
+ #
38
+ # Notes:
39
+ # * The `belongs_to` must be declared BEFORE the macro (the reflection is
40
+ # validated at declaration time). Polymorphic associations are not
41
+ # supported in this version.
42
+ # * Do NOT also set native `counter_cache: true` on the same column — both
43
+ # would fire and double-count.
44
+ # * Counters track the PERSISTED record. Writes that skip callbacks
45
+ # (`update_column(s)`, `update_all`, `delete`, raw SQL) are not tracked —
46
+ # run `recount_counter_caches!` to reconcile.
47
+ # * `if:` conditions should read the record's OWN columns; the previous
48
+ # state is reconstructed from the changed attributes, not the
49
+ # associations.
50
+ # * `recount_counter_caches!` rewrites every parent's counter and, for a
51
+ # conditional counter, scans the children in Ruby (portable across
52
+ # adapters, but O(n)) — a maintenance operation, run it offline.
53
+ # * Reach for the `counter_culture` gem when you need multi-level rollups,
54
+ # delta columns, or after-commit execution.
55
+ module CounterCacheable
56
+ extend ActiveSupport::Concern
57
+
58
+ LABEL = "ConcernsOnRails::Models::CounterCacheable".freeze
59
+
60
+ included do
61
+ class_attribute :counter_cacheable_rules, instance_accessor: false, default: []
62
+
63
+ after_create :counter_cacheable_run_create
64
+ after_update :counter_cacheable_run_update
65
+ after_destroy :counter_cacheable_run_destroy
66
+ end
67
+
68
+ module ClassMethods
69
+ # Declare one counter. Repeatable — each call maintains another column
70
+ # (rules accumulate, reassigned never mutated, so subclasses inherit).
71
+ # `count:` defaults to "<table_name>_count" (e.g. comments → comments_count).
72
+ def counter_cacheable_by(association, count: nil, touch: false, **options)
73
+ association = association.to_sym
74
+ condition = options[:if]
75
+ extra = options.keys - [:if]
76
+ raise ArgumentError, "#{LABEL}: unknown option(s): #{extra.join(', ')}" unless extra.empty?
77
+
78
+ reflection = reflect_on_association(association)
79
+ validate_counter_cacheable!(association, reflection, condition, touch)
80
+
81
+ count_column = (count || "#{table_name}_count").to_sym
82
+ counter_cacheable_ensure_parent_column!(reflection, count_column)
83
+
84
+ self.counter_cacheable_rules = counter_cacheable_rules + [{
85
+ association: association, count_column: count_column,
86
+ condition: condition, touch: touch ? true : false
87
+ }]
88
+ end
89
+
90
+ # Recompute every (or one) counter from scratch — drift repair / backfill.
91
+ # Returns { count_column => parents_with_a_nonzero_count }.
92
+ def recount_counter_caches!(only_association = nil)
93
+ rules = counter_cacheable_rules
94
+ rules = rules.select { |r| r[:association] == only_association.to_sym } if only_association
95
+
96
+ rules.to_h do |rule|
97
+ [rule[:count_column], counter_cacheable_recount_rule(rule)]
98
+ end
99
+ end
100
+
101
+ private
102
+
103
+ def validate_counter_cacheable!(association, reflection, condition, touch)
104
+ if reflection.nil?
105
+ raise ArgumentError,
106
+ "#{LABEL}: no association `#{association}` — declare " \
107
+ "`belongs_to :#{association}` before `counter_cacheable_by :#{association}`"
108
+ end
109
+ unless reflection.macro == :belongs_to
110
+ raise ArgumentError, "#{LABEL}: `#{association}` must be a belongs_to association (got #{reflection.macro})"
111
+ end
112
+ raise ArgumentError, "#{LABEL}: polymorphic association `#{association}` is not supported" if reflection.polymorphic?
113
+ raise ArgumentError, "#{LABEL}: :if must be callable (respond to #call)" unless condition.nil? || condition.respond_to?(:call)
114
+ return if [true, false].include?(touch)
115
+
116
+ raise ArgumentError, "#{LABEL}: :touch must be true or false"
117
+ end
118
+
119
+ # Validate the column on the PARENT table when its class is already
120
+ # loaded and connected; defer silently otherwise (load-order tolerant).
121
+ def counter_cacheable_ensure_parent_column!(reflection, count_column)
122
+ klass = begin
123
+ reflection.klass
124
+ rescue StandardError
125
+ nil
126
+ end
127
+ return unless klass
128
+ return unless counter_cacheable_table_exists?(klass)
129
+ return if klass.column_names.include?(count_column.to_s)
130
+
131
+ raise ArgumentError,
132
+ "#{LABEL}: '#{count_column}' does not exist in the database (table: #{klass.table_name})"
133
+ end
134
+
135
+ def counter_cacheable_table_exists?(klass)
136
+ klass.table_exists?
137
+ rescue StandardError
138
+ false
139
+ end
140
+
141
+ def counter_cacheable_recount_rule(rule)
142
+ reflection = reflect_on_association(rule[:association])
143
+ fk = reflection.foreign_key
144
+ parent_class = reflection.klass
145
+ column = rule[:count_column]
146
+ condition = rule[:condition]
147
+
148
+ tally = if condition
149
+ counter_cacheable_recount_tally(fk, condition)
150
+ else
151
+ unscoped.where.not(fk => nil).group(fk).count
152
+ end
153
+
154
+ parent_class.unscoped.update_all(column => 0)
155
+ tally.each do |parent_id, n|
156
+ next if parent_id.nil? || n.to_i.zero?
157
+
158
+ parent_class.unscoped.where(parent_class.primary_key => parent_id).update_all(column => n)
159
+ end
160
+ tally.count { |_id, n| n.to_i.positive? }
161
+ end
162
+
163
+ def counter_cacheable_recount_tally(foreign_key, condition)
164
+ tally = Hash.new(0)
165
+ unscoped.where.not(foreign_key => nil).find_each do |record|
166
+ tally[record[foreign_key]] += 1 if record.instance_exec(&condition)
167
+ end
168
+ tally
169
+ end
170
+ end
171
+
172
+ private
173
+
174
+ def counter_cacheable_run_create
175
+ self.class.counter_cacheable_rules.each do |rule|
176
+ next unless counter_cacheable_counted_now?(rule)
177
+
178
+ counter_cacheable_adjust(rule, counter_cacheable_fk_value(rule), 1)
179
+ end
180
+ end
181
+
182
+ def counter_cacheable_run_destroy
183
+ self.class.counter_cacheable_rules.each do |rule|
184
+ next unless counter_cacheable_counted_now?(rule)
185
+
186
+ counter_cacheable_adjust(rule, counter_cacheable_fk_value(rule), -1)
187
+ end
188
+ end
189
+
190
+ def counter_cacheable_run_update
191
+ self.class.counter_cacheable_rules.each { |rule| counter_cacheable_apply_update(rule) }
192
+ end
193
+
194
+ # The create × destroy × (reparent + condition-flip) matrix.
195
+ def counter_cacheable_apply_update(rule)
196
+ fk = counter_cacheable_reflection(rule).foreign_key.to_s
197
+ changes = counter_cacheable_changes
198
+ new_fk = self[fk]
199
+ old_fk = changes.key?(fk) ? changes[fk].first : new_fk
200
+
201
+ old_counted = counter_cacheable_counted_previously?(rule)
202
+ new_counted = counter_cacheable_counted_now?(rule)
203
+
204
+ if old_fk == new_fk
205
+ counter_cacheable_apply_same_parent(rule, new_fk, old_counted, new_counted)
206
+ else
207
+ counter_cacheable_apply_reparent(rule, old_fk, new_fk, old_counted, new_counted)
208
+ end
209
+ end
210
+
211
+ # Same parent — only a condition flip can change the count.
212
+ def counter_cacheable_apply_same_parent(rule, parent_id, old_counted, new_counted)
213
+ return if old_counted == new_counted
214
+
215
+ counter_cacheable_adjust(rule, parent_id, new_counted ? 1 : -1)
216
+ end
217
+
218
+ # Foreign key changed — settle the old parent and the new one independently.
219
+ def counter_cacheable_apply_reparent(rule, old_fk, new_fk, old_counted, new_counted)
220
+ counter_cacheable_adjust(rule, old_fk, -1) if old_fk && old_counted
221
+ counter_cacheable_adjust(rule, new_fk, 1) if new_fk && new_counted
222
+ end
223
+
224
+ def counter_cacheable_adjust(rule, parent_id, delta)
225
+ return if parent_id.nil?
226
+
227
+ counters = { rule[:count_column] => delta }
228
+ counters[:touch] = true if rule[:touch]
229
+ counter_cacheable_reflection(rule).klass.update_counters(parent_id, counters)
230
+ end
231
+
232
+ def counter_cacheable_fk_value(rule)
233
+ self[counter_cacheable_reflection(rule).foreign_key]
234
+ end
235
+
236
+ def counter_cacheable_reflection(rule)
237
+ self.class.reflect_on_association(rule[:association])
238
+ end
239
+
240
+ def counter_cacheable_counted_now?(rule)
241
+ condition = rule[:condition]
242
+ return true unless condition
243
+
244
+ instance_exec(&condition) ? true : false
245
+ end
246
+
247
+ # Evaluate the condition against the record as it was BEFORE this save by
248
+ # temporarily restoring the changed attributes to their previous values.
249
+ def counter_cacheable_counted_previously?(rule)
250
+ condition = rule[:condition]
251
+ return true unless condition
252
+
253
+ counter_cacheable_with_previous_attributes { instance_exec(&condition) ? true : false }
254
+ end
255
+
256
+ def counter_cacheable_with_previous_attributes
257
+ changes = counter_cacheable_changes
258
+ return yield if changes.empty?
259
+
260
+ restore = {}
261
+ changes.each do |attr, (old, _new)|
262
+ restore[attr] = self[attr]
263
+ self[attr] = old
264
+ end
265
+ begin
266
+ yield
267
+ ensure
268
+ restore.each { |attr, value| self[attr] = value }
269
+ end
270
+ end
271
+
272
+ # { "attr" => [old, new] } for the just-completed save. saved_changes is
273
+ # Rails 5.1+; previous_changes is the 5.0 fallback.
274
+ def counter_cacheable_changes
275
+ respond_to?(:saved_changes) ? saved_changes : previous_changes
276
+ end
277
+ end
278
+ end
279
+ end
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.19.0".freeze
2
+ VERSION = "1.20.0".freeze
3
3
  end
@@ -51,6 +51,7 @@ require "concerns_on_rails/models/auditable"
51
51
  require "concerns_on_rails/models/lockable"
52
52
  require "concerns_on_rails/models/aliasable"
53
53
  require "concerns_on_rails/models/storable"
54
+ require "concerns_on_rails/models/counter_cacheable"
54
55
 
55
56
  # Controller concerns
56
57
  require "concerns_on_rails/controllers/paginatable"
@@ -68,6 +69,7 @@ require "concerns_on_rails/controllers/idempotentable"
68
69
  require "concerns_on_rails/controllers/webhook_verifiable"
69
70
  require "concerns_on_rails/controllers/cursor_paginatable"
70
71
  require "concerns_on_rails/controllers/deprecatable"
72
+ require "concerns_on_rails/controllers/cacheable"
71
73
 
72
74
  # Backwards compatibility (top-level aliases for pre-1.6 module paths)
73
75
  require "concerns_on_rails/legacy_aliases"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: concerns_on_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.19.0
4
+ version: 1.20.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Nguyen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-13 00:00:00.000000000 Z
11
+ date: 2026-06-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -68,9 +68,11 @@ extra_rdoc_files: []
68
68
  files:
69
69
  - CHANGELOG.md
70
70
  - CODE_OF_CONDUCT.md
71
+ - LICENSE.txt
71
72
  - README.md
72
73
  - lib/concerns_on_rails.rb
73
74
  - lib/concerns_on_rails/controllers/authorizable.rb
75
+ - lib/concerns_on_rails/controllers/cacheable.rb
74
76
  - lib/concerns_on_rails/controllers/cursor_paginatable.rb
75
77
  - lib/concerns_on_rails/controllers/deprecatable.rb
76
78
  - lib/concerns_on_rails/controllers/error_handleable.rb
@@ -90,6 +92,7 @@ files:
90
92
  - lib/concerns_on_rails/models/addressable.rb
91
93
  - lib/concerns_on_rails/models/aliasable.rb
92
94
  - lib/concerns_on_rails/models/auditable.rb
95
+ - lib/concerns_on_rails/models/counter_cacheable.rb
93
96
  - lib/concerns_on_rails/models/expirable.rb
94
97
  - lib/concerns_on_rails/models/hashable.rb
95
98
  - lib/concerns_on_rails/models/lockable.rb