concerns_on_rails 1.28.6 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d01a41891ae267bab705b85ab63d6555e7057719a218738bd82c858c63febfa9
4
- data.tar.gz: 3bcaebd299e65af41029baebb90ba1670a3502fde222071bffeeef86a4e1fb7b
3
+ metadata.gz: 5b713f3fd8cd14e5f3a12db7b3c390111118df1fa143d5d833dee009544a8a64
4
+ data.tar.gz: 16f6d5447ac1d44c5be3dc1cdb0113d9a9e64457de14b87d4199bb95993189d8
5
5
  SHA512:
6
- metadata.gz: 602d50edd8fd84224c73a6ddc09e756bf322f4b3b4e526d34e55774caa88594c54c3ba05d1920005c2152986035b230e2f982e5b6916c2a3a2ec402e1ac2c3c6
7
- data.tar.gz: 9ab3f9c635ab132b284d673861c3a3a8282f93aa370f6f75cb1890a1205b9e3c924705b52afd7d182a94fb5247fdc38f51e94bcc4d2a0b1c8de39ea53ee03b54
6
+ metadata.gz: 7afa091b97fe9359b7294f965593af629d5d66d797e77a7c3ebde396eddc7fcdf627937fae2c90c2ee0c034c23e91f3713ac3827ab201ee8edda0438094ad89c
7
+ data.tar.gz: 683d74ea9d1b258e1c79bcdcd7243bcbcd22b10ba5c862946267e5baab118c3753c69baf7af496436a7424286cbd33be9f5e6bfba889928f6f212b190d9ed3ca
data/CHANGELOG.md CHANGED
@@ -1,5 +1,139 @@
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
+
3
137
  ## 1.28.6 (2026-09-18)
4
138
 
5
139
  Ten feature PRs deepening existing concerns, released as a patch by request: no new
data/README.md CHANGED
@@ -148,7 +148,7 @@ across all 43 concerns — press <kbd>/</kbd> and type.
148
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
149
149
  - **Schema-validated configuration** — every macro checks that the configured columns exist and raises `ArgumentError` early — listing *every* missing column at once, with one ready-to-paste `rails generate migration` command that adds them all
150
150
  - **Composable** — concerns are independent; mix and match per model
151
- - **Tested like an app, not a snippet** — **1,624 RSpec examples** run against a real database on every CI build
151
+ - **Tested like an app, not a snippet** — **1,730 RSpec examples** run against a real database on every CI build
152
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
153
153
 
154
154
  ---
@@ -724,7 +724,7 @@ expirable_by :valid_until
724
724
 
725
725
  ## ✨ Normalizable
726
726
 
727
- Auto-normalize attribute values in `before_validation` — strip whitespace, downcase emails, dedupe spaces, run any custom transform.
727
+ Auto-normalize attribute values in `before_validation` — strip whitespace, downcase emails, dedupe spaces, chain transforms, run any custom lambda.
728
728
 
729
729
  ```ruby
730
730
  class User < ApplicationRecord
@@ -733,27 +733,37 @@ class User < ApplicationRecord
733
733
  normalizable :email, with: :email # strip + downcase
734
734
  normalizable :phone, with: :phone # digits only
735
735
  normalizable :first_name, :last_name, with: :whitespace # strip — same rule, multiple fields
736
- normalizable :slug, with: ->(v) { v.to_s.parameterize } # custom lambda
736
+ normalizable :display_name, with: %i[squish titleize] # a chain, applied left to right
737
+ normalizable :bio, with: %i[squish nullify_blank] # "" / " " → nil
738
+ normalizable :website, with: :url # "Example.COM/x" → "https://example.com/x"
739
+ normalizable :slug, with: ->(v) { v.to_s.parameterize } # custom lambda (chains with presets too)
737
740
  end
738
741
 
739
742
  User.create(email: " ALICE@Example.com ").email # => "alice@example.com"
740
743
  User.create(phone: "+1 (415) 555-1234").phone # => "14155551234"
744
+
745
+ # The same rule outside a record — lookups and params see what the DB sees:
746
+ User.find_by(email: User.normalize(:email, params[:email]))
741
747
  ```
742
748
 
743
749
  **Built-in presets**
744
750
 
745
- | Preset | Transform |
746
- |--------------|------------------------------------------|
747
- | `:email` | `strip` + `downcase` |
748
- | `:phone` | digits only (`gsub(/\D/, "")`) |
749
- | `:whitespace`| `strip` |
750
- | `:squish` | `squish` (collapse inner whitespace) |
751
- | `:downcase` | `downcase` |
752
- | `:upcase` | `upcase` |
751
+ | Preset | Transform |
752
+ |-----------------|----------------------------------------------------------------------|
753
+ | `:email` | `strip` + `downcase` |
754
+ | `:phone` | digits only (`gsub(/\D/, "")`) |
755
+ | `:whitespace` / `:strip` | `strip` |
756
+ | `:squish` | `squish` (collapse inner whitespace) |
757
+ | `:downcase` / `:upcase` / `:capitalize` | the String method of the same name |
758
+ | `:titleize` | upcase each word's first letter and downcase the rest, **in place** — deliberately *not* `String#titleize` (that is `humanize(underscore(v))`, which splits `"Jean-Luc"` into `"Jean Luc"` and drops the `_id` of `"customer_id"`). No character is added or removed, so hyphens and underscores survive; only case changes, so `"DVD player"` → `"Dvd Player"` |
759
+ | `:parameterize` | `parameterize` (URL slug) |
760
+ | `:nullify_blank`| `""` or whitespace-only → `nil` (content untouched) |
761
+ | `:url` | strip, default scheme to `https://` (`host:port` counts as schemeless), lowercase scheme + host, keep userinfo/path/query, drop a redundant default port. Only `http`/`https` are canonicalized — any other scheme (`mailto:`, `tel:`, `javascript:`, `data:`) and unparseable input come back stripped for your format validator to reject |
753
762
 
754
763
  **Notes**
755
764
  - Runs in `before_validation`, so DB constraints and AR validations see the normalized value.
756
- - `nil` values are skipped no `nil ""` coercion.
765
+ - `with:` takes a preset, a Proc, or an Array of them (applied in order); every entry is validated at class load.
766
+ - `nil` values are skipped — no `nil → ""` coercion (use `:nullify_blank` for the opposite direction).
757
767
  - Preset normalizers pass non-string values through unchanged.
758
768
  - Works on Rails 5+ (no dependency on Rails 7.1's built-in `normalizes`).
759
769
 
@@ -787,13 +797,24 @@ Article.search("ruby framework") # title OR body must contain "ruby" AND "frame
787
797
  # match: :prefix — term% (starts with)
788
798
  # match: :exact — term (full match)
789
799
  searchable_by :sku, match: :prefix
800
+
801
+ # ranked: true — best matches first: exact, then prefix, then substring hits;
802
+ # within a tier the earlier-declared column wins. Portable CASE expression, no index needed.
803
+ searchable_by :title, :body, ranked: true
804
+ Article.search("ruby") # "ruby" (title) → "Ruby" (body) → "Rubyists…" → "…about ruby"
805
+ Article.search("ruby", ranked: false) # per-call override (and `ranked: true` opts in per call)
806
+ Article.recent.search("ruby") # relevance leads; the existing ORDER BY breaks ties
807
+ Article.group(:author_id).search("ruby") # grouped relations are returned unranked
808
+ Article.search(q).pluck(:id, Article.search_rank(q)) # the score itself (0 = exact hit on the first column)
790
809
  ```
791
810
 
792
811
  **Notes**
793
812
  - Uses Arel's `matches`, which emits `ILIKE` on Postgres (case-insensitive) and `LIKE` elsewhere.
794
813
  - The query is escaped before interpolation — `%`, `_`, and `\` from user input are treated as literals, not wildcards.
795
814
  - Blank or nil queries return the relation unchanged, so it's safe to drop into a controller pipeline.
796
- - Reach for `pg_search` / Elasticsearch when you need ranking, stemming, or full-text indexes.
815
+ - `ranked:` uses `reorder`, so relevance leads until something reorders again. `Controllers::Sortable#sorted` and `CursorPaginatable` both reorder unconditionally, so chain `.search` **after** them (`paginated(sorted(Article.all).search(q))`), not before.
816
+ - Grouped relations are returned unranked (ORDER BY on a non-grouped column is a hard error on Postgres/MySQL), so `Article.group(:author_id).search(q).count` is safe; pass `ranked: false` when the `group`/`distinct` comes after the search. Under `mode: :all` the per-term scores are summed.
817
+ - Reach for `pg_search` / Elasticsearch when you need stemming, weighting by frequency, or full-text indexes.
797
818
 
798
819
  ---
799
820
 
@@ -1200,9 +1221,25 @@ article.sanitized_body # => "<b>Hi</b>alert(1)" (script tag
1200
1221
  | `Hash` | `{ tags: [...], attributes: [...] }` allow-list. |
1201
1222
  | `Proc` | Used as-is (you own the non-String guard). |
1202
1223
 
1224
+ **Serialization & clean-up**
1225
+
1226
+ ```ruby
1227
+ article.sanitized_attributes # => { "body" => "<b>Hi</b>alert(1)", "summary" => "sum" } — every declared field, cleaned
1228
+ article.as_json(sanitized: true) # declared fields swapped for their sanitized form, the rest raw
1229
+ render json: article.as_json(sanitized: [:body], only: %i[id body]) # subset; composes with only:/except:/methods:
1230
+ article.as_json(sanitized: true, include: :comments) # nested records are sanitized too
1231
+ article.as_json(sanitized: true, include: { comments: { sanitized: false } }) # …unless a child opts out
1232
+
1233
+ Article.sanitize_all! # repair on: :write rows in place → count changed (on: :read columns stay raw)
1234
+ Article.sanitize_all!(:body) # name an on: :read field to overwrite it — destroys the raw value
1235
+ Article.where(legacy: true).sanitize_all!(:body) # scope-aware, subset of fields
1236
+ ```
1237
+
1203
1238
  **Notes**
1204
1239
  - `on: :read` (default) is **non-destructive**: it adds a `sanitized_<field>` reader and leaves the stored column untouched.
1205
1240
  - `on: :write` overwrites the column in `before_validation` — **lossy and irreversible** (never use it on code, Markdown, math, or prices), and bypassed by `update_column` / `update_all` / raw SQL.
1241
+ - `sanitize_all!` is the repair tool for that bypass (and for rows written before the concern was added): one `update_columns` per row that actually changes, skipping validations/callbacks on purpose, inside a transaction. A bare call repairs the `on: :write` fields only — with none declared it returns `0` without a query.
1242
+ - `sanitized:` sanitizes the **serialized** value, so it composes with [Maskable](#-maskable) in either include order: `as_json(masked: true, sanitized: true)` never falls back to the raw column.
1206
1243
  - For full user-authored rich text, prefer [Action Text](https://guides.rubyonrails.org/action_text_overview.html).
1207
1244
 
1208
1245
  ---
@@ -1420,16 +1457,20 @@ account.notifications? # boolean keys get a predicate
1420
1457
  account.items_per_page_changed? # per-key dirty (and items_per_page_was)
1421
1458
  account.reset_theme # drop the key → the default applies again
1422
1459
  account.flag_beta # affixed accessor
1460
+
1461
+ Account.where_theme("dark") # one scope per key, cast like the writer — SQLite json_extract,
1462
+ Account.active.where_flag_beta(true).where_items_per_page(50) # PostgreSQL ->>, MySQL JSON_EXTRACT
1463
+ Account.where_theme(nil) # unset key, explicit null, or NULL column
1423
1464
  ```
1424
1465
 
1425
- **Options** (per key): `type:` (`:string` default, `:integer`, `:float`, `:decimal`, `:boolean`, `:date`, `:datetime`, `:json`), `default:` (a value, or a Proc `instance_exec`'d per read), `in:` (inclusion validation, errors on the accessor name). Macro options: `prefix:` / `suffix:` affix the generated method names (the collision escape hatch). The macro is repeatable — repeat calls for the same column merge keys, different columns are independent, and subclasses can add keys without affecting the parent.
1466
+ **Options** (per key): `type:` (`:string` default, `:integer`, `:float`, `:decimal`, `:boolean`, `:date`, `:datetime`, `:json`), `default:` (a value, or a Proc `instance_exec`'d per read), `in:` (inclusion validation, errors on the accessor name), `query:` (`false` skips this key's `where_` scope). Macro options: `prefix:` / `suffix:` affix the generated method names (the collision escape hatch), `query:` sets the default for every key in the call. The macro is repeatable — repeat calls for the same column merge keys, different columns are independent, and subclasses can add keys without affecting the parent.
1426
1467
 
1427
1468
  **Notes**
1428
1469
  - Works on a plain `text` column (JSON encoded/decoded internally), a native `json`/`jsonb` column, or a column the host app already `serialize`d — detected automatically. `serialize` itself is never used, so the Rails 7.1 API drift is irrelevant.
1429
1470
  - nil vs unset: a written `nil` (explicit JSON null) reads back as `nil` and does **not** fall back to the default; `reset_<key>` removes the key so the default applies again. `:decimal` is stored as a precision-safe string, `:date`/`:datetime` as ISO8601 (datetime in UTC at microsecond precision).
1430
1471
  - Writing one key dirties (and saves) the **whole column** — concurrent writers to different keys are last-write-wins on the hash. Undeclared keys are preserved. `:json` readers return a dup: reassign, don't mutate in place.
1431
1472
  - Generated names are collision-checked against existing methods and columns at macro time (`ArgumentError`; affix to escape). Read-side casting never raises — corrupt column JSON decodes as `{}`, garbage values cast to `nil`.
1432
- - Reach for [`store_attribute`](https://github.com/palkan/store_attribute) / [`jsonb_accessor`](https://github.com/madeintandem/jsonb_accessor) when you need to **query** into the store (jsonb operators, store-backed scopes).
1473
+ - **Querying**: every key gets a `where_<accessor>(value)` equality scope — `json_extract` on SQLite, `->>` on PostgreSQL (a `text` column is cast to `jsonb`), `JSON_UNQUOTE(JSON_EXTRACT())` on MySQL/MariaDB, which also gets a `JSON_TYPE` predicate so a stored JSON `null` is never confused with the string `"null"`. The value is cast exactly as the writer stores it (`where_items_per_page("50")` works; one that will not cast raises), and `where_<key>(nil)` matches an unset key, an explicit JSON null and a `NULL` column on all three. Defaults are **not** queryable (a never-written key is absent in the DB). `:json` keys and other adapters raise; `query: false` opts out, and a `where_<accessor>` the model already defines is left alone with a deprecation warning rather than overwritten. A row holding blank or corrupt JSON reads as an unset key on SQLite (`json_valid` guard) but aborts the whole query on PostgreSQL and MySQL — there is no portable guard. Reach for [`store_attribute`](https://github.com/palkan/store_attribute) / [`jsonb_accessor`](https://github.com/madeintandem/jsonb_accessor) for jsonb operators, ranges or containment queries.
1433
1474
 
1434
1475
  ---
1435
1476
 
@@ -1743,6 +1784,8 @@ class ArticlesController < ApplicationController
1743
1784
  include ConcernsOnRails::Controllers::Sortable
1744
1785
 
1745
1786
  sortable_by :created_at, :title, :published_at,
1787
+ author: { column: "authors.name", joins: :author }, # an association column
1788
+ price: { nulls: :last }, # NULLs after the values
1746
1789
  default: :created_at, direction: :desc
1747
1790
 
1748
1791
  def index
@@ -1751,11 +1794,14 @@ class ArticlesController < ApplicationController
1751
1794
  end
1752
1795
  ```
1753
1796
 
1754
- **URL params**: `?sort=title&direction=asc`
1797
+ **URL params**: `?sort=-created_at,title` or `?sort=title&direction=asc`
1755
1798
 
1756
- - `params[:sort]` selects the column; non-whitelisted values fall back to the declared default.
1757
- - `params[:direction]` accepts `asc` / `desc` (case-insensitive); invalid values fall back to the declared default direction.
1758
- - If no `default:` is given, the **first** declared field is used.
1799
+ - `params[:sort]` is a comma-separated list of sort **keys**, each optionally prefixed with `-` (descending) or `+` (ascending) — the JSON:API convention. A `+` must be percent-encoded as `%2B`, since a raw `+` in a query string decodes to a space. Non-whitelisted keys are dropped and a repeated key collapses to its first occurrence; when nothing valid remains the declared default applies.
1800
+ - Un-prefixed keys take `params[:direction]` (`asc` / `desc`, case-insensitive), then the declared default direction.
1801
+ - A plain Symbol sorts by that column of the relation's own table. A `key: { ... }` rule can point elsewhere: `column: "table.column"` plus `joins:` (anything `left_outer_joins` accepts — LEFT OUTER by default so rows without the association are kept; `join: :inner` drops them), and/or `nulls: :first | :last` (Rails 6.1+). Joins are added only when that key is requested.
1802
+ - `sorted` uses `reorder`, so the requested columns **replace** any prior `ORDER BY` (including a model `default_scope` order).
1803
+ - `nulls:` uses PostgreSQL's native `NULLS FIRST/LAST`; on every other adapter it emits the portable `CASE WHEN col IS NULL` equivalent, so the row order is the same and nothing sends MySQL syntax it rejects.
1804
+ - If no `default:` is given, the **first** declared key is used. A `default:` that is *not* in the allow-list is legal — it orders the relation but stays unselectable by clients.
1759
1805
 
1760
1806
  > Distinct from `Models::Sortable` (which manages list position via `acts_as_list`). Both can coexist on a model + its controller.
1761
1807
 
@@ -2037,25 +2083,35 @@ A declarative, **block-only** per-action authorization gate. Each rule is a pred
2037
2083
  class Api::BaseController < ApplicationController
2038
2084
  include ConcernsOnRails::Controllers::Authorizable
2039
2085
 
2040
- authorize_by { current_user.present? } # every action
2086
+ authorize_by(name: :signed_in) { current_user.present? } # every action
2041
2087
  authorize_by(only: %i[update destroy]) { |_action, user| user.admin? }
2042
2088
  require_role :admin, :editor, only: :publish # role sugar
2043
2089
  end
2090
+
2091
+ class Api::PagesController < Api::BaseController
2092
+ skip_authorization only: %i[index show] # public pages — even the inherited rules skip
2093
+ helper_method :authorized? # <%= link_to "Delete", ... if authorized?(:destroy) %>
2094
+ end
2044
2095
  ```
2045
2096
 
2046
2097
  The predicate runs via `instance_exec`, so `current_user` (and any helper) resolves on the controller. It is **arity-safe** — write it with zero, one (`|action|`), or two (`|action, user|`) parameters.
2047
2098
 
2048
2099
  **API**
2049
2100
 
2050
- | Method | Signature |
2051
- |----------------|--------------------------------------------------------------------------------------------|
2052
- | `authorize_by` | `authorize_by(only: nil, except: nil, status: :forbidden, message: "Forbidden", &block)` |
2053
- | `require_role` | `require_role(*roles, via: :current_user, role_method: :role, only:, except:, status:, message:)` |
2101
+ | Method | Signature |
2102
+ |---------------------------|--------------------------------------------------------------------------------------------|
2103
+ | `authorize_by` | `authorize_by(only: nil, except: nil, status: :forbidden, message: "Forbidden", name: nil, &block)` |
2104
+ | `require_role` | `require_role(*roles, via: :current_user, role_method: :role, only:, except:, status:, message:, name:)` |
2105
+ | `skip_authorization` | `skip_authorization(only: %i[index show])` / `skip_authorization(except: %i[destroy])` / bare — exempt actions from every rule, inherited ones included; bare form exempts all |
2106
+ | `authorized?` | `authorized?(action = action_name)` — evaluate the rules without rendering (for views / conditional UI) |
2107
+ | `on_authorization_denied` | `on_authorization_denied(rule)` — override point; instruments `authorization_denied.concerns_on_rails` (call `super` to keep the event) |
2054
2108
 
2055
2109
  **Notes**
2056
2110
  - Rules run in declaration order; the first failing rule renders and halts.
2111
+ - Every denial emits `authorization_denied.concerns_on_rails` with `controller`, `action`, `actor_id`, `actor_type`, `rule` (the `name:`), `status`, `message` — subscribe for audit logs or alerting on repeated denials. The actor is reduced to scalars on purpose: notification payloads aren't filtered by `config.filter_parameters`.
2057
2112
  - When `Respondable` is also included, denials delegate to `render_error` (envelope `{ success: false, error: { message:, code: "forbidden" } }`); otherwise the same envelope is rendered inline.
2058
2113
  - `only:` / `except:` are mutually exclusive (passing both raises `ArgumentError`); `authorize_by` requires a block and `require_role` requires at least one role.
2114
+ - `skip_authorization` validates its arguments at class-load time, because every mistake there fails **open**: a nil `only:`/`except:`, or an empty/non-action `except:` (`[]`, `false`, `""` — what `except: Rails.env.production? && :destroy` collapses to), raises instead of exempting every action of the controller *and its subclasses*. Only the bare form grants a blanket skip. The skip is inherited and outranks rules a subclass declares afterwards; `skip_authorization only: []` switches it back off.
2059
2115
  - **Non-goals**: no policy objects, no ability DSL, no resource inference — reach for [`pundit`](https://github.com/varvet/pundit) / [`cancancan`](https://github.com/CanCanCommunity/cancancan) when you outgrow a predicate per action.
2060
2116
 
2061
2117
  ---
@@ -2101,15 +2157,18 @@ class ApplicationController < ActionController::Base
2101
2157
 
2102
2158
  timezoneable available: ["UTC", "Eastern Time (US & Canada)"], default: "UTC"
2103
2159
  # timezoneable param: :tz, header: false, cookie: :time_zone
2160
+ # timezoneable cookie: :time_zone, persist: true # ?time_zone=London sticks for a year
2161
+ # timezoneable response_header: true # X-Time-Zone: London (+ Vary: Time-Zone)
2104
2162
  end
2105
2163
  ```
2106
2164
 
2107
2165
  Resolution order: `params[param]` → `Time-Zone` header → cookie (if enabled) → `default` → the current `Time.zone`. Every value — the configured `available:` / `default:` **and** each request candidate — is resolved through `ActiveSupport::TimeZone[...]`, so a zone accepted at boot can never be rejected at request time.
2108
2166
 
2109
- **Options**: `available:` (allow-list applied to param/header/cookie matching; `default:` bypasses it, mirroring Localizable), `default:`, `param:` (default `:time_zone`), `header:` (default `true`, reads the `Time-Zone` header), `cookie:` (default `false`; `true` reads the `:time_zone` cookie, or pass a cookie name).
2167
+ **Options**: `available:` (allow-list applied to param/header/cookie matching; `default:` bypasses it, mirroring Localizable), `default:`, `param:` (default `:time_zone`), `header:` (default `true`, reads the `Time-Zone` header), `cookie:` (default `false`; `true` reads the `:time_zone` cookie, or pass a cookie name), `persist:` (default `false`; `true` or a Hash of cookie options — writes a **param**-chosen zone into the `cookie:` so a settings link makes it stick; needs `cookie:`), `response_header:` (default `false`; `true` emits `X-Time-Zone`, or pass a header name — `Vary: Time-Zone` is appended when the header source is on).
2110
2168
 
2111
2169
  **Notes**
2112
- - An unknown `available:` / `default:` zone raises `ArgumentError` at declaration time (fail-fast on misconfiguration).
2170
+ - An unknown `available:` / `default:` zone raises `ArgumentError` at declaration time (fail-fast on misconfiguration); so does `persist:` without `cookie:`.
2171
+ - `time_zone_source` tells you which source won (`:param`, `:header`, `:cookie`, `:default`, `:current`) — handy for a "times shown in London (from your browser)" hint.
2113
2172
  - Pairs naturally with the model concerns that read the clock (`Schedulable`, `Publishable`, `Expirable`, `SoftDeletable`).
2114
2173
 
2115
2174
  ---
@@ -2224,6 +2283,7 @@ class Api::ArticlesController < ApplicationController
2224
2283
 
2225
2284
  http_cache_actions :index, :show, max_age: 5.minutes,
2226
2285
  visibility: :public, vary: "Accept"
2286
+ etag_with :locale # the body depends on I18n.locale → folded into the ETag, Vary: Accept-Language
2227
2287
 
2228
2288
  def show
2229
2289
  @article = Article.find(params[:id])
@@ -2234,7 +2294,7 @@ end
2234
2294
 
2235
2295
  # A matching response then carries:
2236
2296
  # Cache-Control: public, max-age=300
2237
- # Vary: Accept
2297
+ # Vary: Accept-Language, Accept
2238
2298
  # ETag: W/"…"
2239
2299
  # Last-Modified: Thu, 01 Jan 2026 12:00:00 GMT
2240
2300
  ```
@@ -2243,6 +2303,15 @@ end
2243
2303
 
2244
2304
  **Options** (`http_cache_actions *actions, …`, repeatable; no actions = catch-all; **last matching rule wins**): `visibility:` (`:private` default | `:public`), `max_age:` (Integer/Duration), `must_revalidate:`, `no_store:` (overrides everything → bare `no-store`), `stale_while_revalidate:`, `vary:` (String or Array, appended to any existing `Vary`).
2245
2305
 
2306
+ **ETag context** (`etag_with`, repeatable — the analogue of Rails' class-level `etag { }`): when the representation depends on more than the record — the locale, the requested fields, the caller's role — declare it and the values are folded into the ETag so two representations of one resource never share a validator. Sources are presets (`:locale` → also `Vary: Accept-Language`, `:format` → `Vary: Accept`, `:query`), Symbols naming controller methods, or a block (`instance_exec`'d); `vary:` overrides the implied header(s), `vary: false` suppresses them; nil values are ignored. Per call: `stale_resource?(@article, extras: [params[:fields]])`. An explicit `etag:` stays verbatim only when there is no context to fold in.
2307
+
2308
+ **A source with no `Vary` forces `private`.** A controller method, a block, or a preset with `vary: false` folds a dimension into the ETag that no cache can key on — `Vary` has no way to say *who is asking* — so the response is not shareable and `Cache-Control` is emitted as `private` whatever the rule declared. (`:query` is exempt: the URL already carries it.)
2309
+
2310
+ ```ruby
2311
+ http_cache_actions :show, max_age: 30, visibility: :public
2312
+ etag_with { current_user&.role } # => Cache-Control: private, max-age=30
2313
+ ```
2314
+
2246
2315
  **Conditional-GET correctness**
2247
2316
  - Weak ETag `W/"<md5>"` from the resource's cache key (collections fold their members' keys + size); `If-None-Match` is matched with **weak comparison**, honours `*`, and accepts a comma-separated list.
2248
2317
  - `Last-Modified` is an IMF-fixdate via `Time#httpdate` (not hand-rolled ISO 8601); `If-Modified-Since` is compared at whole-second granularity.
@@ -2335,7 +2404,7 @@ Both forms reference the same module, so you can freely mix them.
2335
2404
  | Complex state machines (callbacks, transition logging) | [`aasm`](https://github.com/aasm/aasm) |
2336
2405
  | Sentinel-aware unique indexes on soft-deleted rows (`deleted_at` in the index) | [`paranoia`](https://github.com/rubysherpas/paranoia) or [`discard`](https://github.com/jhawthorn/discard) |
2337
2406
  | Tagging with contexts, ownership, or tag clouds | [`acts-as-taggable-on`](https://github.com/mbleigh/acts-as-taggable-on) |
2338
- | Full-text search with ranking / stemming | [`pg_search`](https://github.com/Casecommons/pg_search) / Elasticsearch |
2407
+ | Indexed full-text search stemming, tsvector/GIN, typo tolerance (`Searchable` ranks LIKE matches, but never builds an index) | [`pg_search`](https://github.com/Casecommons/pg_search) / Elasticsearch |
2339
2408
  | Versioned audit trails with undo/reify, who-dunnit queries, or association tracking | [`paper_trail`](https://github.com/paper-trail-gem/paper_trail) / [`audited`](https://github.com/collectiveidea/audited) |
2340
2409
  | Field encryption with managed key rotation / Rails-native key infrastructure | [`lockbox`](https://github.com/ankane/lockbox) / Rails 7+ native `encrypts` |
2341
2410
  | Deep clone with per-attribute regex/prepend rules or belongs_to graph copying | [`amoeba`](https://github.com/amoeba-rb/amoeba) |
@@ -2367,9 +2436,9 @@ Point your agent at `llms.txt` for an overview, or paste a single concern's `.md
2367
2436
 
2368
2437
  ```sh
2369
2438
  bundle install # install dev dependencies
2370
- bundle exec rspec # run the test suite (1,624 examples)
2439
+ bundle exec rspec # run the test suite (1,730 examples)
2371
2440
  gem build concerns_on_rails.gemspec # build the gem
2372
- gem install ./concerns_on_rails-1.28.6.gem # install locally
2441
+ gem install ./concerns_on_rails-1.28.7.gem # install locally
2373
2442
 
2374
2443
  # Preview the docs site locally (GitHub Pages serves docs/ as-is):
2375
2444
  cd docs && python3 -m http.server 8000 # → http://localhost:8000