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 +4 -4
- data/CHANGELOG.md +134 -0
- data/README.md +99 -30
- data/lib/concerns_on_rails/controllers/authorizable.rb +152 -17
- data/lib/concerns_on_rails/controllers/cacheable.rb +141 -5
- data/lib/concerns_on_rails/controllers/error_handleable.rb +1 -0
- data/lib/concerns_on_rails/controllers/localizable.rb +2 -23
- data/lib/concerns_on_rails/controllers/sortable.rb +208 -26
- data/lib/concerns_on_rails/controllers/timezoneable.rb +84 -14
- data/lib/concerns_on_rails/models/normalizable.rb +100 -10
- data/lib/concerns_on_rails/models/sanitizable.rb +146 -1
- data/lib/concerns_on_rails/models/searchable.rb +87 -13
- data/lib/concerns_on_rails/models/storable.rb +250 -65
- data/lib/concerns_on_rails/support/vary_header.rb +54 -0
- data/lib/concerns_on_rails/version.rb +1 -1
- data/lib/concerns_on_rails.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5b713f3fd8cd14e5f3a12db7b3c390111118df1fa143d5d833dee009544a8a64
|
|
4
|
+
data.tar.gz: 16f6d5447ac1d44c5be3dc1cdb0113d9a9e64457de14b87d4199bb95993189d8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
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
|
|
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 :
|
|
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
|
|
746
|
-
|
|
747
|
-
| `:email`
|
|
748
|
-
| `:phone`
|
|
749
|
-
| `:whitespace
|
|
750
|
-
| `:squish`
|
|
751
|
-
| `:downcase`
|
|
752
|
-
| `:
|
|
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
|
-
- `
|
|
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
|
-
-
|
|
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)
|
|
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]`
|
|
1757
|
-
- `params[:direction]`
|
|
1758
|
-
-
|
|
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? }
|
|
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
|
|
2051
|
-
|
|
2052
|
-
| `authorize_by`
|
|
2053
|
-
| `require_role`
|
|
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
|
-
|
|
|
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,
|
|
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.
|
|
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
|