concerns_on_rails 1.28.5 → 1.28.7

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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +243 -0
  3. data/README.md +198 -62
  4. data/lib/concerns_on_rails/configuration.rb +35 -0
  5. data/lib/concerns_on_rails/controllers/authorizable.rb +152 -17
  6. data/lib/concerns_on_rails/controllers/cacheable.rb +141 -5
  7. data/lib/concerns_on_rails/controllers/error_handleable.rb +1 -0
  8. data/lib/concerns_on_rails/controllers/idempotentable.rb +76 -15
  9. data/lib/concerns_on_rails/controllers/includable.rb +88 -18
  10. data/lib/concerns_on_rails/controllers/localizable.rb +2 -23
  11. data/lib/concerns_on_rails/controllers/respondable.rb +125 -0
  12. data/lib/concerns_on_rails/controllers/sortable.rb +208 -26
  13. data/lib/concerns_on_rails/controllers/throttleable.rb +106 -10
  14. data/lib/concerns_on_rails/controllers/timezoneable.rb +84 -14
  15. data/lib/concerns_on_rails/models/activatable.rb +90 -13
  16. data/lib/concerns_on_rails/models/auditable.rb +49 -5
  17. data/lib/concerns_on_rails/models/counter_cacheable.rb +94 -17
  18. data/lib/concerns_on_rails/models/duplicable.rb +13 -1
  19. data/lib/concerns_on_rails/models/hashable.rb +66 -11
  20. data/lib/concerns_on_rails/models/maskable.rb +83 -1
  21. data/lib/concerns_on_rails/models/normalizable.rb +100 -10
  22. data/lib/concerns_on_rails/models/sanitizable.rb +146 -1
  23. data/lib/concerns_on_rails/models/searchable.rb +87 -13
  24. data/lib/concerns_on_rails/models/sluggable.rb +18 -0
  25. data/lib/concerns_on_rails/models/storable.rb +250 -65
  26. data/lib/concerns_on_rails/models/tokenizable.rb +121 -29
  27. data/lib/concerns_on_rails/support/include_tree.rb +74 -0
  28. data/lib/concerns_on_rails/support/vary_header.rb +54 -0
  29. data/lib/concerns_on_rails/version.rb +1 -1
  30. data/lib/concerns_on_rails.rb +2 -0
  31. metadata +4 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5c6fcd158fd0dce6d1744e9ad6be5d921bf283e921e717a8327f04f53e8668f4
4
- data.tar.gz: fe3b93a06be3a0cbb4b0e1904a976393b77d24c4075f1d8dccb98e13d3ac6597
3
+ metadata.gz: 5b713f3fd8cd14e5f3a12db7b3c390111118df1fa143d5d833dee009544a8a64
4
+ data.tar.gz: 16f6d5447ac1d44c5be3dc1cdb0113d9a9e64457de14b87d4199bb95993189d8
5
5
  SHA512:
6
- metadata.gz: 1a113631bd4810decc66aad97ba5fc9fe8866d92dcfd1ab1908c2cdafe7e990e961c3655933ad124ae8275f1a199c4275a7ba72a06cc172ecf248e2825eb42de
7
- data.tar.gz: 7102f8a5cb83e21eabe46066f7a62956adf0950dbe926dc8706c9abd217a87577b6004d625c71d179dc2cf4083dc7fdefd25b47883e1a085fee1ec5bb691b41c
6
+ metadata.gz: 7afa091b97fe9359b7294f965593af629d5d66d797e77a7c3ebde396eddc7fcdf627937fae2c90c2ee0c034c23e91f3713ac3827ab201ee8edda0438094ad89c
7
+ data.tar.gz: 683d74ea9d1b258e1c79bcdcd7243bcbcd22b10ba5c862946267e5baab118c3753c69baf7af496436a7424286cbd33be9f5e6bfba889928f6f212b190d9ed3ca
data/CHANGELOG.md CHANGED
@@ -1,5 +1,248 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.28.7 (2026-09-19)
4
+
5
+ The eight PRs held back from 1.28.6, released as a patch by request. Each carried a
6
+ CRITICAL or a design-level defect found in review; each now carries the fix, and in
7
+ most cases a spec that was verified to fail against the unfixed code. Read the
8
+ `### Fixed` section: several of these defects were live in the PRs' own green CI,
9
+ and two of them are security-shaped.
10
+
11
+ Also in this release: the Rails 8.1 component bumps are unblocked, and `json` is
12
+ pinned below 3 (json 3 removes `JSON.generate(..., quirks_mode:)`, which
13
+ ActiveSupport 7.1 calls, and changes `JSON.parse`'s positional options, which
14
+ ActiveSupport 8.1 uses — with json 3.0.2 the suite fails in Storable's decode path).
15
+ 1730 examples, 0 failures.
16
+
17
+ ### Added
18
+ - **Controllers::Cacheable**: `etag_with` folds request context into the ETag —
19
+ presets `:locale` / `:format` / `:query`, controller-method Symbols, or a block — so
20
+ locale-, fieldset- or role-dependent representations of one resource never share a
21
+ validator; each source adds its implied `Vary` (`vary:` overrides, `vary: false`
22
+ suppresses), merged with the `http_cache_actions` policy. `stale_resource?` /
23
+ `set_cache_validators` gain a per-call `extras:`. (#48)
24
+ - **Controllers::Authorizable**: denials instrument
25
+ `authorization_denied.concerns_on_rails` (controller, action, actor_id, actor_type,
26
+ rule name, status, message) via the `on_authorization_denied(rule)` override point;
27
+ `authorize_by`/`require_role` accept `name:`. `skip_authorization only:/except:`
28
+ exempts actions from every rule, inherited ones included. `authorized?(action)`
29
+ evaluates the rules without rendering, for view predicates. (#68)
30
+ - **Controllers::Timezoneable**: `persist:` writes a param-chosen zone into the
31
+ `cookie:` cookie; `response_header:` emits the resolved zone (`X-Time-Zone` or a
32
+ custom name) and appends `Vary: Time-Zone`; `time_zone_source` reports which source
33
+ won. (#71)
34
+ - **Controllers::Sortable**: `params[:sort]` accepts JSON:API-style `-key` / `+key`
35
+ per-column direction prefixes, and `sortable_by` accepts rule hashes —
36
+ `key: { column: "table.column", joins:, join: :left|:inner, nulls: :first|:last }` —
37
+ for association-column sorting (lazy LEFT OUTER JOIN by default) and NULLs pinned
38
+ first or last (Rails 6.1+). (#63)
39
+ - **Models::Searchable**: `searchable_by ..., ranked: true` orders `search` results by
40
+ relevance — exact, then prefix, then substring, earlier-declared columns first
41
+ within a tier — via a portable CASE expression; the relation's existing ORDER BY
42
+ becomes the tiebreaker. `search(q, ranked:)` overrides per call and `search_rank(q)`
43
+ exposes the score expression. (#64)
44
+ - **Models::Normalizable**: `with:` accepts an Array of presets/callables applied left
45
+ to right, validated at class load. New presets `:strip`, `:capitalize`, `:titleize`,
46
+ `:parameterize`, `:nullify_blank` and `:url`. `Model.normalize(field, value)` applies
47
+ a field's rule to a bare value for lookups and params. (#66)
48
+ - **Models::Sanitizable**: `sanitized_attributes` and a `sanitized:` serialization
49
+ option — `as_json(sanitized: true | [:fields])` — which composes with
50
+ `only:`/`except:`, is carried into `include:` children, and sanitizes the *serialized*
51
+ value so a Maskable mask survives. `Model.sanitize_all!(*fields)` rewrites legacy rows
52
+ in place for the current scope (by default the `on: :write` fields only), transactional
53
+ via `Support::BatchOps`, refreshing Encryptable blind indexes. (#72)
54
+ - **Models::Storable**: `where_<accessor>(value)` scope per key — equality on a stored
55
+ key via `json_extract` (SQLite), `->>` (PostgreSQL) or `JSON_UNQUOTE(JSON_EXTRACT())`
56
+ (MySQL). Values are cast as the writer stores them; `where_<key>(nil)` matches
57
+ unset/null. Opt out per key or per macro with `query: false`. (#76)
58
+ - **Support::VaryHeader**: shared `Vary` appender used by Timezoneable and Localizable —
59
+ seeds Rails' own `Accept` dimension, appends rather than clobbers, de-duplicates
60
+ case-insensitively and leaves `Vary: *` alone. (#71)
61
+
62
+ ### Changed
63
+ - **Controllers::Cacheable**: a response whose ETag varies on a dimension `Vary` cannot
64
+ express — a block or controller-method source, or any source with `vary: false` — is
65
+ now emitted as `Cache-Control: private` regardless of the rule's declared
66
+ `visibility:`. Such a response is not shareable, and there is no `Vary` that makes it
67
+ so. (#48)
68
+ - **Controllers::Sortable**: PostgreSQL uses native `NULLS FIRST/LAST`; every other
69
+ adapter gets the portable `CASE WHEN col IS NULL` equivalent. A `default:` outside the
70
+ allow-list orders the relation without becoming client-selectable, repeated sort keys
71
+ collapse to their first occurrence, and `+` must be percent-encoded as `%2B` (Rack
72
+ decodes a raw `+` to a space). `sort_requests` is the override point; `sort_fields` is
73
+ read-only. (#63)
74
+ - **Controllers::Authorizable**: the denial payload carries `actor_id:`/`actor_type:`
75
+ rather than the `current_user` object — notification payloads are not filtered by
76
+ `config.filter_parameters`. (#68)
77
+ - **Models::Normalizable**: `:url` accepts only `http`/`https`; a value carrying any
78
+ other scheme is returned stripped rather than blessed as normalized. `:titleize` is
79
+ deliberately **not** `String#titleize`. (#66)
80
+ - **Models::Storable**: a `where_<key>` scope whose name is already taken no longer
81
+ aborts the declaration — it is skipped with a deprecator warning, so an existing model
82
+ defining that method still boots after an upgrade. (#76)
83
+ - **Models::Searchable**: a grouped relation is returned unranked, since a rank
84
+ `ORDER BY` over `GROUP BY` is an error on PostgreSQL and on MySQL under
85
+ `ONLY_FULL_GROUP_BY`. (#64)
86
+
87
+ ### Fixed
88
+ - **Controllers::Authorizable**: `skip_authorization except: []` (or `false`, or `""`)
89
+ exempted **every** action of the controller and all its subclasses — each of those
90
+ values is truthy while matching no real action name, so the `!except.include?(action)`
91
+ test was true everywhere. `except: Rails.env.production? && :destroy` is the realistic
92
+ spelling. Now rejected at class load, along with non-Symbol/String entries; `only:`
93
+ still accepts them, where they are inert. (#68)
94
+ - **Models::Sanitizable**: `serializable_hash` re-read the raw column instead of
95
+ post-processing the serialized value, so on a model including both Maskable and
96
+ Sanitizable it overwrote the mask with sanitized plaintext — order-dependently, and
97
+ therefore silently. (#72)
98
+ - **Controllers::Timezoneable**: the `cookie:` source now works on a real
99
+ `ActionController::Base`. `#cookies` is PRIVATE there, so the `respond_to?(:cookies)`
100
+ guard was always false and the documented cookie source silently did nothing in every
101
+ real Rails app; only the specs' public-`cookies` double made it look alive. Both guards
102
+ now ask `respond_to?(:cookies, true)`. (#71)
103
+ - **Controllers::Timezoneable**: `Vary` is no longer written before the action runs,
104
+ which suppressed Rails' own `Vary: Accept` (`_set_vary_header` only adds it when `Vary`
105
+ is blank) and let a shared cache serve a JSON body to an HTML request. (#71)
106
+ - **Controllers::Sortable**: MySQL is detected by behaviour rather than by adapter name.
107
+ The previous `adapter_name.include?("mysql")` test was false for Trilogy, so a
108
+ `nulls:` rule emitted PostgreSQL syntax against MySQL 8 — a 1064 parse error on every
109
+ request using that sort key. A dotted Symbol column (`sortable_by :"authors.name"`)
110
+ is quoted correctly again; it had regressed to `"posts"."authors.name"`. Sort keys are
111
+ de-duplicated, so `?sort=` with thousands of repeated keys no longer builds thousands
112
+ of ORDER BY terms. (#63)
113
+ - **Models::Normalizable**: `:titleize` no longer destroys data. It was
114
+ `Inflector.titleize`, i.e. `humanize(underscore(v))`, which deleted characters —
115
+ `"Jean-Luc Picard"` → `"Jean Luc Picard"`, `"customer_id"` → `"Customer"` — and ran in
116
+ `before_validation`, so the original was gone. `:url` no longer drops a URL's
117
+ `userinfo` on Ruby's newer `uri` versions. (#66)
118
+ - **Models::Storable**: `serialize :settings, coder: JSON, type: Hash` — the form Rails
119
+ 7.1's own deprecation message directs users to — was misclassified as a non-JSON coder,
120
+ so the whole query feature refused to run on a perfectly queryable column. A blank or
121
+ corrupt store value no longer makes every `where_` query raise on SQLite. A read-only
122
+ finder no longer mutates the caller's `Time`. Key names are validated at macro time.
123
+ (#76)
124
+ - **Models::CounterCacheable**: the locking spec added in 1.28.6 matched the SQLite
125
+ transaction statement with `start_with?("begin")`; Rails 7.2+ switched SQLite to
126
+ IMMEDIATE transactions and upcased it, so the assertion silently found nothing on
127
+ Rails 8.x. Test-only.
128
+
129
+ ### Internal
130
+ - `json` is pinned to `< 3` in the Gemfile. Verified against a real 8.1.3.1 gemset:
131
+ with json 3.0.2 the suite fails in Storable's decode path; with `json < 3` Rails
132
+ 8.1.3.1 is green.
133
+ - `require "active_support/notifications"` added to `authorizable.rb` and
134
+ `error_handleable.rb`, which instrument without requiring it — a direct require of
135
+ either file used to `NameError` on the first event. (#68)
136
+
137
+ ## 1.28.6 (2026-09-18)
138
+
139
+ Ten feature PRs deepening existing concerns, released as a patch by request: no new
140
+ concerns and no dependency changes, though several add an optional column or option.
141
+ Every PR was reviewed before merge and carries the review's fixes; the `### Changed`
142
+ and `### Fixed` sections below are those fixes, and they are the ones to read — a few
143
+ tighten validation or start raising where the code used to accept bad input silently.
144
+ Eight further PRs were reviewed in the same pass and held back: they carry defects
145
+ that need more than a patch-sized change.
146
+
147
+ ### Added
148
+ - **Models::Maskable**: `masked_attributes` (every declared field masked, keyed like
149
+ `attributes`) and a `masked:` serialization option — `as_json(masked: true)` /
150
+ `to_json(masked: true)` / `serializable_hash(masked: true)` swap the declared fields
151
+ for their masked forms (`masked: [:email]` for a subset; undeclared fields raise).
152
+ Composes with `only:`/`except:`/`methods:`/`include:`; plain serialization is
153
+ unchanged. (#65)
154
+ - **Models::Activatable**: `before_activate` / `after_activate` / `before_deactivate` /
155
+ `after_deactivate` hooks (one transaction with the write; a raising after-hook rolls
156
+ back, a failed update skips it; overriding one moves that direction's batch verb to
157
+ the per-record path) and `activatable_by timestamps:` (`true` → `activated_at` /
158
+ `deactivated_at` stamped on each transition, or a Hash to rename/drop a side). The
159
+ batch fast path stamps too. (#78)
160
+ - **Models::Hashable**: `prefix:` prepends a literal to generated values (Stripe-style
161
+ public IDs such as `ord_k7m3pq9a`; string types only), and `to_param: true` makes the
162
+ hashed field the URL parameter (falls back to the id while blank). Both validated at
163
+ class load. (#70)
164
+ - **Models::Tokenizable**: `tokenizable_by … expires_in:` gives a token a lifetime —
165
+ `<field>_expires_at` is stamped on every generation, `authenticate_by_<field>` refuses
166
+ an expired token, `<field>_expired?` and the `<field>_expired` scope report it. New
167
+ `consume_<field>(value)` (every field) authenticates and revokes in one race-safe step
168
+ for single-use tokens. (#51)
169
+ - **Models::Auditable**: `ConcernsOnRails.setup { |c| c.audit_actor = -> { Current.user&.id } }`
170
+ sets the `"by"` actor once for every audited model that passes no `actor:` (resolved
171
+ per save, `instance_exec`'d on the record); `actor: false` opts a model out, and
172
+ `actor: :method_name` stamps a record method's value. (#50)
173
+ - **Models::CounterCacheable**: `recount_counter_caches!(association, parents:)` repairs
174
+ only the given parents (ids, records or a relation) — locked, zeroed and re-tallied in
175
+ one transaction, other rows untouched — so a post-import fix is O(their children).
176
+ Empty `parents:` is a no-op; the association is required when more than one is
177
+ declared. (#75)
178
+ - **Controllers::Includable**: nested include allow-lists — `includable :author,
179
+ comments: :author` accepts `?include=comments.author` (a path must match every
180
+ segment). `requested_includes(as: :query | :paths | :json)` returns the sanitized
181
+ includes for `includes`/`preload`, JSON:API serializers, or `as_json(include:)`;
182
+ `requested_include_paths` exposes the dotted paths. `default:` loads named paths when
183
+ `?include` is absent (blank opts out); `strategy:` picks
184
+ `includes`/`preload`/`eager_load`. New `Support::IncludeTree`. (#69)
185
+ - **Controllers::Idempotentable**: replays now carry the original response's `Location`,
186
+ `Content-Location`, `ETag`, `Last-Modified` and `Link` headers, so a retried `create`
187
+ still says where the resource lives. `idempotent_actions … headers:` tunes the
188
+ allow-list (`[]` to capture none). Records written before this release replay
189
+ unchanged. (#47)
190
+ - **Controllers::Throttleable**: `throttle_by … if:/unless:` per-request skip conditions
191
+ (Symbol method or callable; both must pass), mirroring Rails 7.2's `rate_limit`. A
192
+ throttled request instruments `rate_limited.concerns_on_rails` (rule, discriminator,
193
+ count/limit/period, reset_at/retry_after, controller, action) through the new public
194
+ `on_rate_limited(rule, result)` hook. (#44)
195
+ - **Controllers::Respondable**: `respondable_by error_format: :problem_details,
196
+ problem_type_base:` makes `render_error` emit RFC 9457 `application/problem+json`
197
+ documents (`type`, `title`, `status`, `detail`, `instance`, plus `code`/`errors`
198
+ extensions). Every concern that renders errors through Respondable follows, so the
199
+ switch is app-wide. The default stays `:envelope`. (#57)
200
+
201
+ ### Changed
202
+ - **Controllers::Throttleable**: with several applicable rules the `X-RateLimit-*`
203
+ headers now describe the tightest passing rule (fewest remaining) instead of the last
204
+ one declared, and a tie on remaining is broken by the rule that resets *last* — so the
205
+ advertised `X-RateLimit-Reset` can no longer promise a 60-second wait when an hourly
206
+ rule is the real budget. `result[:discriminator]` is now available to
207
+ `throttled_response`. Note it is raw personal data (an IP or a user id) in the
208
+ instrumentation payload too; override `on_rate_limited` to hash or drop it. (#44)
209
+ - **Controllers::Throttleable**: `if:`/`unless:` conditions are now arity-aware — a
210
+ one-argument callable receives the controller, a zero-arity Proc is still
211
+ `instance_exec`'d. Previously a `->(c) { … }` condition raised `ArgumentError` on
212
+ every request. (#44)
213
+ - **Models::Auditable**: an explicit `actor: nil` now keeps meaning "never record who"
214
+ and no longer falls through to the gem-wide `config.audit_actor`; only omitting
215
+ `actor:` takes the fallback. `config.audit_actor` accepts a non-Proc callable and
216
+ `false` (a synonym for nil), and rejects a lambda that demands arguments at
217
+ configuration time rather than raising inside `before_save`. (#50)
218
+ - **Models::CounterCacheable**: `recount_counter_caches!` now raises `ArgumentError`
219
+ for an association with no declared counter, instead of returning `{}` as a silent
220
+ success; and a scoped repair locks the named parent rows before tallying, so a
221
+ concurrent child write can no longer be lost. (#75)
222
+ - **Controllers::Idempotentable**: response-header capture is case-insensitive, so an
223
+ app that sets `location` rather than `Location` (correct under Rack 3) is captured on
224
+ every supported Rails version, not only 7.1+. `idempotency_scope` is now a documented
225
+ public override point — the default namespace has no per-principal component, so two
226
+ users sharing a client-chosen key can be served each other's cached response. (#47)
227
+ - **Controllers::Respondable**: the problem-details media type is emitted as bare
228
+ `application/problem+json`, with no `charset` parameter — RFC 9457 registers none, and
229
+ strict clients reject the parameterised form. (#57)
230
+
231
+ ### Fixed
232
+ - **Models::Activatable**: lifecycle hooks are invoked with `send`, so a `private` hook
233
+ override runs instead of raising `NoMethodError` — which in a batch verb aborted and
234
+ rolled back the whole run. (#78)
235
+ - **Models::Hashable**: the `to_param: true` / Sluggable conflict now raises in *either*
236
+ declaration order. With Hashable declared first, friendly_id's `to_param` used to land
237
+ above Hashable's and silently win, so `to_param: true` did nothing. (#70)
238
+ - **Models::Tokenizable**: `expires_in:` now requires an `ActiveSupport::Duration` or a
239
+ `Numeric`. `expires_in: 2.hours.from_now` was accepted as ~1.8 billion seconds (tokens
240
+ effectively never expired) and `expires_in: "2 hours"` as 2 seconds. A model missing
241
+ both token columns now reports them in one error. (#51)
242
+ - **Controllers::Includable**: `requested_includes(as: :paths)` returns a fresh array on
243
+ the `default:` path; it previously handed back the class attribute itself, so a caller
244
+ mutating the result corrupted every later request in the process. (#69)
245
+
3
246
  ## 1.28.5 (2026-09-17)
4
247
 
5
248
  Eight feature PRs deepening existing concerns, released as a patch by request: