concerns_on_rails 1.24.0 → 1.26.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 +39 -0
- data/README.md +195 -54
- data/lib/concerns_on_rails/controllers/permittable.rb +25 -0
- data/lib/concerns_on_rails/models/anonymizable.rb +42 -13
- data/lib/concerns_on_rails/models/counter_cacheable.rb +41 -28
- data/lib/concerns_on_rails/models/monetizable.rb +6 -1
- data/lib/concerns_on_rails/models/publishable.rb +10 -1
- data/lib/concerns_on_rails/models/sequenceable.rb +7 -17
- data/lib/concerns_on_rails/models/sluggable.rb +8 -5
- data/lib/concerns_on_rails/models/soft_deletable.rb +17 -10
- data/lib/concerns_on_rails/models/sortable.rb +21 -7
- data/lib/concerns_on_rails/models/stateable.rb +27 -2
- data/lib/concerns_on_rails/models/taggable.rb +31 -8
- data/lib/concerns_on_rails/support/sequence_calculator.rb +0 -4
- data/lib/concerns_on_rails/version.rb +1 -1
- data/lib/concerns_on_rails.rb +1 -0
- metadata +17 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7552b47dbc09add52b4bb8d519e8a8d4ed4e49590457114003fa83b6e9b7036e
|
|
4
|
+
data.tar.gz: b03171261ba6e24865ce46ab0c1e77a234cfec514e45e50a2dd055e82b5da287
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 461bbe636a2537cc5b60fe1eb5be3c080c115a3f119ca057a5f75d24dd54da44a6f5833a1f2baff6d2562d829fa77f98e7d1494e57cd8e1402b513f697025d61
|
|
7
|
+
data.tar.gz: 046a10782bc2259cf8911610a13b71289aa77ce2a1164045fd524fc785b58cc6061756ea99fba3f147d6964145641f63e03ed50b8530656b9a7269a4b83ce2bf
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 1.26.0 (2026-08-24)
|
|
4
|
+
|
|
5
|
+
A fixes-and-performance round from a full audit of the 25 model concerns: one privacy bug (Anonymizable left Encryptable blind-index fingerprints queryable after erasure), four silent-misbehavior fixes, five query-count reductions, and three hardening changes. No new concerns. 1173 examples, 0 failures.
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **Models::Anonymizable × Encryptable**: `anonymize!` writes via `update_columns`, which skips `before_save` — so Encryptable's blind-index refresh never ran and the `<field>_bidx` column kept the deterministic fingerprint of the ERASED value: `find_by_<field>(old_pii)` still resolved the record after erasure. The erasure payload now rewrites the blind-index column in the same single UPDATE (the fingerprint of the anonymized value; nil when the strategy nullifies), so the erased value stops resolving.
|
|
9
|
+
- **Models::Publishable**: `publish_at!(1.day.from_now)` on a BOOLEAN publishable column now raises `ArgumentError` — the Time used to cast to `true` and silently publish NOW instead of scheduling (a boolean column cannot represent a future publish; use `publish!`/`unpublish!` or a datetime column).
|
|
10
|
+
- **Models::Monetizable**: `subunit_to_unit:` is coerced to Integer at the macro. A String like `"100"` passed the old `.to_i.positive?` validation but was stored raw — the writer's `BigDecimal * "100"` raised TypeError (swallowed to nil by the form-garbage rescue, silently nil-ing every assignment) and the reader's division raised outright.
|
|
11
|
+
- **Models::Taggable**: input containing the delimiter now splits into multiple tags eagerly and identically everywhere — `add_tags("a,b")` adds "a" and "b"; `tag_list=`, `remove_tags`, and `tagged_with?` (AND semantics, mirroring the class-level scope) agree. A tag can never survive with the delimiter inside it (the column format cannot escape it), so pre-1.26 such input silently split on the NEXT normalize pass instead.
|
|
12
|
+
- **Models::Sortable**: `sortable_by` raises `ArgumentError` on unknown trailing options (a typo'd `ad_new_at:` used to vanish into `**field_options`), on a multi-pair Hash (only the first pair was read), and on an invalid direction (previously coerced to `:asc` silently — reordering the whole default scope without a whisper).
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- **Models::Stateable**: `stateable_by` validates its options — unknown keys raise (previously ignored silently). New `lock: true` option: guarded `<event>!` transitions take a row lock (`SELECT ... FOR UPDATE`) and re-check the guard against the fresh row, closing the check-then-write race where two concurrent transitions both passed the in-memory guard. Off by default; requires a clean record (`with_lock` reloads) and costs a SELECT per transition.
|
|
16
|
+
- **Models::Sluggable**: `sluggable_by ..., scope:` accepts an association name (`scope: :account`) — friendly_id resolves association scopes itself, but ColumnGuard rejected them as missing columns. Column scopes (`scope: :account_id`) validate exactly as before.
|
|
17
|
+
- **Models::SoftDeletable**: `deleted_within` emits a table-qualified Arel predicate, so the scope stays unambiguous inside joins against tables sharing the column name.
|
|
18
|
+
|
|
19
|
+
### Performance
|
|
20
|
+
- **Models::CounterCacheable**: counter adjustments are grouped per (parent class, parent id) and flushed as ONE `update_counters` UPDATE — a model with sibling counters on the same parent (`comments_count` + `approved_comments_count`) used to issue one statement per rule per save. A reparent stays at one UPDATE per parent, both counters batched.
|
|
21
|
+
- **Models::Anonymizable**: `anonymize_all!` streams in PK batches (`find_each`) instead of loading the whole relation, filters already-stamped rows DB-side, and skips the per-record `reload` (batch instances are discarded) — two queries saved per record on large erasure jobs.
|
|
22
|
+
- **Models::SoftDeletable**: the `soft_delete_all` / `restore_all` slow paths stream via `find_each` with DB-side filtering instead of `to_a`-loading the relation (memory-bounded; the Integer-count contract, rollback semantics, and the single-UPDATE fast path are unchanged).
|
|
23
|
+
- **Models::Sequenceable**: creates no longer run the `exists?` probe after computing MAX+1 — within one consistent read MAX+1 cannot be taken, so the probe re-verified a tautology on EVERY create (and could not close the concurrent-insert race anyway; the scoped unique index does, per the module docs). One query saved per create.
|
|
24
|
+
- **Models::Taggable**: `all_tags` dedupes DB-side (`where.not(nil).distinct.pluck`), so identical tag strings ship over the wire once instead of once per row.
|
|
25
|
+
|
|
26
|
+
### Internal
|
|
27
|
+
- Regression specs for every fix — the Anonymizable blind-index pair doubles as the previously-missing Encryptable-composition coverage; CounterCacheable gained SQL statement-count specs; Sequenceable gained a no-probe query-count spec. Sluggable's `class_methods do` converted to a real `ClassMethods` module (the Stateable precedent).
|
|
28
|
+
|
|
29
|
+
## 1.25.0 (2026-08-16)
|
|
30
|
+
|
|
31
|
+
One new controller concern — Permittable, typed/validated params contracts with a boot-time schema-drift guard — developed here and shipped as the standalone [`permittable` gem](https://rubygems.org/gems/permittable) (new runtime dependency; `ConcernsOnRails::Controllers::Permittable` is an alias). 1160 examples, 0 failures.
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
- **Controllers::Permittable**: declarative, typed params contracts ("strong parameters with types, validation, and drift detection"). `permit_params *actions, root:, model:, unknown:, enforce:` declares a per-action contract (repeatable; no actions = catch-all; last matching rule wins; inherited copy-on-write) whose block DSL (`required`/`optional`/`array`, nested blocks) types every field (`:string :integer :float :decimal :boolean :date :datetime`) and validates it (`in:`, `format:`, `length:`, `normalize:` presets/Proc, `default:` — itself contract-checked at class load — and custom `validate:` with symbol violation codes). `permitted_params` returns the cast/validated/defaulted hash (lazy; `enforce: true` moves the check to a before_action); violations raise `InvalidParameters`, auto-rescued into the shared ErrorEnvelope as 422 (400 for a missing `root:` key) with machine-readable `details: [{ param: "user.age", code: "inclusion" }]`, and instrument `invalid_parameters.concerns_on_rails`. Coercion is deliberately STRICT (no ActiveModel::Type leniency — `"abc"` is never `0`, `?age[]=1` type confusion is a violation, not a 500); `nil`/`""` are ABSENT (absent optionals are omitted, so partial updates never nil-out columns). The headline: `model:` enables the **schema-drift guard** — every non-`virtual:` scalar field is checked against the model's columns via Support::ColumnGuard at controller class load, so a column dropped by a migration fails the deploy (with the migration-hint error + `virtual: true` escape hatch), not the request. `unknown: :error/:log` polices undeclared keys at every nesting level (Rails' routing keys exempt at top level); `sensitive: true` registers fields with the gem-wide filter_parameters registry (the Encryptable pipe). Output reshaping replaces params-mutating before_actions safely: per-field `transform:` (a callable applied AFTER cast + validation — `->(v) { v.split(",") }`; defaults and absent fields untouched, partially-invalid arrays never transformed) and a once-per-contract `finalize do |p| ... end` that runs only when every field validated, executes on a bare runner (controller state unreachable — contracts stay pure), must return the final Hash, and gets `violate!(param, code)` — record one violation and halt — as the cross-field validation seam. The request's `params` is never mutated.
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
- **Permittable extracted into the standalone [`permittable` gem](https://github.com/VSN2015/permittable)** (new runtime dependency, `~> 0.1`, published on [rubygems.org](https://rubygems.org/gems/permittable) — this version resolves against permittable 0.1.2). `ConcernsOnRails::Controllers::Permittable` is now an alias for `::Permittable` — the include path, macro, helpers, and error classes are unchanged, and the concerns_on_rails spec suite runs against the alias as a compatibility suite. The gem's `sensitive:` registrations are routed into ConcernsOnRails' shared filter_parameters registry, so Permittable params and Encryptable attributes share one filter. The instrumentation event is now the gem's `invalid_parameters.permittable` (was `invalid_parameters.concerns_on_rails`; Permittable never shipped in a release, so nothing published breaks).
|
|
38
|
+
|
|
39
|
+
### Internal
|
|
40
|
+
- `spec/support/integration_harness.rb`: `dispatch` accepts `params:` (form-encoded request body) so specs can exercise real `ActionController::Parameters` bodies.
|
|
41
|
+
|
|
3
42
|
## 1.24.0 (2026-08-15)
|
|
4
43
|
|
|
5
44
|
A developer-experience wave from the 2026-08-15 usability review: leaner install, lazy loading, teaching errors, and one-initializer store configuration. No behavior changes for configured concerns. 1084 examples, 0 failures.
|
data/README.md
CHANGED
|
@@ -6,13 +6,17 @@
|
|
|
6
6
|
One `include`, one declarative macro — done.
|
|
7
7
|
|
|
8
8
|
[](https://rubygems.org/gems/concerns_on_rails)
|
|
9
|
-
[](https://rubygems.org/gems/concerns_on_rails)
|
|
9
|
+
[](https://rubygems.org/gems/concerns_on_rails)
|
|
10
|
+
[](https://rubygems.org/gems/concerns_on_rails/versions)
|
|
10
11
|
[](https://github.com/VSN2015/concerns_on_rails/actions/workflows/ci.yml)
|
|
12
|
+
[](https://vsn2015.github.io/concerns_on_rails)
|
|
11
13
|
[](https://www.ruby-lang.org)
|
|
12
14
|
[](https://rubyonrails.org)
|
|
13
15
|
[](#-license)
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
### [📖 **Documentation**](https://vsn2015.github.io/concerns_on_rails) · [💎 **RubyGems**](https://rubygems.org/gems/concerns_on_rails) · [📝 **Changelog**](CHANGELOG.md) · [🐛 **Issues**](https://github.com/VSN2015/concerns_on_rails/issues)
|
|
18
|
+
|
|
19
|
+
🧩 **26 model concerns** · 🎮 **16 controller concerns** · 🪶 **lean deps** · ✅ **schema-validated** · 🧪 **1,160 specs**
|
|
16
20
|
|
|
17
21
|
</div>
|
|
18
22
|
|
|
@@ -32,63 +36,108 @@ end
|
|
|
32
36
|
Article.published.without_deleted.find("hello-world")
|
|
33
37
|
```
|
|
34
38
|
|
|
39
|
+
> 📖 **Prefer browsable docs?** Every concern has its own searchable, deep-linkable page
|
|
40
|
+
> (dark mode included) at **[vsn2015.github.io/concerns_on_rails](https://vsn2015.github.io/concerns_on_rails)** —
|
|
41
|
+
> same content as this README, one page per concern. For install stats, every released
|
|
42
|
+
> version, and the live download counter, see the gem page:
|
|
43
|
+
> **[rubygems.org/gems/concerns_on_rails](https://rubygems.org/gems/concerns_on_rails)**.
|
|
44
|
+
|
|
35
45
|
---
|
|
36
46
|
|
|
37
47
|
## 📚 Table of Contents
|
|
38
48
|
|
|
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)
|
|
49
|
+
[Find your concern](#-find-your-concern) · [Why this gem?](#-why-this-gem) · [Installation](#-installation) · [Compatibility](#-compatibility) · [Quick Start](#-quick-start) · [Module paths](#-module-paths--namespacing) · [AI assistants](#-using-this-gem-with-ai-assistants) · [Development](#-development) · [Contributing](#-contributing) · [License](#-license)
|
|
50
|
+
|
|
51
|
+
Every concern below links to its section in this README **and** to its standalone page on the [docs site](https://vsn2015.github.io/concerns_on_rails) (📖).
|
|
40
52
|
|
|
41
53
|
### 🧱 Model concerns
|
|
42
54
|
|
|
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
|
-
| [🔏 Encryptable](#-encryptable) | Transparent field encryption (AES-256-GCM) + blind-index lookups |
|
|
69
|
-
| [🕵️ Anonymizable](#-anonymizable) | GDPR right-to-erasure with per-field strategies |
|
|
70
|
-
| [🧬 Duplicable](#-duplicable) | Concern-aware deep copy ("clone this invoice") |
|
|
55
|
+
| Concern | What it does | Docs |
|
|
56
|
+
|---------|--------------|:----:|
|
|
57
|
+
| [📝 Sluggable](#-sluggable) | URL-friendly slugs | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/sluggable) |
|
|
58
|
+
| [🔢 Sortable](#-sortable) | List ordering via `acts_as_list` | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/sortable) |
|
|
59
|
+
| [📤 Publishable](#-publishable) | `published_at` timestamp publishing | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/publishable) |
|
|
60
|
+
| [❌ SoftDeletable](#-softdeletable) | Soft delete with scopes & hooks | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/soft-deletable) |
|
|
61
|
+
| [🔐 Hashable](#-hashable) | Auto-generate tokens / UUIDs / codes | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/hashable) |
|
|
62
|
+
| [🗓️ Schedulable](#-schedulable) | `starts_at` / `ends_at` time windows | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/schedulable) |
|
|
63
|
+
| [⏳ Expirable](#-expirable) | Single-timestamp expiry | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/expirable) |
|
|
64
|
+
| [✨ Normalizable](#-normalizable) | Attribute normalization (`:email`, `:phone`, …) | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/normalizable) |
|
|
65
|
+
| [🔍 Searchable](#-searchable) | LIKE / ILIKE search across columns | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/searchable) |
|
|
66
|
+
| [✅ Activatable](#-activatable) | Boolean active / inactive toggle | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/activatable) |
|
|
67
|
+
| [🔑 Tokenizable](#-tokenizable) | Security tokens with timing-safe lookup | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/tokenizable) |
|
|
68
|
+
| [🧾 Sequenceable](#-sequenceable) | Ordered, human-friendly reference numbers | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/sequenceable) |
|
|
69
|
+
| [🔄 Stateable](#-stateable) | Lightweight string-backed state machine | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/stateable) |
|
|
70
|
+
| [🏠 Addressable](#-addressable) | Postal address normalization + validation | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/addressable) |
|
|
71
|
+
| [🏷️ Taggable](#-taggable) | Lightweight tagging over a single column | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/taggable) |
|
|
72
|
+
| [🧼 Sanitizable](#-sanitizable) | Opt-in HTML sanitization (XSS defense) | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/sanitizable) |
|
|
73
|
+
| [🙈 Maskable](#-maskable) | Non-destructive display masking | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/maskable) |
|
|
74
|
+
| [💰 Monetizable](#-monetizable) | Integer-cents money columns (BigDecimal) | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/monetizable) |
|
|
75
|
+
| [📜 Auditable](#-auditable) | Single-column change history ("paper_trail-lite") | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/auditable) |
|
|
76
|
+
| [🔐 Lockable](#-lockable) | Failed-attempt tracking + account lockout | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/lockable) |
|
|
77
|
+
| [🪞 Aliasable](#-aliasable) | Full read / write / query association aliases | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/aliasable) |
|
|
78
|
+
| [⚙️ Storable](#-storable) | Typed accessors over one JSON column ("store_attribute-lite") | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/storable) |
|
|
79
|
+
| [🧮 CounterCacheable](#-countercacheable) | Conditional denormalized counters ("counter_culture-lite") | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/counter-cacheable) |
|
|
80
|
+
| [🔏 Encryptable](#-encryptable) | Transparent field encryption (AES-256-GCM) + blind-index lookups | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/encryptable) |
|
|
81
|
+
| [🕵️ Anonymizable](#-anonymizable) | GDPR right-to-erasure with per-field strategies | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/anonymizable) |
|
|
82
|
+
| [🧬 Duplicable](#-duplicable) | Concern-aware deep copy ("clone this invoice") | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/duplicable) |
|
|
71
83
|
|
|
72
84
|
### 🎮 Controller concerns
|
|
73
85
|
|
|
74
|
-
| Concern | What it does |
|
|
75
|
-
|
|
76
|
-
| [📄 Paginatable](#-paginatable) | Offset pagination with headers |
|
|
77
|
-
| [🧭 CursorPaginatable](#-cursorpaginatable) | Cursor (keyset) pagination with headers |
|
|
78
|
-
| [🔎 Filterable](#-filterable) | Declarative URL-param filters |
|
|
79
|
-
| [↕️ Sortable (controller)](#-sortable-controller) | URL-param ordering with allow-list |
|
|
80
|
-
| [📦 Respondable](#-respondable) | Standardized JSON envelopes |
|
|
81
|
-
| [🛟 ErrorHandleable](#-errorhandleable) | JSON `rescue_from` handlers |
|
|
82
|
-
| [🔗 Includable](#-includable) | Association sideloading + sparse fieldsets |
|
|
83
|
-
| [🛡️ SecureHeadable](#-secureheadable) | Security response headers + native CSP DSL |
|
|
84
|
-
| [🌐 Localizable](#-localizable) | Per-request locale from params / `Accept-Language` |
|
|
85
|
-
| [🔒 Authorizable](#-authorizable) | Per-action 403 authorization gate |
|
|
86
|
-
| [🚦 Throttleable](#-throttleable) | Rate limiting (429 + `X-RateLimit-*`) |
|
|
87
|
-
| [🕒 Timezoneable](#-timezoneable) | Per-request `Time.zone` from params / header / cookie |
|
|
88
|
-
| [🔁 Idempotentable](#-idempotentable) | `Idempotency-Key` request replay |
|
|
89
|
-
| [🪝 WebhookVerifiable](#-webhookverifiable) | HMAC verification for inbound webhooks |
|
|
90
|
-
| [🌅 Deprecatable](#-deprecatable) | RFC `Deprecation` / `Sunset` headers + 410 |
|
|
91
|
-
| [🗄️ Cacheable](#-cacheable) | HTTP conditional GET (ETag / 304) + `Cache-Control` |
|
|
86
|
+
| Concern | What it does | Docs |
|
|
87
|
+
|---------|--------------|:----:|
|
|
88
|
+
| [📄 Paginatable](#-paginatable) | Offset pagination with headers | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/paginatable) |
|
|
89
|
+
| [🧭 CursorPaginatable](#-cursorpaginatable) | Cursor (keyset) pagination with headers | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/cursor-paginatable) |
|
|
90
|
+
| [🔎 Filterable](#-filterable) | Declarative URL-param filters | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/filterable) |
|
|
91
|
+
| [↕️ Sortable (controller)](#-sortable-controller) | URL-param ordering with allow-list | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/sortable-controller) |
|
|
92
|
+
| [📦 Respondable](#-respondable) | Standardized JSON envelopes | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/respondable) |
|
|
93
|
+
| [🛟 ErrorHandleable](#-errorhandleable) | JSON `rescue_from` handlers | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/error-handleable) |
|
|
94
|
+
| [🔗 Includable](#-includable) | Association sideloading + sparse fieldsets | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/includable) |
|
|
95
|
+
| [🛡️ SecureHeadable](#-secureheadable) | Security response headers + native CSP DSL | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/secure-headable) |
|
|
96
|
+
| [🌐 Localizable](#-localizable) | Per-request locale from params / `Accept-Language` | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/localizable) |
|
|
97
|
+
| [🔒 Authorizable](#-authorizable) | Per-action 403 authorization gate | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/authorizable) |
|
|
98
|
+
| [🚦 Throttleable](#-throttleable) | Rate limiting (429 + `X-RateLimit-*`) | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/throttleable) |
|
|
99
|
+
| [🕒 Timezoneable](#-timezoneable) | Per-request `Time.zone` from params / header / cookie | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/timezoneable) |
|
|
100
|
+
| [🔁 Idempotentable](#-idempotentable) | `Idempotency-Key` request replay | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/idempotentable) |
|
|
101
|
+
| [🪝 WebhookVerifiable](#-webhookverifiable) | HMAC verification for inbound webhooks | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/webhook-verifiable) |
|
|
102
|
+
| [🌅 Deprecatable](#-deprecatable) | RFC `Deprecation` / `Sunset` headers + 410 | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/deprecatable) |
|
|
103
|
+
| [🗄️ Cacheable](#-cacheable) | HTTP conditional GET (ETag / 304) + `Cache-Control` | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/cacheable) |
|
|
104
|
+
| [🛂 Permittable](#-permittable) | Typed, validated params contracts + schema-drift guard | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/permittable) |
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## 🧭 Find your concern
|
|
109
|
+
|
|
110
|
+
Forty-three concerns is a lot of menu. Start from the problem instead:
|
|
111
|
+
|
|
112
|
+
| "I need to…" | Reach for |
|
|
113
|
+
|--------------|-----------|
|
|
114
|
+
| Give records pretty URLs — `/posts/hello-world`, not `/posts/42` | [📝 Sluggable](#-sluggable) |
|
|
115
|
+
| Let users trash records, then restore them | [❌ SoftDeletable](#-softdeletable) |
|
|
116
|
+
| Schedule a post to go live Friday at 9am | [📤 Publishable](#-publishable) |
|
|
117
|
+
| Support drag-and-drop reordering | [🔢 Sortable](#-sortable) |
|
|
118
|
+
| Issue API keys / invite codes with timing-safe lookup | [🔑 Tokenizable](#-tokenizable) |
|
|
119
|
+
| Number invoices like `INV-2026-00042`, per account, resetting yearly | [🧾 Sequenceable](#-sequenceable) |
|
|
120
|
+
| Add a small state machine without the AASM dependency | [🔄 Stateable](#-stateable) |
|
|
121
|
+
| Encrypt SSNs at rest and still `find_by` them | [🔏 Encryptable](#-encryptable) |
|
|
122
|
+
| Handle a GDPR "delete my data" request in one call | [🕵️ Anonymizable](#-anonymizable) |
|
|
123
|
+
| Know who changed which field, and when | [📜 Auditable](#-auditable) |
|
|
124
|
+
| Lock accounts after 5 failed logins | [🔐 Lockable](#-lockable) |
|
|
125
|
+
| Keep typed, defaulted settings in one JSON column | [⚙️ Storable](#-storable) |
|
|
126
|
+
| Maintain `approved_comments_count` next to `comments_count` | [🧮 CounterCacheable](#-countercacheable) |
|
|
127
|
+
| Ship a "duplicate this invoice" button (line items included) | [🧬 Duplicable](#-duplicable) |
|
|
128
|
+
| Handle money without ever touching a Float | [💰 Monetizable](#-monetizable) |
|
|
129
|
+
| Paginate a JSON index — page numbers or infinite scroll | [📄 Paginatable](#-paginatable) / [🧭 CursorPaginatable](#-cursorpaginatable) |
|
|
130
|
+
| Turn `?status=published&sort=title` into scopes safely | [🔎 Filterable](#-filterable) + [↕️ Sortable](#-sortable-controller) |
|
|
131
|
+
| Render one consistent JSON envelope, errors included | [📦 Respondable](#-respondable) + [🛟 ErrorHandleable](#-errorhandleable) |
|
|
132
|
+
| Rate-limit login attempts per IP | [🚦 Throttleable](#-throttleable) |
|
|
133
|
+
| Make payment retries safe (Stripe-style `Idempotency-Key`) | [🔁 Idempotentable](#-idempotentable) |
|
|
134
|
+
| Verify Stripe / GitHub / Shopify webhook signatures | [🪝 WebhookVerifiable](#-webhookverifiable) |
|
|
135
|
+
| Retire `/api/v1` with proper `Deprecation` / `Sunset` headers | [🌅 Deprecatable](#-deprecatable) |
|
|
136
|
+
| Serve ETags + 304s so clients stop re-downloading JSON | [🗄️ Cacheable](#-cacheable) |
|
|
137
|
+
| Get strong params that also cast, bound-check, and default | [🛂 Permittable](#-permittable) |
|
|
138
|
+
|
|
139
|
+
Still browsing? The [docs site](https://vsn2015.github.io/concerns_on_rails) has **instant search**
|
|
140
|
+
across all 43 concerns — press <kbd>/</kbd> and type.
|
|
92
141
|
|
|
93
142
|
---
|
|
94
143
|
|
|
@@ -97,8 +146,10 @@ Article.published.without_deleted.find("hello-world")
|
|
|
97
146
|
- **Twenty-six model concerns + sixteen controller concerns**, all production-ready
|
|
98
147
|
- **One include, one macro** — no boilerplate, no glue code
|
|
99
148
|
- **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable), and both load **lazily**: an app that never includes those concerns never loads them. Depends on `activerecord`/`actionpack`/`activesupport`, not the full `rails` meta-gem; controller concerns have zero extra deps
|
|
100
|
-
- **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
|
|
149
|
+
- **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early — with a ready-to-paste `rails generate migration` hint when it doesn't
|
|
101
150
|
- **Composable** — concerns are independent; mix and match per model
|
|
151
|
+
- **Tested like an app, not a snippet** — **1,160 RSpec examples** run against a real database on every CI build
|
|
152
|
+
- **Documented twice** — everything in this README also lives as a per-concern page on the [docs site](https://vsn2015.github.io/concerns_on_rails), searchable and deep-linkable
|
|
102
153
|
|
|
103
154
|
---
|
|
104
155
|
|
|
@@ -107,7 +158,7 @@ Article.published.without_deleted.find("hello-world")
|
|
|
107
158
|
Add to your application's `Gemfile`:
|
|
108
159
|
|
|
109
160
|
```ruby
|
|
110
|
-
gem "concerns_on_rails", "~> 1.
|
|
161
|
+
gem "concerns_on_rails", "~> 1.26"
|
|
111
162
|
```
|
|
112
163
|
|
|
113
164
|
Or pull the latest from GitHub:
|
|
@@ -1785,6 +1836,56 @@ end
|
|
|
1785
1836
|
|
|
1786
1837
|
---
|
|
1787
1838
|
|
|
1839
|
+
## 🛂 Permittable
|
|
1840
|
+
|
|
1841
|
+
Declarative, **typed params contracts** — what strong parameters would be if it also knew types, bounds, defaults, and *why* a request was bad. `params.permit` (and Rails 8's `params.expect`) only answer "which keys may pass"; a Permittable contract additionally **casts** each field, **validates** it, applies **defaults**, and turns every failure into a machine-readable 422. Because the contract is class-level data (not code inside the action), it is introspectable — and can be checked against a model's schema at boot.
|
|
1842
|
+
|
|
1843
|
+
```ruby
|
|
1844
|
+
class UsersController < ApplicationController
|
|
1845
|
+
include ConcernsOnRails::Controllers::Permittable
|
|
1846
|
+
|
|
1847
|
+
permit_params :create, :update, root: :user, model: User do
|
|
1848
|
+
required :name, :string, length: 1..80, normalize: :squish
|
|
1849
|
+
required :email, :string, format: URI::MailTo::EMAIL_REGEXP, normalize: :email
|
|
1850
|
+
optional :age, :integer, in: 18..120
|
|
1851
|
+
optional :ssn, :string, sensitive: true # auto-redacted from logs
|
|
1852
|
+
optional :plan, :string, in: %w[free pro], default: "free"
|
|
1853
|
+
array :tag_names, of: :string, length: 0..10
|
|
1854
|
+
optional :address do
|
|
1855
|
+
required :city, :string
|
|
1856
|
+
optional :zip, :string, format: /\A\d{5}\z/
|
|
1857
|
+
end
|
|
1858
|
+
end
|
|
1859
|
+
|
|
1860
|
+
def create
|
|
1861
|
+
user = User.create!(permitted_params) # cast, validated, defaulted
|
|
1862
|
+
end
|
|
1863
|
+
end
|
|
1864
|
+
|
|
1865
|
+
# POST { user: { name: "Jo", email: "JO@x.com ", age: "30" } }
|
|
1866
|
+
# ⇒ permitted_params == { "name" => "Jo", "email" => "jo@x.com", "age" => 30, "plan" => "free" }
|
|
1867
|
+
# POST { user: { age: "12" } }
|
|
1868
|
+
# ⇒ 422 { error: { code: "invalid_parameters",
|
|
1869
|
+
# details: [{ param: "user.name", code: "missing" },
|
|
1870
|
+
# { param: "user.age", code: "inclusion" }] } }
|
|
1871
|
+
```
|
|
1872
|
+
|
|
1873
|
+
**The schema-drift guard** is the headline: with `model:` (a class, or `true` to infer from the controller name), every non-`virtual:` scalar field is checked against the model's columns **at controller class load** through the same `ColumnGuard` all model concerns use. A column dropped by a migration fails the deploy (production eager-loads controllers) with a teaching error — the missing-column message plus a ready-to-paste migration command plus the `virtual: true` escape hatch. Nested/array fields are implicitly virtual; the check skips gracefully when the schema is unreachable (`db:create`, `assets:precompile`). In CI, one `Rails.application.eager_load!` spec exercises every contract in the app.
|
|
1874
|
+
|
|
1875
|
+
**Options** (`permit_params *actions, …`, repeatable; no actions = catch-all; **last matching rule wins**, subclasses inherit copy-on-write): `root:` (key to unwrap, `require(:user)`-style; missing root → **400**; default `false` = top-level params), `model:` (schema-drift guard), `unknown:` (`:ignore` default / `:log` / `:error` — undeclared keys at every nesting level; Rails' `controller`/`action`/`format` keys are exempt at the top level), `enforce:` (`false` = validate lazily on first `permitted_params` call; `true` = validate in a `before_action` so the action body never runs on bad input).
|
|
1876
|
+
|
|
1877
|
+
**Field DSL**: `required`/`optional` `name, type` (`:string` default, `:integer`, `:float`, `:decimal`, `:boolean`, `:date`, `:datetime`) with `in:` (Range/Array), `format:`/`length:`/`normalize:` (`:squish`/`:strip`/`:downcase`/`:upcase`/`:email` or a Proc; string fields only), `default:` (validated against the field's own contract at load time), `validate:` (Proc — falsy fails as `"invalid"`, a returned Symbol becomes the violation code), `transform:` (see below), `virtual:`, `sensitive:` (registers with the gem-wide filter_parameters registry, same pipe as Encryptable). Nested hashes via a block; `array :name, of: :type` (or a block for arrays of hashes) with `length:` as element count and per-index violation paths (`items[1]`).
|
|
1878
|
+
|
|
1879
|
+
**Output reshaping** — the safe replacement for params-mutating before_actions, operating on the validated copy only (the request's `params` is never touched). `transform:` (scalar/array fields) applies a callable **after** cast + validation to reshape that field's output — `transform: ->(v) { v.split(",") }` turns a validated delimited String into an Array (absent fields and `default:` values are untouched; a partially-invalid array is never transformed). `finalize do |p| … end` (once per contract, top level only) runs after every field validated cleanly, receives the result hash, and returns the final shape — zip parallel fields into value objects, drop scaffolding keys. It executes on a bare runner, **not** the controller, so contracts stay pure; its one extra verb, `violate!(param, code)`, records a violation and halts the block immediately — doubling as the cross-field validation seam ("ends_at after starts_at").
|
|
1880
|
+
|
|
1881
|
+
**Coercion is strict** — deliberately not `ActiveModel::Type` ("abc".to_i == 0 and `Boolean.cast("abc") == true` silently corrupt untrusted input): a value the type can't faithfully represent is an `invalid_type` violation, booleans accept only `true/false/"true"/"false"/"1"/"0"/1/0`, and array/hash type-confusion (`?age[]=1`) is rejected instead of 500ing. `nil` and `""` are both **absent**: absent optional fields are *omitted* from the result (partial updates never nil-out columns), absent required fields violate, `default:` fills absence.
|
|
1882
|
+
|
|
1883
|
+
**Failure surface**: violations raise `Permittable::InvalidParameters` (carries `details` + `status`), auto-`rescue_from`d into the shared error envelope (`Respondable#render_error` when included, the identical inline JSON otherwise) and instrumented as `invalid_parameters.permittable` for dashboards. Introspect contracts via `permittable_contracts` / `permit_rule_for(action)`. Naming note: legacy InheritedResources controllers also define `permitted_params` — don't mix the two on one controller.
|
|
1884
|
+
|
|
1885
|
+
> Permittable ships as the standalone [`permittable` gem](https://github.com/VSN2015/permittable) (a runtime dependency of concerns_on_rails); `ConcernsOnRails::Controllers::Permittable` is an alias for `::Permittable`, with `sensitive:` registrations pooled into this gem's shared filter_parameters registry.
|
|
1886
|
+
|
|
1887
|
+
---
|
|
1888
|
+
|
|
1788
1889
|
## 🗂️ Module paths & namespacing
|
|
1789
1890
|
|
|
1790
1891
|
Every concern is available under two paths:
|
|
@@ -1828,13 +1929,35 @@ Both forms reference the same module, so you can freely mix them.
|
|
|
1828
1929
|
|
|
1829
1930
|
---
|
|
1830
1931
|
|
|
1932
|
+
## 🤖 Using this gem with AI assistants
|
|
1933
|
+
|
|
1934
|
+
Working with Claude, Cursor, Copilot, or another AI coding agent? The docs are AI-ready — every page
|
|
1935
|
+
is plain Markdown, fetchable without JavaScript:
|
|
1936
|
+
|
|
1937
|
+
- **[`llms.txt`](https://vsn2015.github.io/concerns_on_rails/llms.txt)** — a machine-readable index of
|
|
1938
|
+
every concern document, following the [llms.txt convention](https://llmstxt.org)
|
|
1939
|
+
- **[`llms-full.txt`](https://vsn2015.github.io/concerns_on_rails/llms-full.txt)** — all 43 concern docs
|
|
1940
|
+
concatenated into one ~400 KB plain-text file, for one-shot context loading
|
|
1941
|
+
- **Per-concern Markdown** — `https://vsn2015.github.io/concerns_on_rails/concerns/<slug>.md`
|
|
1942
|
+
(e.g. [`concerns/sluggable.md`](https://vsn2015.github.io/concerns_on_rails/concerns/sluggable.md))
|
|
1943
|
+
when your agent only needs one concern's docs
|
|
1944
|
+
- This README also ships **inside the packaged gem**, so an agent reading your bundle already has the
|
|
1945
|
+
full reference locally
|
|
1946
|
+
|
|
1947
|
+
Point your agent at `llms.txt` for an overview, or paste a single concern's `.md` URL for focused context.
|
|
1948
|
+
|
|
1949
|
+
---
|
|
1950
|
+
|
|
1831
1951
|
## 🛠️ Development
|
|
1832
1952
|
|
|
1833
1953
|
```sh
|
|
1834
1954
|
bundle install # install dev dependencies
|
|
1835
|
-
bundle exec rspec # run the test suite
|
|
1955
|
+
bundle exec rspec # run the test suite (1,173 examples)
|
|
1836
1956
|
gem build concerns_on_rails.gemspec # build the gem
|
|
1837
|
-
gem install ./concerns_on_rails-1.
|
|
1957
|
+
gem install ./concerns_on_rails-1.26.0.gem # install locally
|
|
1958
|
+
|
|
1959
|
+
# Preview the docs site locally (GitHub Pages serves docs/ as-is):
|
|
1960
|
+
cd docs && python3 -m http.server 8000 # → http://localhost:8000
|
|
1838
1961
|
```
|
|
1839
1962
|
|
|
1840
1963
|
The test suite uses an in-memory SQLite database and a lightweight `FakeController` harness for controller-concern specs — no Rails routes or boot required.
|
|
@@ -1843,7 +1966,25 @@ The test suite uses an in-memory SQLite database and a lightweight `FakeControll
|
|
|
1843
1966
|
|
|
1844
1967
|
## 🤝 Contributing
|
|
1845
1968
|
|
|
1846
|
-
Bug reports and pull requests are welcome at **[github.com/VSN2015/concerns_on_rails](https://github.com/VSN2015/concerns_on_rails)**.
|
|
1969
|
+
Bug reports and pull requests are welcome at **[github.com/VSN2015/concerns_on_rails](https://github.com/VSN2015/concerns_on_rails)**.
|
|
1970
|
+
|
|
1971
|
+
- 🐛 [Open an issue](https://github.com/VSN2015/concerns_on_rails/issues) — a failing spec is the fastest path to a fix
|
|
1972
|
+
- 🔀 Send a PR — run `bundle exec rspec` first; every concern keeps its spec under `spec/concerns/`
|
|
1973
|
+
- 📖 Docs count too — each concern's page lives in [`docs/concerns/`](docs/concerns) and redeploys automatically
|
|
1974
|
+
- ⭐️ If this gem saved you an afternoon of boilerplate, a star helps other devs find it
|
|
1975
|
+
|
|
1976
|
+
---
|
|
1977
|
+
|
|
1978
|
+
## 🔗 Links
|
|
1979
|
+
|
|
1980
|
+
| Resource | Where |
|
|
1981
|
+
|----------|-------|
|
|
1982
|
+
| 📖 Documentation site | [vsn2015.github.io/concerns_on_rails](https://vsn2015.github.io/concerns_on_rails) |
|
|
1983
|
+
| 💎 Gem page — live download count, all released versions | [rubygems.org/gems/concerns_on_rails](https://rubygems.org/gems/concerns_on_rails) |
|
|
1984
|
+
| 📝 Changelog | [CHANGELOG.md](CHANGELOG.md) |
|
|
1985
|
+
| 🤖 AI/LLM docs index (`llms.txt`) | [vsn2015.github.io/concerns_on_rails/llms.txt](https://vsn2015.github.io/concerns_on_rails/llms.txt) |
|
|
1986
|
+
| 🐛 Issue tracker | [github.com/VSN2015/concerns_on_rails/issues](https://github.com/VSN2015/concerns_on_rails/issues) |
|
|
1987
|
+
| 🛂 Permittable (extracted sibling gem) | [github.com/VSN2015/permittable](https://github.com/VSN2015/permittable) |
|
|
1847
1988
|
|
|
1848
1989
|
---
|
|
1849
1990
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
require "permittable"
|
|
2
|
+
|
|
3
|
+
module ConcernsOnRails
|
|
4
|
+
module Controllers
|
|
5
|
+
# Permittable lives in the standalone `permittable` gem (a runtime
|
|
6
|
+
# dependency — it was developed here and extracted). This alias keeps the
|
|
7
|
+
# concerns_on_rails include path, and everything documented for it,
|
|
8
|
+
# working unchanged:
|
|
9
|
+
#
|
|
10
|
+
# include ConcernsOnRails::Controllers::Permittable
|
|
11
|
+
#
|
|
12
|
+
# is the same module as `include Permittable`. Full docs: the permittable
|
|
13
|
+
# gem README / docs/concerns/permittable.md. Note the instrumentation
|
|
14
|
+
# event is the gem's: "invalid_parameters.permittable".
|
|
15
|
+
Permittable = ::Permittable
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# One registry for the whole process: route the gem's `sensitive:` field
|
|
20
|
+
# registrations into the registry ConcernsOnRails::Railtie already appends to
|
|
21
|
+
# config.filter_parameters, so Permittable params and Encryptable attributes
|
|
22
|
+
# share one filter. (Contracts declared through ::Permittable before this
|
|
23
|
+
# bridge loads registered on the gem's default registry — that one stays
|
|
24
|
+
# appended by Permittable::Railtie, so nothing is un-filtered.)
|
|
25
|
+
Permittable.filter_parameter_registry = ConcernsOnRails.filter_parameter_registry
|
|
@@ -104,14 +104,21 @@ module ConcernsOnRails
|
|
|
104
104
|
# Anonymize every matching record that isn't already stamped, in one
|
|
105
105
|
# transaction. Returns the Integer count of records anonymized (the
|
|
106
106
|
# 1.22 batch contract). Without a stamp column every record matches.
|
|
107
|
+
# Streams in PK batches (find_each) rather than loading the relation,
|
|
108
|
+
# filters stamped rows DB-side, and skips the per-record reload —
|
|
109
|
+
# the batch discards its instances, so reloading each one would cost
|
|
110
|
+
# a wasted SELECT per row.
|
|
107
111
|
def anonymize_all!
|
|
112
|
+
relation = anonymizable_stamp ? all.where(anonymizable_stamp => nil) : all
|
|
108
113
|
transaction do
|
|
109
|
-
|
|
110
|
-
|
|
114
|
+
count = 0
|
|
115
|
+
relation.find_each do |record|
|
|
116
|
+
next if record.anonymized?
|
|
111
117
|
|
|
112
|
-
record.
|
|
113
|
-
|
|
118
|
+
record.send(:anonymize_record!)
|
|
119
|
+
count += 1
|
|
114
120
|
end
|
|
121
|
+
count
|
|
115
122
|
end
|
|
116
123
|
end
|
|
117
124
|
|
|
@@ -159,14 +166,7 @@ module ConcernsOnRails
|
|
|
159
166
|
# Erase the configured fields in a single UPDATE (see the module docs for
|
|
160
167
|
# why validations and callbacks are deliberately skipped). Returns true.
|
|
161
168
|
def anonymize!
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
payload = anonymizable_payload
|
|
165
|
-
transaction do
|
|
166
|
-
before_anonymize
|
|
167
|
-
update_columns(payload)
|
|
168
|
-
after_anonymize
|
|
169
|
-
end
|
|
169
|
+
anonymize_record!
|
|
170
170
|
# update_columns leaves DB-serialized values (e.g. ciphertext) in the
|
|
171
171
|
# in-memory attributes; reload so readers decode through the types.
|
|
172
172
|
reload
|
|
@@ -182,6 +182,19 @@ module ConcernsOnRails
|
|
|
182
182
|
|
|
183
183
|
private
|
|
184
184
|
|
|
185
|
+
# The write itself, without the trailing reload — anonymize_all! goes
|
|
186
|
+
# through this directly because its instances are discarded.
|
|
187
|
+
def anonymize_record!
|
|
188
|
+
raise ArgumentError, "#{LABEL}: anonymize! cannot be called on a new record" if new_record?
|
|
189
|
+
|
|
190
|
+
payload = anonymizable_payload
|
|
191
|
+
transaction do
|
|
192
|
+
before_anonymize
|
|
193
|
+
update_columns(payload)
|
|
194
|
+
after_anonymize
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
185
198
|
# { column => value }: strategy output cast through the attribute's type,
|
|
186
199
|
# plus the stamp and — when an anonymized field is also audited — the
|
|
187
200
|
# cleared audit column. update_columns serializes each value through the
|
|
@@ -192,7 +205,9 @@ module ConcernsOnRails
|
|
|
192
205
|
payload = {}
|
|
193
206
|
self.class.anonymizable_rules.each do |field, strategy|
|
|
194
207
|
value = anonymizable_apply_strategy(strategy, public_send(field))
|
|
195
|
-
|
|
208
|
+
cast = self.class.type_for_attribute(field.to_s).cast(value)
|
|
209
|
+
payload[field] = cast
|
|
210
|
+
anonymizable_add_blind_index(payload, field, cast)
|
|
196
211
|
end
|
|
197
212
|
stamp = self.class.anonymizable_stamp
|
|
198
213
|
payload[stamp] = Time.zone.now if stamp
|
|
@@ -200,6 +215,20 @@ module ConcernsOnRails
|
|
|
200
215
|
payload
|
|
201
216
|
end
|
|
202
217
|
|
|
218
|
+
# update_columns skips before_save, so Encryptable's blind-index refresh
|
|
219
|
+
# never runs here. Without this, the `<field>_bidx` column would keep the
|
|
220
|
+
# deterministic fingerprint of the ERASED value — find_by_<field> with
|
|
221
|
+
# the old PII would still resolve the record after anonymization.
|
|
222
|
+
def anonymizable_add_blind_index(payload, field, value)
|
|
223
|
+
return unless self.class.respond_to?(:encryptable_rules)
|
|
224
|
+
|
|
225
|
+
rule = self.class.encryptable_rules[field]
|
|
226
|
+
return unless rule && rule[:blind_index]
|
|
227
|
+
|
|
228
|
+
payload[rule[:blind_index][:column]] =
|
|
229
|
+
ConcernsOnRails::Models::Encryptable.blind_fingerprint(rule, value)
|
|
230
|
+
end
|
|
231
|
+
|
|
203
232
|
def anonymizable_apply_strategy(strategy, value)
|
|
204
233
|
strategy.arity == 1 ? strategy.call(value) : strategy.call(value, self)
|
|
205
234
|
end
|
|
@@ -189,27 +189,30 @@ module ConcernsOnRails
|
|
|
189
189
|
private
|
|
190
190
|
|
|
191
191
|
def counter_cacheable_run_create
|
|
192
|
-
|
|
193
|
-
next unless counter_cacheable_counted_now?(rule)
|
|
194
|
-
|
|
195
|
-
counter_cacheable_adjust(rule, counter_cacheable_fk_value(rule), 1)
|
|
196
|
-
end
|
|
192
|
+
counter_cacheable_flush(counter_cacheable_presence_adjustments(1))
|
|
197
193
|
end
|
|
198
194
|
|
|
199
195
|
def counter_cacheable_run_destroy
|
|
200
|
-
|
|
201
|
-
next unless counter_cacheable_counted_now?(rule)
|
|
202
|
-
|
|
203
|
-
counter_cacheable_adjust(rule, counter_cacheable_fk_value(rule), -1)
|
|
204
|
-
end
|
|
196
|
+
counter_cacheable_flush(counter_cacheable_presence_adjustments(-1))
|
|
205
197
|
end
|
|
206
198
|
|
|
207
199
|
def counter_cacheable_run_update
|
|
208
|
-
|
|
200
|
+
counter_cacheable_flush(
|
|
201
|
+
self.class.counter_cacheable_rules.flat_map { |rule| counter_cacheable_update_adjustments(rule) }
|
|
202
|
+
)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# create/destroy share one shape: ±1 on the current parent when counted.
|
|
206
|
+
def counter_cacheable_presence_adjustments(delta)
|
|
207
|
+
self.class.counter_cacheable_rules.filter_map do |rule|
|
|
208
|
+
next unless counter_cacheable_counted_now?(rule)
|
|
209
|
+
|
|
210
|
+
counter_cacheable_adjustment(rule, counter_cacheable_fk_value(rule), delta)
|
|
211
|
+
end
|
|
209
212
|
end
|
|
210
213
|
|
|
211
214
|
# The create × destroy × (reparent + condition-flip) matrix.
|
|
212
|
-
def
|
|
215
|
+
def counter_cacheable_update_adjustments(rule)
|
|
213
216
|
fk = counter_cacheable_reflection(rule).foreign_key.to_s
|
|
214
217
|
changes = counter_cacheable_changes
|
|
215
218
|
new_fk = self[fk]
|
|
@@ -219,31 +222,41 @@ module ConcernsOnRails
|
|
|
219
222
|
new_counted = counter_cacheable_counted_now?(rule)
|
|
220
223
|
|
|
221
224
|
if old_fk == new_fk
|
|
222
|
-
|
|
225
|
+
# Same parent — only a condition flip can change the count.
|
|
226
|
+
return [] if old_counted == new_counted
|
|
227
|
+
|
|
228
|
+
[counter_cacheable_adjustment(rule, new_fk, new_counted ? 1 : -1)]
|
|
223
229
|
else
|
|
224
|
-
|
|
230
|
+
# Foreign key changed — settle the old parent and the new one independently.
|
|
231
|
+
[(counter_cacheable_adjustment(rule, old_fk, -1) if old_counted),
|
|
232
|
+
(counter_cacheable_adjustment(rule, new_fk, 1) if new_counted)]
|
|
225
233
|
end
|
|
226
234
|
end
|
|
227
235
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
return if old_counted == new_counted
|
|
236
|
+
def counter_cacheable_adjustment(rule, parent_id, delta)
|
|
237
|
+
return nil if parent_id.nil?
|
|
231
238
|
|
|
232
|
-
|
|
239
|
+
{ klass: counter_cacheable_reflection(rule).klass, parent_id: parent_id,
|
|
240
|
+
column: rule[:count_column], delta: delta, touch: rule[:touch] }
|
|
233
241
|
end
|
|
234
242
|
|
|
235
|
-
#
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
243
|
+
# One update_counters per distinct (parent class, parent id): sibling
|
|
244
|
+
# rules adjusting the same parent (comments_count + approved_comments_count)
|
|
245
|
+
# ride a single UPDATE instead of one statement each.
|
|
246
|
+
def counter_cacheable_flush(adjustments)
|
|
247
|
+
adjustments.compact.group_by { |adj| [adj[:klass], adj[:parent_id]] }.each do |(klass, parent_id), group|
|
|
248
|
+
counters = counter_cacheable_merged_counters(group)
|
|
249
|
+
next if counters.empty?
|
|
240
250
|
|
|
241
|
-
|
|
242
|
-
|
|
251
|
+
counters[:touch] = true if group.any? { |adj| adj[:touch] }
|
|
252
|
+
klass.update_counters(parent_id, counters)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
243
255
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
256
|
+
# Sum per column, dropping zero-sum entries (nothing to write).
|
|
257
|
+
def counter_cacheable_merged_counters(group)
|
|
258
|
+
group.each_with_object(Hash.new(0)) { |adj, acc| acc[adj[:column]] += adj[:delta] }
|
|
259
|
+
.reject { |_column, delta| delta.zero? }
|
|
247
260
|
end
|
|
248
261
|
|
|
249
262
|
def counter_cacheable_fk_value(rule)
|
|
@@ -43,7 +43,12 @@ module ConcernsOnRails
|
|
|
43
43
|
|
|
44
44
|
raise ArgumentError, "ConcernsOnRails::Models::Monetizable: :as cannot be combined with multiple fields" if as && fields.size > 1
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
# Coerce, don't just validate: a String like "100" passed the old
|
|
47
|
+
# `.to_i.positive?` check but was stored raw — the writer's
|
|
48
|
+
# `BigDecimal * "100"` then raised TypeError (swallowed to nil by the
|
|
49
|
+
# form-garbage rescue) and the reader's division raised outright.
|
|
50
|
+
subunit_to_unit = subunit_to_unit.to_i
|
|
51
|
+
unless subunit_to_unit.positive?
|
|
47
52
|
raise ArgumentError, "ConcernsOnRails::Models::Monetizable: :subunit_to_unit must be a positive integer"
|
|
48
53
|
end
|
|
49
54
|
|
|
@@ -133,10 +133,19 @@ module ConcernsOnRails
|
|
|
133
133
|
|
|
134
134
|
# Publish at an explicit time. A future time schedules the record.
|
|
135
135
|
# Fires the publish hooks (same state change as publish!, so since 1.22
|
|
136
|
-
# they no longer silently skip).
|
|
136
|
+
# they no longer silently skip). Raises on a boolean publishable column:
|
|
137
|
+
# the Time would cast to `true` and silently publish NOW instead of
|
|
138
|
+
# scheduling — a boolean column cannot represent a future publish.
|
|
137
139
|
# Example:
|
|
138
140
|
# record.publish_at!(1.day.from_now)
|
|
139
141
|
def publish_at!(time)
|
|
142
|
+
if self.class.publishable_boolean_column?
|
|
143
|
+
raise ArgumentError,
|
|
144
|
+
"ConcernsOnRails::Models::Publishable: publish_at! needs a timestamp column, but " \
|
|
145
|
+
"'#{self.class.publishable_field}' is a boolean — a Time casts to true and would " \
|
|
146
|
+
"publish immediately. Use publish!/unpublish!, or a datetime column to schedule."
|
|
147
|
+
end
|
|
148
|
+
|
|
140
149
|
publishable_write_with_hooks(time, :publish)
|
|
141
150
|
end
|
|
142
151
|
|
|
@@ -33,7 +33,6 @@ module ConcernsOnRails
|
|
|
33
33
|
extend ActiveSupport::Concern
|
|
34
34
|
|
|
35
35
|
RESET_PERIODS = %i[never year month day].freeze
|
|
36
|
-
MAX_GENERATION_ATTEMPTS = 10
|
|
37
36
|
NAME = "ConcernsOnRails::Models::Sequenceable".freeze
|
|
38
37
|
|
|
39
38
|
included do
|
|
@@ -108,26 +107,17 @@ module ConcernsOnRails
|
|
|
108
107
|
end
|
|
109
108
|
|
|
110
109
|
# Assigns the sequence (and, when configured, the formatted string) only when
|
|
111
|
-
# the integer column is blank, so callers can pass an explicit value.
|
|
112
|
-
#
|
|
113
|
-
#
|
|
110
|
+
# the integer column is blank, so callers can pass an explicit value.
|
|
111
|
+
# MAX+1 (or start_at on an empty scope) cannot already be taken within the
|
|
112
|
+
# same consistent read — the pre-1.26 exists? probe re-verified that
|
|
113
|
+
# tautology with an extra query on EVERY create, and could not close the
|
|
114
|
+
# concurrent-insert race anyway. Concurrency is the scoped unique index's
|
|
115
|
+
# job (pair with Support::UniqueRetry around the create).
|
|
114
116
|
def assign_sequenceable_value(field)
|
|
115
117
|
cfg = self.class.sequenceable_config.fetch(field)
|
|
116
118
|
sequenceable_pin_created_at(cfg)
|
|
117
119
|
|
|
118
|
-
if self[field].blank?
|
|
119
|
-
candidate = self.class.send(:sequence_base_value, field, self, {})
|
|
120
|
-
attempts = 0
|
|
121
|
-
while self.class.send(:sequence_value_taken?, field, candidate, self, {})
|
|
122
|
-
attempts += 1
|
|
123
|
-
if attempts >= MAX_GENERATION_ATTEMPTS
|
|
124
|
-
raise "#{NAME}: could not find a free value for '#{field}' after " \
|
|
125
|
-
"#{MAX_GENERATION_ATTEMPTS} attempts — add a scoped unique index"
|
|
126
|
-
end
|
|
127
|
-
candidate += 1
|
|
128
|
-
end
|
|
129
|
-
self[field] = candidate
|
|
130
|
-
end
|
|
120
|
+
self[field] = self.class.send(:sequence_base_value, field, self, {}) if self[field].blank?
|
|
131
121
|
|
|
132
122
|
return unless cfg[:into] && self[cfg[:into]].blank?
|
|
133
123
|
|
|
@@ -52,7 +52,10 @@ module ConcernsOnRails
|
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
# class methods
|
|
55
|
-
class_methods do
|
|
55
|
+
# A real module (not `class_methods do`) so the macro and its private
|
|
56
|
+
# helpers aren't constrained by Metrics/BlockLength (the Stateable
|
|
57
|
+
# precedent). ActiveSupport::Concern auto-extends ClassMethods.
|
|
58
|
+
module ClassMethods
|
|
56
59
|
include ConcernsOnRails::Support::ColumnGuard
|
|
57
60
|
|
|
58
61
|
# Define sluggable field, with optional friendly_id features.
|
|
@@ -64,11 +67,11 @@ module ConcernsOnRails
|
|
|
64
67
|
# sluggable_by :title, finders: true # Model.find accepts a slug directly
|
|
65
68
|
def sluggable_by(field, history: false, scope: nil, reserved_words: nil, finders: false)
|
|
66
69
|
self.sluggable_field = field.to_sym
|
|
67
|
-
# Validate the slug column too
|
|
68
|
-
#
|
|
69
|
-
|
|
70
|
+
# Validate the slug column too (a missing one used to fail at first save
|
|
71
|
+
# with an opaque friendly_id error); an association scope: is exempt.
|
|
72
|
+
scope_column = scope && reflect_on_association(scope.to_sym) ? nil : scope
|
|
70
73
|
ensure_columns!("ConcernsOnRails::Models::Sluggable",
|
|
71
|
-
[sluggable_field, friendly_id_config.slug_column,
|
|
74
|
+
[sluggable_field, friendly_id_config.slug_column, scope_column].compact,
|
|
72
75
|
types: { friendly_id_config.slug_column.to_sym => "string:uniq" })
|
|
73
76
|
return unless history || scope || reserved_words || finders
|
|
74
77
|
|
|
@@ -24,11 +24,12 @@ module ConcernsOnRails
|
|
|
24
24
|
# `with_deleted` peels off the default scope so deleted + non-deleted are both returned.
|
|
25
25
|
scope :with_deleted, -> { unscope(where: soft_delete_field) }
|
|
26
26
|
# Records soft-deleted within the last `duration` (e.g. `deleted_within(7.days)`).
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
27
|
+
# Arel `gteq` rather than an endless range (`x..`): AR only translates an
|
|
28
|
+
# endless range to `>=` on Rails 6.0+, but this gem supports Rails >= 5.0.
|
|
29
|
+
# arel_table also qualifies the column with the table name, so the scope
|
|
30
|
+
# stays unambiguous inside joins against tables sharing the column.
|
|
30
31
|
scope :deleted_within, lambda { |duration|
|
|
31
|
-
soft_deleted.where(
|
|
32
|
+
soft_deleted.where(arel_table[soft_delete_field].gteq(duration.ago))
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
# Hide soft-deleted rows from `.all` only when enabled (the default). The block is
|
|
@@ -64,16 +65,20 @@ module ConcernsOnRails
|
|
|
64
65
|
return all.where(soft_delete_field => nil).update_all(soft_delete_field => Time.zone.now)
|
|
65
66
|
end
|
|
66
67
|
|
|
68
|
+
# find_each streams in PK batches instead of materializing the whole
|
|
69
|
+
# relation; filtering deleted rows DB-side also skips loading them at
|
|
70
|
+
# all. Updated rows leave the filtered set, but pagination is strictly
|
|
71
|
+
# forward by id, so nothing is skipped or revisited.
|
|
67
72
|
transaction do
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
73
|
+
count = 0
|
|
74
|
+
all.where(soft_delete_field => nil).find_each do |record|
|
|
71
75
|
record.soft_delete! ||
|
|
72
76
|
raise(ActiveRecord::RecordNotSaved.new(
|
|
73
77
|
"ConcernsOnRails::Models::SoftDeletable: failed to soft-delete record", record
|
|
74
78
|
))
|
|
75
|
-
|
|
79
|
+
count += 1
|
|
76
80
|
end
|
|
81
|
+
count
|
|
77
82
|
end
|
|
78
83
|
end
|
|
79
84
|
|
|
@@ -102,13 +107,15 @@ module ConcernsOnRails
|
|
|
102
107
|
return soft_deleted.update_all(soft_delete_field => nil) if soft_delete_batch_fast_path?(:restore)
|
|
103
108
|
|
|
104
109
|
transaction do
|
|
105
|
-
|
|
110
|
+
count = 0
|
|
111
|
+
soft_deleted.find_each do |record|
|
|
106
112
|
record.restore! ||
|
|
107
113
|
raise(ActiveRecord::RecordNotSaved.new(
|
|
108
114
|
"ConcernsOnRails::Models::SoftDeletable: failed to restore record", record
|
|
109
115
|
))
|
|
110
|
-
|
|
116
|
+
count += 1
|
|
111
117
|
end
|
|
118
|
+
count
|
|
112
119
|
end
|
|
113
120
|
end
|
|
114
121
|
|
|
@@ -86,24 +86,38 @@ module ConcernsOnRails
|
|
|
86
86
|
private
|
|
87
87
|
|
|
88
88
|
def resolve_sortable_config(field_config, field_options)
|
|
89
|
-
|
|
89
|
+
if field_config.nil? && field_options.any?
|
|
90
|
+
# `sortable_by position: :desc` — the trailing keywords ARE the config.
|
|
91
|
+
field_config = field_options
|
|
92
|
+
elsif field_options.any?
|
|
93
|
+
# `sortable_by :position, ad_new_at: :top` — a typo'd option used to
|
|
94
|
+
# ride into **field_options and vanish silently.
|
|
95
|
+
raise ArgumentError,
|
|
96
|
+
"ConcernsOnRails::Models::Sortable: unknown option(s): #{field_options.keys.join(', ')}"
|
|
97
|
+
end
|
|
90
98
|
# A bare `sortable_by` keeps the documented defaults (:position asc)
|
|
91
99
|
# instead of crashing on nil (pre-1.22 NoMethodError).
|
|
92
100
|
field_config = sortable_field || :position if field_config.nil?
|
|
93
101
|
|
|
94
102
|
field, direction = parse_sortable_config(field_config)
|
|
95
|
-
|
|
96
|
-
|
|
103
|
+
unless %i[asc desc].include?(direction)
|
|
104
|
+
# Raise instead of the old silent :asc fallback — a misspelled
|
|
105
|
+
# direction reordered the whole default scope without a whisper.
|
|
106
|
+
raise ArgumentError,
|
|
107
|
+
"ConcernsOnRails::Models::Sortable: direction must be :asc or :desc, got '#{direction}'"
|
|
108
|
+
end
|
|
97
109
|
[field, direction]
|
|
98
110
|
end
|
|
99
111
|
|
|
100
112
|
def parse_sortable_config(config)
|
|
101
113
|
if config.is_a?(Hash)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
114
|
+
if config.size > 1
|
|
115
|
+
raise ArgumentError,
|
|
116
|
+
"ConcernsOnRails::Models::Sortable: pass exactly one field => direction pair, " \
|
|
117
|
+
"got #{config.inspect}"
|
|
118
|
+
end
|
|
105
119
|
key, value = config.first
|
|
106
|
-
[key.to_sym, value.to_sym]
|
|
120
|
+
[key.to_sym, value.to_s.to_sym]
|
|
107
121
|
else
|
|
108
122
|
[config.to_sym, :asc]
|
|
109
123
|
end
|
|
@@ -29,13 +29,19 @@ module ConcernsOnRails
|
|
|
29
29
|
#
|
|
30
30
|
# Plus a generic `transition_to!(state)`.
|
|
31
31
|
#
|
|
32
|
-
# Options for stateable_by: default:, transitions:, prefix:, suffix:
|
|
32
|
+
# Options for stateable_by: default:, transitions:, prefix:, suffix:, lock:
|
|
33
33
|
# (prefix:/suffix: take `true` to use the field name, or a literal string/symbol).
|
|
34
34
|
#
|
|
35
35
|
# Notes:
|
|
36
36
|
# * String columns only (store the state name) — not integer-backed like Rails enum.
|
|
37
37
|
# * A state named like an AR method (`new`, `valid`) or a concern scope
|
|
38
38
|
# (`active`, `expired`) will clash — use prefix:/suffix: to disambiguate.
|
|
39
|
+
# * Guarded transitions check the in-memory state: two processes firing the
|
|
40
|
+
# same <event>! concurrently can both pass the guard (check-then-write).
|
|
41
|
+
# `lock: true` closes that race — each <event>! takes a row lock
|
|
42
|
+
# (SELECT ... FOR UPDATE) and re-checks the guard against the fresh row
|
|
43
|
+
# first. Requires a clean record (with_lock reloads; AR refuses to
|
|
44
|
+
# reload unsaved changes) and costs a SELECT per transition.
|
|
39
45
|
module Stateable
|
|
40
46
|
extend ActiveSupport::Concern
|
|
41
47
|
|
|
@@ -44,6 +50,9 @@ module ConcernsOnRails
|
|
|
44
50
|
# Raised when a guarded transition is attempted from a disallowed state.
|
|
45
51
|
class InvalidTransition < StandardError; end
|
|
46
52
|
|
|
53
|
+
# Valid stateable_by keyword options (everything besides field/states:).
|
|
54
|
+
OPTIONS = %i[default transitions prefix suffix lock].freeze
|
|
55
|
+
|
|
47
56
|
included do
|
|
48
57
|
class_attribute :stateable_field, instance_accessor: false
|
|
49
58
|
class_attribute :stateable_states, instance_accessor: false, default: []
|
|
@@ -51,6 +60,7 @@ module ConcernsOnRails
|
|
|
51
60
|
class_attribute :stateable_transitions, instance_accessor: false, default: {}
|
|
52
61
|
class_attribute :stateable_prefix, instance_accessor: false
|
|
53
62
|
class_attribute :stateable_suffix, instance_accessor: false
|
|
63
|
+
class_attribute :stateable_lock, instance_accessor: false, default: false
|
|
54
64
|
end
|
|
55
65
|
|
|
56
66
|
# Move to any declared state by name, bypassing transition guards.
|
|
@@ -84,12 +94,16 @@ module ConcernsOnRails
|
|
|
84
94
|
private
|
|
85
95
|
|
|
86
96
|
def stateable_configure!(field, states, options)
|
|
97
|
+
unknown = options.keys - OPTIONS
|
|
98
|
+
raise ArgumentError, "#{LABEL}: unknown option(s): #{unknown.join(', ')}" if unknown.any?
|
|
99
|
+
|
|
87
100
|
self.stateable_field = field.to_sym
|
|
88
101
|
self.stateable_states = Array(states).map(&:to_sym)
|
|
89
102
|
self.stateable_default = options[:default]&.to_sym
|
|
90
103
|
self.stateable_transitions = options[:transitions] || {}
|
|
91
104
|
self.stateable_prefix = stateable_affix(options[:prefix])
|
|
92
105
|
self.stateable_suffix = stateable_affix(options[:suffix])
|
|
106
|
+
self.stateable_lock = options[:lock] ? true : false
|
|
93
107
|
ensure_columns!(LABEL, stateable_field, types: :string)
|
|
94
108
|
end
|
|
95
109
|
|
|
@@ -162,10 +176,21 @@ module ConcernsOnRails
|
|
|
162
176
|
private
|
|
163
177
|
|
|
164
178
|
# Instance-level guarded transition body, shared by every `<event>!`.
|
|
179
|
+
# With `lock: true` the guard is re-checked under a row lock (with_lock
|
|
180
|
+
# reloads, so the state read is the committed one) — closing the
|
|
181
|
+
# check-then-write race between two concurrent transitions.
|
|
182
|
+
def stateable_perform_transition!(field, to, from, event)
|
|
183
|
+
if self.class.stateable_lock && persisted?
|
|
184
|
+
with_lock { stateable_execute_transition!(field, to, from, event) }
|
|
185
|
+
else
|
|
186
|
+
stateable_execute_transition!(field, to, from, event)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
165
190
|
# Hooks and the state write share ONE transaction, so a raising
|
|
166
191
|
# after_transition rolls the state change back instead of leaving it
|
|
167
192
|
# committed with the side effect half-done (SoftDeletable's pattern).
|
|
168
|
-
def
|
|
193
|
+
def stateable_execute_transition!(field, to, from, event)
|
|
169
194
|
current = self[field].to_s
|
|
170
195
|
raise InvalidTransition, "#{self.class.name}: cannot #{event} from '#{self[field]}'" unless from.empty? || from.include?(current)
|
|
171
196
|
|
|
@@ -27,7 +27,10 @@ module ConcernsOnRails
|
|
|
27
27
|
#
|
|
28
28
|
# Notes:
|
|
29
29
|
# * Matching is boundary-safe ("rail" does not match "rails").
|
|
30
|
-
# * A tag
|
|
30
|
+
# * A tag cannot contain the delimiter (default ",") — input containing
|
|
31
|
+
# it is split into multiple tags on the spot (`add_tags("a,b")` adds
|
|
32
|
+
# "a" and "b"), everywhere, so what you read back always matches what
|
|
33
|
+
# a save would have produced.
|
|
31
34
|
# * Reach for acts-as-taggable-on when you need tag contexts, ownership,
|
|
32
35
|
# tag counts/clouds, or polymorphic tags shared across models.
|
|
33
36
|
module Taggable
|
|
@@ -70,8 +73,14 @@ module ConcernsOnRails
|
|
|
70
73
|
end
|
|
71
74
|
|
|
72
75
|
# All distinct tags currently stored across the table, sorted.
|
|
76
|
+
# distinct + NULL filter dedupe DB-side, so identical tag strings ship
|
|
77
|
+
# over the wire once instead of once per row.
|
|
73
78
|
def all_tags
|
|
74
|
-
|
|
79
|
+
where.not(taggable_field => nil)
|
|
80
|
+
.distinct
|
|
81
|
+
.pluck(taggable_field)
|
|
82
|
+
.flat_map { |raw| taggable_split(raw) }
|
|
83
|
+
.uniq.sort
|
|
75
84
|
end
|
|
76
85
|
|
|
77
86
|
# Split a raw stored column value into a normalized tag array.
|
|
@@ -87,8 +96,16 @@ module ConcernsOnRails
|
|
|
87
96
|
|
|
88
97
|
private
|
|
89
98
|
|
|
99
|
+
# Splits each entry on the delimiter before cleaning: a tag can never
|
|
100
|
+
# contain the delimiter (the column format has no way to escape it),
|
|
101
|
+
# so "a,b" was ALWAYS going to read back as two tags after the next
|
|
102
|
+
# normalize pass — splitting here makes that immediate and uniform
|
|
103
|
+
# instead of a silent later surprise.
|
|
90
104
|
def taggable_clean_all(names)
|
|
91
|
-
names.flatten
|
|
105
|
+
names.flatten
|
|
106
|
+
.flat_map { |t| t.to_s.split(taggable_delimiter) }
|
|
107
|
+
.map { |t| taggable_clean(t) }
|
|
108
|
+
.reject(&:blank?).uniq
|
|
92
109
|
end
|
|
93
110
|
|
|
94
111
|
# Boundary-safe match for one tag against the delimiter-joined column.
|
|
@@ -126,20 +143,23 @@ module ConcernsOnRails
|
|
|
126
143
|
end
|
|
127
144
|
|
|
128
145
|
def add_tags(*names)
|
|
129
|
-
self.tag_list = tag_list + names.flatten
|
|
146
|
+
self.tag_list = tag_list + names.flatten
|
|
130
147
|
tag_list
|
|
131
148
|
end
|
|
132
149
|
alias add_tag add_tags
|
|
133
150
|
|
|
134
151
|
def remove_tags(*names)
|
|
135
|
-
drop = names.flatten
|
|
152
|
+
drop = taggable_coerce(names.flatten)
|
|
136
153
|
self.tag_list = tag_list.reject { |t| drop.include?(t) }
|
|
137
154
|
tag_list
|
|
138
155
|
end
|
|
139
156
|
alias remove_tag remove_tags
|
|
140
157
|
|
|
158
|
+
# AND semantics for delimiter-containing input, mirroring the class-level
|
|
159
|
+
# tagged_with default: tagged_with?("a,b") is true when BOTH tags are set.
|
|
141
160
|
def tagged_with?(tag)
|
|
142
|
-
|
|
161
|
+
parts = self.class.taggable_split(tag.to_s)
|
|
162
|
+
parts.any? && (parts - tag_list).empty?
|
|
143
163
|
end
|
|
144
164
|
alias has_tag? tagged_with?
|
|
145
165
|
|
|
@@ -156,9 +176,12 @@ module ConcernsOnRails
|
|
|
156
176
|
self[field] = tags.empty? ? nil : tags.join(self.class.taggable_delimiter)
|
|
157
177
|
end
|
|
158
178
|
|
|
179
|
+
# Funnel every input shape through taggable_split so Strings and Arrays
|
|
180
|
+
# (and Array items that themselves contain the delimiter) normalize
|
|
181
|
+
# identically.
|
|
159
182
|
def taggable_coerce(value)
|
|
160
|
-
|
|
161
|
-
|
|
183
|
+
raw = value.is_a?(Array) ? value.join(self.class.taggable_delimiter) : value.to_s
|
|
184
|
+
self.class.taggable_split(raw)
|
|
162
185
|
end
|
|
163
186
|
end
|
|
164
187
|
end
|
|
@@ -15,10 +15,6 @@ module ConcernsOnRails
|
|
|
15
15
|
max ? max + 1 : cfg[:start_at]
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
def sequence_value_taken?(field, candidate, record, scope_attrs)
|
|
19
|
-
sequence_relation(field, record, scope_attrs).exists?(field => candidate)
|
|
20
|
-
end
|
|
21
|
-
|
|
22
18
|
# Relation of existing rows that share this record's scope (and period, when
|
|
23
19
|
# reset is enabled). Reads from `unscoped` so a model's default_scope never
|
|
24
20
|
# hides rows the counter must account for.
|
data/lib/concerns_on_rails.rb
CHANGED
|
@@ -62,6 +62,7 @@ module ConcernsOnRails
|
|
|
62
62
|
autoload :CursorPaginatable, "concerns_on_rails/controllers/cursor_paginatable"
|
|
63
63
|
autoload :Deprecatable, "concerns_on_rails/controllers/deprecatable"
|
|
64
64
|
autoload :Cacheable, "concerns_on_rails/controllers/cacheable"
|
|
65
|
+
autoload :Permittable, "concerns_on_rails/controllers/permittable"
|
|
65
66
|
end
|
|
66
67
|
|
|
67
68
|
module Support
|
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.26.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-08-
|
|
11
|
+
date: 2026-08-24 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: actionpack
|
|
@@ -104,6 +104,20 @@ dependencies:
|
|
|
104
104
|
- - "~>"
|
|
105
105
|
- !ruby/object:Gem::Version
|
|
106
106
|
version: '5.4'
|
|
107
|
+
- !ruby/object:Gem::Dependency
|
|
108
|
+
name: permittable
|
|
109
|
+
requirement: !ruby/object:Gem::Requirement
|
|
110
|
+
requirements:
|
|
111
|
+
- - "~>"
|
|
112
|
+
- !ruby/object:Gem::Version
|
|
113
|
+
version: '0.1'
|
|
114
|
+
type: :runtime
|
|
115
|
+
prerelease: false
|
|
116
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
117
|
+
requirements:
|
|
118
|
+
- - "~>"
|
|
119
|
+
- !ruby/object:Gem::Version
|
|
120
|
+
version: '0.1'
|
|
107
121
|
description: A collection of plug-and-play ActiveSupport concerns for Rails models
|
|
108
122
|
and Rails controllers
|
|
109
123
|
email:
|
|
@@ -128,6 +142,7 @@ files:
|
|
|
128
142
|
- lib/concerns_on_rails/controllers/includable.rb
|
|
129
143
|
- lib/concerns_on_rails/controllers/localizable.rb
|
|
130
144
|
- lib/concerns_on_rails/controllers/paginatable.rb
|
|
145
|
+
- lib/concerns_on_rails/controllers/permittable.rb
|
|
131
146
|
- lib/concerns_on_rails/controllers/respondable.rb
|
|
132
147
|
- lib/concerns_on_rails/controllers/secure_headable.rb
|
|
133
148
|
- lib/concerns_on_rails/controllers/sortable.rb
|