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 +4 -4
- data/CHANGELOG.md +8 -0
- data/LICENSE.txt +21 -0
- data/README.md +137 -48
- data/lib/concerns_on_rails/controllers/cacheable.rb +318 -0
- data/lib/concerns_on_rails/legacy_aliases.rb +1 -0
- data/lib/concerns_on_rails/models/counter_cacheable.rb +279 -0
- data/lib/concerns_on_rails/version.rb +1 -1
- data/lib/concerns_on_rails.rb +2 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7eae55cba50d5c8a671187f6aba31e3b33e25eab1349054a45346400f64c70d1
|
|
4
|
+
data.tar.gz: e1369be961046f7f3926280083ee1f07fa027ad7c93d2161422f2b6dac687f57
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 & controllers.**<br/>
|
|
6
|
+
One `include`, one declarative macro — done.
|
|
7
|
+
|
|
8
|
+
[](https://rubygems.org/gems/concerns_on_rails)
|
|
9
|
+
[](https://rubygems.org/gems/concerns_on_rails)
|
|
10
|
+
[](https://github.com/VSN2015/concerns_on_rails/actions/workflows/ci.yml)
|
|
11
|
+
[](https://www.ruby-lang.org)
|
|
12
|
+
[](https://rubyonrails.org)
|
|
13
|
+
[](#-license)
|
|
14
|
+
|
|
15
|
+
🧩 **23 model concerns** · 🎮 **16 controller concerns** · 🪶 **lean deps** · ✅ **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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
39
|
+
[Why this gem?](#-why-this-gem) · [Installation](#-installation) · [Compatibility](#-compatibility) · [Quick Start](#-quick-start) · [Module paths](#-module-paths--namespacing) · [Development](#-development) · [Contributing](#-contributing) · [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 & 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-
|
|
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
|
|
@@ -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
|
data/lib/concerns_on_rails.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
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
|