concerns_on_rails 1.32.0 → 1.32.1

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +109 -0
  3. data/README.md +22 -8
  4. data/lib/concerns_on_rails/controllers/cacheable.rb +18 -3
  5. data/lib/concerns_on_rails/controllers/cursor_paginatable.rb +73 -16
  6. data/lib/concerns_on_rails/controllers/filterable.rb +84 -22
  7. data/lib/concerns_on_rails/controllers/localizable.rb +20 -7
  8. data/lib/concerns_on_rails/controllers/paginatable.rb +5 -2
  9. data/lib/concerns_on_rails/controllers/throttleable.rb +20 -1
  10. data/lib/concerns_on_rails/controllers/timezoneable.rb +40 -6
  11. data/lib/concerns_on_rails/models/addressable.rb +64 -9
  12. data/lib/concerns_on_rails/models/auditable.rb +16 -1
  13. data/lib/concerns_on_rails/models/counter_cacheable.rb +123 -38
  14. data/lib/concerns_on_rails/models/encryptable.rb +99 -17
  15. data/lib/concerns_on_rails/models/lockable.rb +8 -1
  16. data/lib/concerns_on_rails/models/maskable.rb +10 -1
  17. data/lib/concerns_on_rails/models/monetizable.rb +22 -0
  18. data/lib/concerns_on_rails/models/sanitizable.rb +58 -3
  19. data/lib/concerns_on_rails/models/searchable.rb +7 -2
  20. data/lib/concerns_on_rails/models/sluggable.rb +28 -2
  21. data/lib/concerns_on_rails/models/soft_deletable.rb +34 -15
  22. data/lib/concerns_on_rails/models/sortable.rb +20 -0
  23. data/lib/concerns_on_rails/models/stateable.rb +8 -2
  24. data/lib/concerns_on_rails/models/storable.rb +9 -0
  25. data/lib/concerns_on_rails/models/taggable.rb +48 -2
  26. data/lib/concerns_on_rails/support/batch_ops.rb +6 -1
  27. data/lib/concerns_on_rails/support/encryptor.rb +11 -1
  28. data/lib/concerns_on_rails/support/generated_values.rb +2 -1
  29. data/lib/concerns_on_rails/support/link_header.rb +13 -1
  30. data/lib/concerns_on_rails/support/locking.rb +44 -2
  31. data/lib/concerns_on_rails/support/masker.rb +12 -4
  32. data/lib/concerns_on_rails/support/money.rb +13 -1
  33. data/lib/concerns_on_rails/support/scalar_param.rb +17 -7
  34. data/lib/concerns_on_rails/support/time_value.rb +14 -0
  35. data/lib/concerns_on_rails/version.rb +1 -1
  36. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a5638a56a20ce9b346dd013448af31656fe6925eaefbf8bd87ee8906c17825b4
4
- data.tar.gz: b3feeb0feaab7f14bf5d03ea46a9e7a1a69f0d087173cf2fb0baa1efb2c86daa
3
+ metadata.gz: 3e44cfe4621a22897c4d3f6e2ade5b1485d7c686f10dd72fb0dd582a50eadd73
4
+ data.tar.gz: 478bc126777b1627ffba7275f1f81421c0ce112b01e3348c30b4010a023af75c
5
5
  SHA512:
6
- metadata.gz: 25623c6d25c7fafc8e8c4dfd5f16350df059a5f0bb2999c0d457b986cfcff1c55365f8e072ab5dd01cb9ae48e6226ff335a546f02c1521ca0c3934898e92c296
7
- data.tar.gz: ad5dd6aa2c310c9d711fd9513e01488e79f164df54aa2accdbca8ab9a46f3c2fe296c0733c6b0c404c0df09147c28160f7c5177e0938b6637eb3037d71f1e363
6
+ metadata.gz: bf067b1d2068e59ed7007bddfc41e2b70a287a57e6e3727e0fba8653ef4d4c1f892e158d6e012b96fbfb862eb1a2a8e48c17d60acc0891c2b85fc43f9c8bb17f
7
+ data.tar.gz: 71db79d7dd7e673b111d20a89a99d2ea7a95cd41c13ff3ededd017f27ef10a4351d7689b7aebcf04a2c8aedebbc0cee5bb8d8f57e1ac96bec1aaf5f838852171
data/CHANGELOG.md CHANGED
@@ -1,5 +1,114 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.32.1 (2026-10-08)
4
+
5
+ A bug-fix release from a six-angle audit of 1.32.0 — data and encryption, state and counters,
6
+ scopes and batch verbs, field transforms, request params, HTTP policy. Every finding was
7
+ reproduced by a failing spec before it was fixed, and every fix ships with a regression spec;
8
+ the batch then went through an adversarial review round. No new concerns, no new options and
9
+ no migrations. A few fixes now raise where the gem used to misbehave silently — read
10
+ **Changed** first. 2827 examples, 0 failures (2795 on Rails 6.0).
11
+
12
+ ### Changed
13
+ - **Encryptable: Lockable's `unlock_token:` cannot be `encryptable`** (`ArgumentError` at
14
+ declaration, either order). The token is minted, claimed and cleared by callback-skipping
15
+ SQL keyed on the column value, so an encrypted token was never found: every unlock link was
16
+ dead. Keep it a plain column — it is random, single-use and cleared with the lock.
17
+ - **Monetizable: an `encryptable` cents column is refused** (`ArgumentError`, either order):
18
+ `sum_`/`average_`/`minimum_`/`maximum_` ran SQL aggregates over ciphertext.
19
+ - **Auditable: `audited_changes_since` reads its argument in `Time.zone`** — a `Date` is
20
+ midnight in `Time.zone`, a zone-less String is read in `Time.zone` (not the server's system
21
+ zone), and a String that names no time raises `ArgumentError` (`"junk"` used to mean June 1).
22
+ - **Encryptable: blind indexes refresh in `before_create`/`before_update`**, after every
23
+ `before_save`, instead of in an include-time `before_save`. An app `before_save` that rewrites
24
+ the field (`self.email = email.downcase`) is now fingerprinted as stored, and a new row always
25
+ refreshes every indexed field, so a copy (`dup`, Duplicable's `reset:`) no longer keeps its
26
+ original's digest. Rows written by such a callback before this release keep a stale digest
27
+ until re-saved with a change.
28
+
29
+ ### Fixed
30
+ - **Encryptable**: decrypted text reads back as UTF-8 (binary only when the bytes are not valid
31
+ UTF-8). Non-ASCII values compared unequal to what was written after a reload, were dirty on
32
+ every reassignment, and `reencrypt!` fingerprinted different bytes under a `downcase`
33
+ `expression:`, so rotated rows could no longer be found.
34
+ - **Encryptable**: an empty-String column default (`text null: false, default: ""`) reads as
35
+ `""` instead of raising `DecryptionError` on every new record.
36
+ - **Encryptable**: `find_by_<field>` / `where_<field>` / `<field>_fingerprint` run the lookup
37
+ value through the field's Normalizable rule and its Rails 7.1+ `normalizes`, as Rails' own
38
+ finders do.
39
+ - **Storable**: a `:float` key given `"Infinity"`/`"NaN"` casts to nil instead of raising
40
+ `JSON::GeneratorError` (a 500 from a form field).
41
+ - **SoftDeletable**: `without_deleted.really_destroy_all` (and any relation repeating the
42
+ default `deleted_at IS NULL`) no longer hard-deletes the trash, and `without_deleted.restore_all`
43
+ no longer restores it: only the default scope's own predicate is peeled off.
44
+ - **`*_all` batch verbs** (`Support::BatchOps`) roll the whole batch back inside a caller's
45
+ transaction too: the batch runs in its own savepoint, so a caller rescuing the
46
+ `RecordNotSaved` no longer commits the records written before the failing one.
47
+ - **Sluggable**: a record moved to another `scope:` value whose slug that scope already holds
48
+ rebuilds it against the new scope instead of duplicating it; a slug that is free there is kept.
49
+ - **Sortable**: the position acts_as_list assigns in `before_create` is reported through
50
+ `Support::GeneratedValues`, so an Auditable model tracking it records it on create.
51
+ - **CounterCacheable**: a save that changes a row's STI type (`becomes!`, `update!(type:)`)
52
+ judges the old state under the old type's rule and the new state under the new type's, and
53
+ destroy uses the persisted type's rule — the live counter no longer drifts from
54
+ `recount_counter_caches!`.
55
+ - **Stateable `lock: true` / Activatable `toggle_active!`**: the reload under the row lock keeps
56
+ a loaded `belongs_to` parent whose foreign key it didn't change, so CounterCacheable's mirror
57
+ reaches the caller's parent instead of leaving it to raise `StaleObjectError`.
58
+ - **Stateable**: `transition_all` no longer skips rows already in the target state when the
59
+ event's `from:` lists that state (a declared self-transition).
60
+ - **Lockable**: `attempts_remaining` returns the full `max_attempts` once a lock has lapsed
61
+ (`unlock_in`), instead of 0 on an unlocked account.
62
+ - **Sanitizable** `on: :write`, **Taggable** and **Addressable** also normalize in a prepended
63
+ `before_save` backstop (Normalizable's pattern), so `update_attribute` / `save(validate: false)`
64
+ no longer store raw markup, un-normalized tags or un-normalized address parts. A validated
65
+ save stores exactly what it stored before (the backstop never re-runs a rule on a value
66
+ validation already accepted).
67
+ - **Maskable / Sanitizable**: `as_json(masked: true)` / `as_json(sanitized: true)` carry the flag
68
+ through every nested `include:` level, so a grandchild reached through a model without the
69
+ concern is no longer serialized raw.
70
+ - **Masker `:email`** fails closed (full mask) unless the value is exactly one address, so a
71
+ second address in a list is no longer revealed.
72
+ - **Monetizable**: a `Rational` amount converts (`Rational(1999, 100)` → 1999 cents) instead of
73
+ being stored as nil.
74
+ - **Searchable / Taggable**: queries and tags are trimmed (and `mode: :all` split) on Unicode
75
+ whitespace — a no-break or ideographic space no longer becomes part of the LIKE pattern or a
76
+ distinct tag.
77
+ - **Filterable**: a nested-array param (`?name[][]=a&name[][]=b`) is ignored instead of raising
78
+ a `TypeError` 500; a JSON-body number on a date/datetime column — compared, in equality,
79
+ `in`/`not_in`, or handed to a `type: :date` lambda — matches nothing instead of being bound raw
80
+ (a 500 on PostgreSQL); a date/time operand longer than 128 characters no longer raises
81
+ `Date._parse`'s `ArgumentError` (a 500) on Rails 6.0–7.0 and is answered as 7.1+ answers it.
82
+ - **Paginatable**: a number where a nested page Hash belongs (`{"page": 2}` under
83
+ `style: :jsonapi`) falls back to the defaults instead of a `TypeError` 500.
84
+ - **CursorPaginatable**: a page ending on a stored ±Infinity/NaN float no longer raises
85
+ `JSON::GeneratorError` on every request — the boundary is minted as a string and walked
86
+ correctly on PostgreSQL and SQLite; a tampered number/boolean for a date/time boundary, or an
87
+ over-long date String on Rails 6.0–7.0, is an `InvalidCursor` (400), not a decoded position
88
+ or a 500.
89
+ - **Paginatable / CursorPaginatable**: `Link` header URLs are percent-encoded inside `<…>`, so a
90
+ crafted request path can no longer inject extra Link entries.
91
+ - **Throttleable**: the first-hit seed is written `raw: true` (to any store whose `write` takes
92
+ options), so a memcached `Rails.cache` on Rails 6.0–7.0 enforces the limit again (every later
93
+ `increment` returned nil and the 429 never fired).
94
+ - **Timezoneable**: a `Time-Zone` header or `time_zone` cookie that is not valid UTF-8 is ignored
95
+ instead of raising a 500 (persistently, for the cookie); the `available:` allow-list matches by
96
+ zone, not spelling, so `America/New_York` matches an allow-listed `Eastern Time (US & Canada)`.
97
+ - **Cacheable**: per-call `stale_resource?(r, extras: [...])` downgrades `Cache-Control` to
98
+ `private`, like a Vary-less `etag_with` source, so a CDN no longer serves one caller's
99
+ representation to the next.
100
+ - **Localizable**: `Accept-Language: pt-BR` (and `?locale=pt-br`) selects an available `:pt_BR`;
101
+ `-` and `_` are one separator (an exact spelling still wins).
102
+
103
+ ### Known issues (not fixed in this release)
104
+ - CounterCacheable: a parent destroyed inside the same rolled-back transaction keeps the
105
+ mirrored counter/lock_version in memory.
106
+ - Aliasable: eager-loading an alias together with its source in one query loads each child twice.
107
+ - Encryptable: on the pre-7.1 Marshal format, a record whose field has a per-field Proc `key:`
108
+ cannot be `Marshal.dump`ed (`Rails.cache`).
109
+ - CursorPaginatable: on PostgreSQL, a page ending on a timestamp stored as `'infinity'` now mints
110
+ its cursor (it used to be a 500), but the next page answers 400 — the walk cannot step past it.
111
+
3
112
  ## 1.32.0 (2026-10-07)
4
113
 
5
114
  A dependency release (#127): the `permittable` floor rises to `>= 0.8`, and the Permittable docs
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** — **2,723 RSpec examples** run against a real database on every CI build
151
+ - **Tested like an app, not a snippet** — **2,827 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
  ---
@@ -203,8 +203,22 @@ still wins over the gem-wide fallback.
203
203
  - **Rails**: 6.0 through 8.1
204
204
 
205
205
  Every one of those lines runs the full suite on every push — Rails 6.0, 6.1, 7.0, 7.1, 7.2,
206
- 8.0 and 8.1 on SQLite, plus PostgreSQL and MySQL, and Ruby 3.3/3.4 against the current
207
- lines. See [`.github/workflows/ci.yml`](.github/workflows/ci.yml).
206
+ 8.0 and 8.1 on SQLite, plus PostgreSQL and MySQL. Ruby 3.2, 3.3, 3.4 and 4.0 each run
207
+ against every one of those Rails lines, and a non-blocking ruby-head job gives early
208
+ warning of the next Ruby. See [`.github/workflows/ci.yml`](.github/workflows/ci.yml).
209
+
210
+ **Rails 6.0/6.1 on Ruby 3.4+** needs a few Gemfile lines that Rails 6.x itself never
211
+ declared. Ruby 3.4 and 4.0 moved these libraries out of the default gems, and without them
212
+ `require "active_record"` fails with `LoadError: cannot load such file -- mutex_m`:
213
+
214
+ ```ruby
215
+ gem "base64"
216
+ gem "bigdecimal"
217
+ gem "drb"
218
+ gem "mutex_m"
219
+ gem "benchmark" # Ruby 4.0+
220
+ gem "logger" # Ruby 4.0+
221
+ ```
208
222
 
209
223
  **Rails 5.x is not supported**, despite what older versions of this gem declared. Rails 5
210
224
  cannot run on any Ruby this gem supports: Ruby 3 separated keyword arguments, which breaks
@@ -1257,7 +1271,7 @@ end
1257
1271
  | `fingerprint:` | `nil` | A `string` column to store `address_fingerprint` in (stamped in `before_validation`, after normalization) so duplicates are one indexed query away: `Location.with_address(record)`. |
1258
1272
  | `if:` / `unless:` | `nil` | Standard Rails validation conditions (Symbol, Proc, or Array) gating the address **validations** — e.g. `if: :on_addresses?`. Normalization still runs unconditionally. Per class: a later `addressable_by` (or a subclass's) replaces the condition. Procs get Rails' callback arguments for their arity (`-> {}`, `->(record) {}`, `->(record, _) {}`). |
1259
1273
 
1260
- **What it normalizes** (in `before_validation`)
1274
+ **What it normalizes** (in `before_validation`, plus a `before_save` backstop for saves that skip validation)
1261
1275
  - Text parts: `strip` + `squish`.
1262
1276
  - `postal_code`: squish + upcase, with canonical spacing for CA (`A1A1A1` → `A1A 1A1`).
1263
1277
  - `country` / `state`: upcased when they look like a 2-letter code (full names left alone). With `normalize_country: true`, a recognized ISO country name or alpha-3 code is canonicalized to its alpha-2 (`"Canada"`/`"CAN"` → `"CA"`); unrecognized values are left as-is.
@@ -1335,7 +1349,7 @@ Article.normalize_tags! # repair rows written around the
1335
1349
  **Notes**
1336
1350
  - Matching is **boundary-safe** — searching `rail` does not match `rails`. An explicit SQL `ESCAPE` clause makes tags containing `_` / `%` match literally on every adapter.
1337
1351
  - `tagged_with` matches **case-insensitively on every adapter** — `LIKE` on SQLite and MySQL, `ILIKE` on PostgreSQL — so one call means one thing everywhere (how non-ASCII characters fold is still the database collation's business). The Ruby-side helpers (`tagged_with?`, `all_tags`, `tag_counts`) compare exactly, so `downcase: true` — which folds on write — is what makes the scope and the helpers agree.
1338
- - Tags are normalized in `before_validation`, so a direct `record.tags = "a, b"` assignment is cleaned too. An empty list stores `NULL`.
1352
+ - Tags are normalized in `before_validation` (plus a `before_save` backstop for saves that skip validation), so a direct `record.tags = "a, b"` assignment is cleaned too. An empty list stores `NULL`.
1339
1353
  - An [Encryptable](#-encryptable) tag column holds ciphertext, which `tagged_with`'s `LIKE` never matches — declaring it raises `ArgumentError` at declaration, in either order (an undeclared default `:tags` column is refused when `tagged_with` is called).
1340
1354
  - `tagged_with` matches the **normalized** column form (`"ruby,rails"`) — which every write through the model produces. A row written around the callbacks (`update_column`, `update_all`, raw SQL, an import) as `"ruby, rails"` is found by `tagged_with?` but not by `tagged_with`; run `Model.normalize_tags!` (relation-aware, one `update_columns` per changed row in a transaction, no validations/callbacks, returns the count; `unscoped` to include default-scoped-away rows) to rewrite such rows.
1341
1355
  - `tag_counts` runs one `GROUP BY` on the raw column — identical tag strings ship once with their row count and are split in Ruby — so it scales with distinct tag strings, not rows; ordered by count desc then name, `limit:` keeps the top N.
@@ -1394,7 +1408,7 @@ Article.where(legacy: true).sanitize_all!(:body) # scope-aware, subset of fiel
1394
1408
 
1395
1409
  **Notes**
1396
1410
  - `on: :read` (default) is **non-destructive**: it adds a `sanitized_<field>` reader and leaves the stored column untouched.
1397
- - `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.
1411
+ - `on: :write` overwrites the column in `before_validation` (plus a `before_save` backstop for `update_attribute` / `save(validate: false)`) — **lossy and irreversible** (never use it on code, Markdown, math, or prices), and bypassed only by `update_column` / `update_all` / raw SQL.
1398
1412
  - `with: :strip, on: :write` stores **plain text**, not HTML-escaped text: `"<b>Tom</b> & Jerry"` is stored as `"Tom & Jerry"` (so `<%= %>` shows it once, not as `Tom &amp;amp; Jerry`). `&lt;` / `&gt;` are deliberately kept encoded — the stored value can never become markup, even rendered with `raw` — so a typed `a < b` is stored as `a &lt; b`. An `&amp;` that would read back as a character reference (a literal `&amp;copy;`) also stays encoded (a static, linear-time rule: `&` followed by `#`, a `name;`, or an HTML5 semicolon-less legacy name), so re-saving and `sanitize_all!` are idempotent. `sanitize_all!` uses the same plain-text form, and repairs rows stored double-escaped by earlier versions. The `on: :read` reader and `sanitized:` serialization still return HTML-escaped text.
1399
1413
  - `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.
1400
1414
  - `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.
@@ -2708,9 +2722,9 @@ Point your agent at `llms.txt` for an overview, or paste a single concern's `.md
2708
2722
 
2709
2723
  ```sh
2710
2724
  bundle install # install dev dependencies
2711
- bundle exec rspec # run the test suite (2,723 examples)
2725
+ bundle exec rspec # run the test suite (2,827 examples)
2712
2726
  gem build concerns_on_rails.gemspec # build the gem
2713
- gem install ./concerns_on_rails-1.32.0.gem # install locally
2727
+ gem install ./concerns_on_rails-1.32.1.gem # install locally
2714
2728
 
2715
2729
  # Preview the docs site locally (GitHub Pages serves docs/ as-is):
2716
2730
  cd docs && python3 -m http.server 8000 # → http://localhost:8000
@@ -64,7 +64,8 @@ module ConcernsOnRails
64
64
  # method, a block, or a preset with `vary: false` — makes the response
65
65
  # unshareable, so `Cache-Control` is emitted as `private` however the
66
66
  # rule declared its `visibility:`. `:query` is exempt: the URL already
67
- # carries it.
67
+ # carries it. Per-call `extras:` are such a source too (they carry no
68
+ # Vary), so a request that passes any is `private` as well.
68
69
  #
69
70
  # Notes:
70
71
  # * `no_store: true` overrides everything (emits the lone `no-store`),
@@ -279,10 +280,13 @@ module ConcernsOnRails
279
280
  # the computed { etag:, last_modified: } pair. Class-level `etag_with`
280
281
  # values and per-call `extras:` are folded into the ETag (an explicit
281
282
  # `etag:` stays verbatim only when there are none), and the sources' Vary
282
- # headers are merged into the response.
283
+ # headers are merged into the response. Per-call extras advertise no Vary,
284
+ # so — exactly like a Vary-less etag_with source — they make this
285
+ # response `private` (see http_cache_visibility_for).
283
286
  def set_cache_validators(resource = nil, etag: nil, last_modified: nil, extras: nil)
284
287
  etag ||= cache_etag_for(resource) unless resource.nil?
285
288
  last_modified ||= cache_last_modified_for(resource) unless resource.nil?
289
+ http_cache_note_request_extras(extras)
286
290
  context = cache_etag_extras(extras)
287
291
  etag = http_cache_combine_etag(etag, context) if etag && context.any?
288
292
  http_cache_write_validators(etag, last_modified)
@@ -378,7 +382,10 @@ module ConcernsOnRails
378
382
  # `vary: false` — is a dimension no cache can key on (`Vary` has no way to
379
383
  # say "who is asking"), so the representation is simply not shareable and
380
384
  # the declared `visibility:` is downgraded rather than trusted. `:query`
381
- # is exempt: the query string is already part of the cache key.
385
+ # is exempt: the query string is already part of the cache key. Per-call
386
+ # `extras:` (`stale_resource?(r, extras: [current_user.role])`) are the
387
+ # same kind of dimension and used to keep `public`, so a CDN keyed on the
388
+ # URL handed one caller's representation to the next.
382
389
  def http_cache_visibility_for(rule)
383
390
  return "private" if http_cache_unshareable_etag?
384
391
 
@@ -386,9 +393,17 @@ module ConcernsOnRails
386
393
  end
387
394
 
388
395
  def http_cache_unshareable_etag?
396
+ return true if @http_cache_request_extras
397
+
389
398
  self.class.cacheable_etag_extras.any? { |entry| entry[:vary].empty? && entry[:source] != :query }
390
399
  end
391
400
 
401
+ # Any per-call extra counts, a nil one included — just as a block source
402
+ # downgrades whatever it returns: the dimension exists either way.
403
+ def http_cache_note_request_extras(extras)
404
+ @http_cache_request_extras = true unless Array(extras).empty?
405
+ end
406
+
392
407
  # Previously a hand-rolled, case-SENSITIVE merge that also appended to
393
408
  # `Vary: *` — and, run from set_cache_validators before render, it left
394
409
  # the header non-blank so Rails skipped its own `Vary: Accept`.
@@ -558,11 +558,15 @@ module ConcernsOnRails
558
558
  # redefined #is_a? returns true for Time). iso8601(6) keeps microsecond
559
559
  # precision so boundary equality survives the round trip. The UTC form is
560
560
  # a copy: `to_time.utc` converted the boundary record's own attribute in
561
- # place (TimeWithZone#to_time is memoized).
561
+ # place (TimeWithZone#to_time is memoized). A non-finite Float — a
562
+ # stored ±Infinity or NaN (see cursor_non_finite_minted?) — is written
563
+ # as the String ActiveModel's Float type casts back ("Infinity",
564
+ # "-Infinity", "NaN"): JSON has no such number, and JSON.generate raised
565
+ # on it, so one such row on a page boundary made that page a 500.
562
566
  def serialize_cursor_value(value)
563
567
  return ConcernsOnRails::Support::TimeValue.utc(value).iso8601(6) if value.is_a?(Time) || value.is_a?(DateTime)
564
568
  return value.iso8601 if value.is_a?(Date)
565
- return value.to_s if value.is_a?(BigDecimal)
569
+ return value.to_s if value.is_a?(BigDecimal) || (value.is_a?(Float) && !value.finite?)
566
570
 
567
571
  value
568
572
  end
@@ -596,24 +600,38 @@ module ConcernsOnRails
596
600
  # drops unboundable values to 1=0) and a RangeError 500 on 6.0. A
597
601
  # timestamp outside years 0001..9999 casts fine too, then raised
598
602
  # DatetimeFieldOverflow on PostgreSQL (a 500) and compared as text on
599
- # SQLite. We never mint any of them, so each is tampering and gets the
603
+ # SQLite. So did a number or `true` for a datetime boundary: the date
604
+ # and time casts hand a non-String back unchanged, and
605
+ # `(created_at, id) > (12345, 3)` restarted the walk on SQLite (it sorts
606
+ # any number before a date's text) and was a 500 on PostgreSQL. We
607
+ # never mint any of them, so each is tampering and gets the
600
608
  # InvalidCursor 400.
601
609
  def cast_cursor_value!(model, col, raw)
602
610
  return nil if raw.nil? # a NULL boundary; valid_cursor_values? only admits it on a nullable column
603
611
 
604
612
  type = model.type_for_attribute(col.to_s)
605
- value = type.cast(raw)
606
- raise InvalidCursor, "Invalid pagination cursor." unless cursor_bindable?(type, value)
613
+ value = cursor_type_cast(type, raw)
614
+ raise InvalidCursor, "Invalid pagination cursor." unless cursor_bindable?(model, type, value)
607
615
 
608
616
  value
609
617
  end
610
618
 
619
+ # Rails 6.0–7.0's date and time casts raise Date._parse's ArgumentError
620
+ # for a String over 128 characters (7.1 casts it to nil): a tampered
621
+ # date boundary was a 500 instead of the 400 below. Any other type's
622
+ # ArgumentError is left alone.
623
+ def cursor_type_cast(type, raw)
624
+ type.cast(raw)
625
+ rescue ArgumentError
626
+ raise unless ConcernsOnRails::Support::TimeValue.temporal_type?(type)
627
+
628
+ raise InvalidCursor, "Invalid pagination cursor."
629
+ end
630
+
611
631
  # `serializable?` is the Rails 6.1+ range probe; on 6.0 only serialize
612
- # itself knows, raising ActiveModel::RangeError (a ::RangeError). A
613
- # non-finite Float/BigDecimal is refused outright: it is never minted
614
- # and adapters disagree on how (or whether) to quote it.
615
- def cursor_bindable?(type, value)
616
- return false if cursor_never_minted?(value)
632
+ # itself knows, raising ActiveModel::RangeError (a ::RangeError).
633
+ def cursor_bindable?(model, type, value)
634
+ return false if cursor_never_minted?(model, type, value)
617
635
  return type.serializable?(value) if type.respond_to?(:serializable?)
618
636
 
619
637
  type.serialize(value)
@@ -622,12 +640,37 @@ module ConcernsOnRails
622
640
  false
623
641
  end
624
642
 
625
- # nil, a non-finite Float/BigDecimal, or a date/time outside
626
- # Support::TimeValue::YEARS: none of them is ever minted.
627
- def cursor_never_minted?(value)
628
- value.nil? ||
629
- ((value.is_a?(Float) || value.is_a?(BigDecimal)) && !value.finite?) ||
630
- !ConcernsOnRails::Support::TimeValue.representable?(value)
643
+ # nil, a non-finite Float/BigDecimal (except where one is minted, see
644
+ # cursor_non_finite_minted?), anything but a date or time on a date/time
645
+ # column, or a date/time outside Support::TimeValue::YEARS: none of them
646
+ # is ever minted.
647
+ def cursor_never_minted?(model, type, value)
648
+ return true if value.nil?
649
+ return !cursor_non_finite_minted?(model, type, value) if value.is_a?(Float) && !value.finite?
650
+ return true if value.is_a?(BigDecimal) && !value.finite?
651
+
652
+ !cursor_time_minted?(type, value)
653
+ end
654
+
655
+ # A date/time column's boundary is a date or time, and any date or time
656
+ # lies within Support::TimeValue::YEARS.
657
+ def cursor_time_minted?(type, value)
658
+ time_value = ConcernsOnRails::Support::TimeValue
659
+ return false if time_value.temporal_type?(type) && !time_value.temporal?(value)
660
+
661
+ time_value.representable?(value)
662
+ end
663
+
664
+ # A float column can hold a non-finite boundary on PostgreSQL (±Infinity
665
+ # and NaN) and SQLite (±Infinity; it stores NaN as NULL). Anywhere else
666
+ # — another column type, NaN on SQLite, MySQL, which stores none — it is
667
+ # tampering, and refused outright: adapters disagree on how (or
668
+ # whether) to quote one.
669
+ def cursor_non_finite_minted?(model, type, value)
670
+ return false unless type.type == :float
671
+
672
+ adapter = model.connection.adapter_name
673
+ adapter.match?(/postgres/i) || (adapter.match?(/sqlite/i) && !value.nan?)
631
674
  end
632
675
 
633
676
  # Pre-bidirectional cursors carry no "d" — they are forward cursors and
@@ -702,6 +745,7 @@ module ConcernsOnRails
702
745
  # tuple cannot say "NULLs last" (`(c, id) > (v, x)` is never TRUE for a
703
746
  # NULL c), so :auto AND an explicit :row fall back to it.
704
747
  def cursor_predicate(model, pairs, values, nullable: [], backward: false)
748
+ values = values.map { |value| cursor_sql_value(model, value) }
705
749
  if nullable.empty? && cursor_row_predicate?(model, pairs)
706
750
  cursor_row_predicate(model, pairs, values)
707
751
  else
@@ -709,6 +753,19 @@ module ConcernsOnRails
709
753
  end
710
754
  end
711
755
 
756
+ # A boundary as the WHERE binds it. Only an infinite float on SQLite
757
+ # differs: SQLite reads no quoted literal as infinity — Rails before 7.2
758
+ # quote Float::INFINITY as the bare word Infinity (no such column, a
759
+ # 500), later ones as the TEXT 'Infinity', which SQLite sorts after
760
+ # every number, so the boundary row matched again and the walk never
761
+ # ended — but it reads a literal past the largest double as one.
762
+ # PostgreSQL reads its quoted 'Infinity' / 'NaN' as float8.
763
+ def cursor_sql_value(model, value)
764
+ return value unless value.is_a?(Float) && value.infinite? && model.connection.adapter_name.match?(/sqlite/i)
765
+
766
+ Arel.sql(value.positive? ? "9e999" : "-9e999")
767
+ end
768
+
712
769
  # :auto picks :row when it is expressible (>= 2 uniform-direction
713
770
  # columns) and the adapter supports tuples, silently falling back
714
771
  # otherwise; an explicit :row raises on mixed directions instead of
@@ -186,12 +186,22 @@ module ConcernsOnRails
186
186
  return options[:with].call(relation, type ? type.cast(value) : value) unless type
187
187
 
188
188
  operand = ConcernsOnRails::Support::NumericOperand.classify(value, type, column_type: nil)
189
- return options[:with].call(relation, type.cast(value)) if operand.nil?
189
+ return options[:with].call(relation, filterable_lambda_cast(type, value)) if operand.nil?
190
190
  return relation.none unless operand.status == :exact
191
191
 
192
192
  options[:with].call(relation, operand.value)
193
193
  end
194
194
 
195
+ # What the type answers for any other String it cannot read: nil — also
196
+ # for the one Rails 6.0–7.0 raised on (filterable_type_cast).
197
+ # A date/time `type:` hands over nil, too, for an operand that is no
198
+ # date or time (a JSON body's Integer, which the date cast returns as
199
+ # is): the lambda would bind it raw, exactly the comparison bug.
200
+ def filterable_lambda_cast(type, value)
201
+ cast = filterable_type_cast(type, value)
202
+ filterable_uncastable?(type, value, cast) ? nil : cast
203
+ end
204
+
195
205
  # ?price[gte]=10&price[lte]=50 — unknown keys are ignored.
196
206
  def apply_filter_operator_hash(relation, field, hash, options)
197
207
  hash.each do |key, raw|
@@ -333,7 +343,7 @@ module ConcernsOnRails
333
343
  # list left empty matches nothing (`in`) or every non-NULL row
334
344
  # (`not_in`, exactly what `!=` / NOT IN would answer).
335
345
  def apply_filter_equality(relation, field, value, negate: false)
336
- kept = filterable_equality_values(relation, field, value.is_a?(Array) ? value : [value])
346
+ kept = filterable_equality_values(relation, field, value.is_a?(Array) ? value : [value], negate: negate)
337
347
  return filterable_where(relation, field, value, negate) if kept.nil?
338
348
  return relation.none if kept == :uncastable
339
349
  return negate ? relation.where.not(field => nil) : relation.none if kept.empty?
@@ -361,28 +371,51 @@ module ConcernsOnRails
361
371
 
362
372
  # The members to keep on a numeric or date/time column, or nil to keep
363
373
  # the raw value unchanged.
364
- def filterable_equality_values(relation, field, values)
374
+ def filterable_equality_values(relation, field, values, negate: false)
365
375
  filterable_numeric_equality_values(relation, field, values) ||
366
- filterable_time_equality_values(relation, field, values)
367
- end
368
-
369
- # A date or time no stored value can equal (outside TimeValue::YEARS)
370
- # drops out of the list, like an out-of-range number. On PostgreSQL it
371
- # used to raise (a 500). nil when no member is out of range, so the
372
- # caller keeps its unchanged path. A member that casts to no date or time
373
- # stays exactly as it was.
374
- def filterable_time_equality_values(relation, field, values)
376
+ filterable_time_equality_values(relation, field, values, negate)
377
+ end
378
+
379
+ # A date or time no stored value can equal drops out of the list, like
380
+ # an out-of-range number: one outside TimeValue::YEARS (on PostgreSQL it
381
+ # used to raise, a 500). A value the cast hands back unchanged — no
382
+ # date or time at all (a JSON body's Integer: a 500 on PostgreSQL, a
383
+ # coerced date on MySQL) — fails the whole filter closed (:uncastable),
384
+ # as an uncastable number does. A String Rails 6.0–7.0 cannot parse
385
+ # (filterable_type_cast; `where`'s own cast raised a 500) is answered as
386
+ # 7.1+ answers it, casting it to nil: it equals nothing (dropped from
387
+ # `=`/`in`), and `!=`/NOT IN against NULL matches nothing (`not`/`not_in`
388
+ # fail closed). nil when no member drops, so the caller keeps its
389
+ # unchanged path. A String that casts to nil stays as it was.
390
+ def filterable_time_equality_values(relation, field, values, negate)
375
391
  column_type = filterable_column_type(relation, field)
376
392
  return nil unless column_type
377
393
 
378
- kept = values.reject { |member| filterable_time_beyond(filterable_equality_cast(column_type, member)) }
394
+ casts = values.map { |member| [member, filterable_equality_cast(column_type, member)] }
395
+ return :uncastable if filterable_any_time_uncastable?(column_type, casts, negate)
396
+
397
+ kept = casts.reject { |_member, cast| filterable_time_never_equal?(cast) }.map(&:first)
379
398
  kept.size == values.size ? nil : kept
380
399
  end
381
400
 
401
+ def filterable_time_never_equal?(cast)
402
+ cast.equal?(UNCASTABLE) || filterable_time_beyond(cast)
403
+ end
404
+
405
+ def filterable_any_time_uncastable?(column_type, casts, negate)
406
+ casts.any? do |member, cast|
407
+ next negate if cast.equal?(UNCASTABLE)
408
+ next false if member.nil? || cast.nil?
409
+
410
+ filterable_uncastable?(column_type, member, cast)
411
+ end
412
+ end
413
+
382
414
  # Only to classify the member: `where` still casts the raw value itself,
383
- # so a type whose cast raises is left to fail (or not) exactly as before.
415
+ # so a type whose cast raises is left to fail (or not) exactly as before
416
+ # — except the date/time cast filterable_type_cast answers UNCASTABLE for.
384
417
  def filterable_equality_cast(column_type, member)
385
- column_type.cast(member)
418
+ filterable_type_cast(column_type, member)
386
419
  rescue StandardError
387
420
  nil
388
421
  end
@@ -457,9 +490,23 @@ module ConcernsOnRails
457
490
  def filter_cast(relation, field, value, options)
458
491
  type = options[:type] || filterable_column_type(relation, field)
459
492
  return value if type.nil?
460
- return UNCASTABLE if filterable_uncastable?(type, value)
493
+ return UNCASTABLE if filterable_non_numeric?(type, value)
494
+
495
+ cast = filterable_type_cast(type, value)
496
+ filterable_uncastable?(type, value, cast) ? UNCASTABLE : cast
497
+ end
461
498
 
499
+ # type.cast, or UNCASTABLE for a String a date/time type cannot parse on
500
+ # Rails 6.0–7.0: Date._parse refuses one over 128 characters with an
501
+ # ArgumentError, which their date and time casts only rescue (to nil)
502
+ # from 7.1 on, so `?happened_on_gte=<129 characters>` was a 500. Any
503
+ # other type's ArgumentError is left alone.
504
+ def filterable_type_cast(type, value)
462
505
  type.cast(value)
506
+ rescue ArgumentError
507
+ raise unless ConcernsOnRails::Support::TimeValue.temporal_type?(type)
508
+
509
+ UNCASTABLE
463
510
  end
464
511
 
465
512
  # PostgreSQL's Column answers `array` (its sql_type drops the "[]", so
@@ -484,20 +531,35 @@ module ConcernsOnRails
484
531
 
485
532
  # Numeric types are checked against the string BEFORE casting, because
486
533
  # `Integer#cast`/`Decimal#cast` answer 0 for any non-numeric string rather
487
- # than nil — the cast result alone cannot tell "0" from "twelve". Every
488
- # other type reports the failure by casting to nil (blank values never get
489
- # this far; `apply_filter_operator` skipped them).
490
- def filterable_uncastable?(type, value)
491
- return type.cast(value).nil? unless NUMERIC_TYPES.include?(type.type)
534
+ # than nil — the cast result alone cannot tell "0" from "twelve".
535
+ def filterable_non_numeric?(type, value)
536
+ return false unless NUMERIC_TYPES.include?(type.type)
492
537
  return false if value.is_a?(Numeric)
493
538
 
494
539
  !NUMERIC_STRING.match?(value.to_s)
495
540
  end
496
541
 
542
+ # Every other type reports the failure by casting to nil (blank values
543
+ # never get this far; `apply_filter_operator` skipped them) — except the
544
+ # date/time types, whose casts return a non-String they cannot read
545
+ # unchanged: a JSON body's `{"happened_on_gte": 12345}` bound
546
+ # `happened_on >= 12345`, which SQLite answered with every row (it sorts
547
+ # any number before a date's text) and PostgreSQL with a 500. Their cast
548
+ # must be a date or time, or the ±Float::INFINITY PostgreSQL's own types
549
+ # read "infinity" as — which only a String can ask for.
550
+ def filterable_uncastable?(type, value, cast)
551
+ return true if cast.nil? || cast.equal?(UNCASTABLE)
552
+ return false unless ConcernsOnRails::Support::TimeValue.temporal_type?(type)
553
+ return false if ConcernsOnRails::Support::TimeValue.temporal?(cast)
554
+
555
+ !(value.is_a?(String) && cast.is_a?(Float) && cast.infinite?)
556
+ end
557
+
497
558
  # Scalars (and arrays of scalars, which AR turns into `IN (...)`) are safe
498
559
  # to pass to .where; a Hash / ActionController::Parameters is not — and
499
560
  # since 1.22 neither is an array CONTAINING one (`?status[][x]=1` used to
500
- # slip through as [Parameters] and 500 with a TypeError).
561
+ # slip through as [Parameters] and 500 with a TypeError), nor, now, an
562
+ # array of arrays (`?status[][]=a&status[][]=b`, the same TypeError).
501
563
  def filterable_scalar?(value)
502
564
  ConcernsOnRails::Support::ScalarParam.where_safe?(value)
503
565
  end
@@ -142,11 +142,11 @@ module ConcernsOnRails
142
142
 
143
143
  def parse_accept_language(header, allowed)
144
144
  ranked_accept_languages(header).each do |lang|
145
- # Full tag first (fr-CA matches an available :"fr-CA"), then the
146
- # primary subtag (fr). Pre-1.22 the region was discarded outright,
147
- # so a regional locale in available: could never match its own
148
- # Accept-Language tag.
149
- match = match_locale(lang, allowed) || match_locale(lang.split("-").first, allowed)
145
+ # Full tag first (fr-CA matches an available :"fr-CA" or :fr_CA),
146
+ # then the primary subtag (fr). Pre-1.22 the region was discarded
147
+ # outright, so a regional locale in available: could never match its
148
+ # own Accept-Language tag.
149
+ match = match_locale(lang, allowed) || match_locale(lang.split(/[-_]/).first, allowed)
150
150
  return match if match
151
151
  end
152
152
  nil
@@ -184,11 +184,24 @@ module ConcernsOnRails
184
184
  QVALUE.match?(weight) ? weight.to_f : 0.0
185
185
  end
186
186
 
187
+ # Case-insensitive, with `_` and `-` as ONE separator: Accept-Language
188
+ # tags are BCP 47 (`pt-BR`) while Rails locales are often `:pt_BR`. The
189
+ # concern's own Content-Language already writes one as the other, yet a
190
+ # client sending that tag back fell through to `pt` or the default. The
191
+ # app's own symbol is what comes back.
192
+ # An exact (case-insensitive) spelling wins, so an app offering both
193
+ # :pt_BR and :"pt-BR" gets the one the client named.
187
194
  def match_locale(candidate, allowed)
188
195
  return nil if candidate.blank?
189
196
 
190
- wanted = candidate.to_s.downcase
191
- allowed.find { |loc| loc.to_s.downcase == wanted }
197
+ exact = candidate.to_s.downcase
198
+ wanted = locale_match_key(candidate)
199
+ allowed.find { |loc| loc.to_s.downcase == exact } ||
200
+ allowed.find { |loc| locale_match_key(loc) == wanted }
201
+ end
202
+
203
+ def locale_match_key(locale)
204
+ locale.to_s.downcase.tr("_", "-")
192
205
  end
193
206
  end
194
207
  end
@@ -377,10 +377,13 @@ module ConcernsOnRails
377
377
 
378
378
  # Dig the configured path out of params: `["page"]` → params[:page];
379
379
  # `["page", "number"]` → params[:page][:number]. A scalar where a Hash
380
- # is expected yields nil (→ the default), like any other garbage.
380
+ # is expected yields nil (→ the default), like any other garbage. Only
381
+ # a Hash / Parameters is dug into: a JSON body's `{"page": 2}` under
382
+ # `style: :jsonapi` reached `2["number"]` (Integer#[] reads a bit) and
383
+ # raised TypeError, a 500.
381
384
  def pagination_param(path)
382
385
  path.reduce(params) do |node, key|
383
- break nil unless node.respond_to?(:[]) && !node.is_a?(String) && !node.is_a?(Array)
386
+ break nil unless ConcernsOnRails::Support::ScalarParam.hash_like?(node)
384
387
 
385
388
  node[key]
386
389
  end