concerns_on_rails 1.31.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 +129 -0
  3. data/README.md +44 -19
  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 +12 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 493ce0d913905625ffaabf033052a0ccc631aa22fe3c9a06a04c776ad4a80f67
4
- data.tar.gz: 022bb7a0230e1c9c8ebc210c43245cf458c9248f3810f24f02c731695c0c046d
3
+ metadata.gz: 3e44cfe4621a22897c4d3f6e2ade5b1485d7c686f10dd72fb0dd582a50eadd73
4
+ data.tar.gz: 478bc126777b1627ffba7275f1f81421c0ce112b01e3348c30b4010a023af75c
5
5
  SHA512:
6
- metadata.gz: 55cd86cfb3b04af3972060c2d4455dca7fe2ee7abfdccfa029c75ee6a9caee4b6700eeb128dd3f15aec5c52460af7ca5936935185593788d02df9c050044e3fa
7
- data.tar.gz: 750dda120eb24d9e3d121a8e4ec1828e2d26303677f5758bc415f138fa4497845e1b67219203dc593ad5fe55a1a406e77d544b292f1639d4aa56546bb855010a
6
+ metadata.gz: bf067b1d2068e59ed7007bddfc41e2b70a287a57e6e3727e0fba8653ef4d4c1f892e158d6e012b96fbfb862eb1a2a8e48c17d60acc0891c2b85fc43f9c8bb17f
7
+ data.tar.gz: 71db79d7dd7e673b111d20a89a99d2ea7a95cd41c13ff3ededd017f27ef10a4351d7689b7aebcf04a2c8aedebbc0cee5bb8d8f57e1ac96bec1aaf5f838852171
data/CHANGELOG.md CHANGED
@@ -1,5 +1,134 @@
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
+
112
+ ## 1.32.0 (2026-10-07)
113
+
114
+ A dependency release (#127): the `permittable` floor rises to `>= 0.8`, and the Permittable docs
115
+ are rewritten against permittable 0.10. It is a minor release because hosts still on permittable
116
+ 0.1–0.7 must upgrade it (the same rule as the Rails floor raise in 1.29.0). No code change, no
117
+ new concerns and no migrations. 2723 examples, 0 failures, green on PostgreSQL, MySQL and every
118
+ Rails line from 6.0 to 8.1.
119
+
120
+ ### Changed
121
+ - **`permittable` dependency floor raised from `~> 0.1` to `>= 0.8, < 1`.** The Permittable
122
+ documentation (README section and docs page) was rewritten against permittable 0.10 — monitor
123
+ mode, `:json` fields, `nullable:`, `message:`/I18n copy, `format:` presets, field groups,
124
+ RFC 9457 output, the type-checking drift guard, the `permittable:generate`/`:audit`/`:openapi`
125
+ tasks, RSpec matchers and standalone contracts — and the old pin let a host resolve a 0.x that
126
+ had none of it. 0.8 is the floor because it is the newest line that still supports Rails 6.0
127
+ (0.9+ requires ActiveSupport >= 6.1), so the 6.0 CI line keeps resolving; the docs carry a
128
+ heads-up listing what a 0.8 host lacks and how Respondable's `:problem_details` and the gem's
129
+ own `Permittable.error_format = :problem` interact. No code change; the gem README stays the
130
+ canonical Permittable reference.
131
+
3
132
  ## 1.31.0 (2026-10-05)
4
133
 
5
134
  The fixes from two audits, each fix put through adversarial review rounds until a reviewer
data/README.md CHANGED
@@ -101,7 +101,7 @@ Every concern below links to its section in this README **and** to its standalon
101
101
  | [🪝 WebhookVerifiable](#-webhookverifiable) | HMAC verification for inbound webhooks | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/webhook-verifiable) |
102
102
  | [🌅 Deprecatable](#-deprecatable) | RFC `Deprecation` / `Sunset` headers + 410 | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/deprecatable) |
103
103
  | [🗄️ Cacheable](#-cacheable) | HTTP conditional GET (ETag / 304) + `Cache-Control` | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/cacheable) |
104
- | [🛂 Permittable](#-permittable) | Typed, validated params contracts + schema-drift guard | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/permittable) |
104
+ | [🛂 Permittable](#-permittable) | Typed, validated params contracts, schema-drift guard, OpenAPI export | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/permittable) |
105
105
 
106
106
  ---
107
107
 
@@ -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,483 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
  ---
@@ -158,7 +158,7 @@ across all 43 concerns — press <kbd>/</kbd> and type.
158
158
  Add to your application's `Gemfile`:
159
159
 
160
160
  ```ruby
161
- gem "concerns_on_rails", "~> 1.31"
161
+ gem "concerns_on_rails", "~> 1.32"
162
162
  ```
163
163
 
164
164
  Or pull the latest from GitHub:
@@ -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.
@@ -2583,7 +2597,12 @@ etag_with { current_user&.role } # => Cache-Control: private, max-ag
2583
2597
 
2584
2598
  ## 🛂 Permittable
2585
2599
 
2586
- Declarative, **typed params contracts** — what strong parameters would be if it also knew types, bounds, defaults, and *why* a request was bad. `params.permit` (and Rails 8's `params.expect`) only answer "which keys may pass"; a Permittable contract additionally **casts** each field, **validates** it, applies **defaults**, and turns every failure into a machine-readable 422. Because the contract is class-level data (not code inside the action), it is introspectable — and can be checked against a model's schema at boot.
2600
+ Declarative, **typed params contracts** — what strong parameters would be if it also knew types, bounds, defaults, and *why* a request was bad. `params.permit` (and Rails 8's `params.expect`) only answer "which keys may pass"; a Permittable contract additionally **casts** each field, **validates** it, applies **defaults**, optionally **reshapes** the output, and turns every failure into a machine-readable 422. Because the contract is class-level data (not code inside the action), it has readers beyond the validator: a boot-time **schema-drift guard**, an **OpenAPI 3.1** exporter, **RSpec matchers**, a route-set **coverage audit**, a **draft generator**, and a standalone `Contract` for any Hash.
2601
+
2602
+ > **Heads up — two things to know before relying on this section**
2603
+ >
2604
+ > 1. **Your lockfile, not this page, decides which features you have.** The pin is `>= 0.8, < 1`, so everything up to 0.8 (monitor mode, `:json`, `nullable:`, `message:`, field groups, matchers, generator, OpenAPI) is guaranteed, and an app that locked 0.8 keeps it until `bundle update permittable`. permittable 0.9+ requires ActiveSupport >= 6.1, so on Rails 6.0 Bundler stops at permittable **0.8**. That version lacks `format:` presets, `Permittable.check_column_types` and the enum rule, `Permittable.error_format = :problem`, `permittable:audit`, `params.expect` scanning in the generator, and the 0.10 canonical-numeric tightening (`"1_8"` and `" 99 "` still cast there). Everything else here applies. To make the newer surface a hard requirement, pin `gem "permittable", ">= 0.10"` in the app's Gemfile.
2605
+ > 2. **Two RFC 9457 switches exist — use one.** With [Respondable](#-respondable) included, Permittable's 422s render through `render_error`, so `respondable_by error_format: :problem_details` turns them into problem documents together with every other concern. The gem's own `Permittable.error_format = :problem` also renders RFC 9457 but deliberately **bypasses** `render_error`, so a controller with both set follows the gem's setting and ignores Respondable's envelope customisations.
2587
2606
 
2588
2607
  ```ruby
2589
2608
  class UsersController < ApplicationController
@@ -2591,10 +2610,12 @@ class UsersController < ApplicationController
2591
2610
 
2592
2611
  permit_params :create, :update, root: :user, model: User do
2593
2612
  required :name, :string, length: 1..80, normalize: :squish
2594
- required :email, :string, format: URI::MailTo::EMAIL_REGEXP, normalize: :email
2613
+ required :email, :string, format: :email, normalize: :email
2595
2614
  optional :age, :integer, in: 18..120
2596
2615
  optional :ssn, :string, sensitive: true # auto-redacted from logs
2597
2616
  optional :plan, :string, in: %w[free pro], default: "free"
2617
+ optional :nickname, :string, nullable: true # explicit null clears the column
2618
+ optional :metadata, :json, max_depth: 3, length: 0..32
2598
2619
  array :tag_names, of: :string, length: 0..10
2599
2620
  optional :address do
2600
2621
  required :city, :string
@@ -2615,19 +2636,23 @@ end
2615
2636
  # { param: "user.age", code: "inclusion" }] } }
2616
2637
  ```
2617
2638
 
2618
- **The schema-drift guard** is the headline: with `model:` (a class, or `true` to infer from the controller name), every non-`virtual:` scalar field is checked against the model's columns **at controller class load** through the same `ColumnGuard` all model concerns use. A column dropped by a migration fails the deploy (production eager-loads controllers) with a teaching error — the missing-column message plus a ready-to-paste migration command plus the `virtual: true` escape hatch. Nested/array fields are implicitly virtual; the check skips gracefully when the schema is unreachable (`db:create`, `assets:precompile`). In CI, one `Rails.application.eager_load!` spec exercises every contract in the app.
2639
+ **The schema-drift guard** is the headline: with `model:` (a class, or `true` to infer from the controller name), every non-`virtual:` scalar field is checked against the model's columns **at controller class load**. A column dropped by a migration fails the deploy (production eager-loads controllers) with a teaching error — the missing-column message plus a ready-to-paste migration command plus the `virtual: true` escape hatch. Nested/array fields are implicitly virtual; the check skips gracefully when the schema is unreachable (`db:create`, `assets:precompile`). Opt into the type half with `Permittable.check_column_types = true` (families: text / numeric incl. boolean / temporal; a Rails `enum` must be declared `:string, in: Model.statuses.keys`). In CI, one `Rails.application.eager_load!` spec exercises every contract in the app.
2640
+
2641
+ **Options** (`permit_params *actions, …`, repeatable; no actions = catch-all; **last matching rule wins**, subclasses inherit copy-on-write): `root:` (key to unwrap, `require(:user)`-style; missing root → **400**; default `false` = top-level params), `model:` (schema-drift guard), `unknown:` (`:ignore` default / `:log` / `:error` — undeclared keys at every nesting level; Rails' router/form/CSRF keys, the route's path params and ParamsWrapper's copy are exempt at the top level), `enforce:` (`false` = validate lazily on first `permitted_params` call, memoized per action; `true` = validate in a `before_action`), `mode:` (`:monitor` reports instead of rejecting — see below), `desc:` (OpenAPI operation description).
2642
+
2643
+ **Field DSL**: `required`/`optional` `name, type` (`:string` default, `:integer`, `:float`, `:decimal`, `:boolean`, `:date`, `:datetime`, and `:json` — a free-form hash for `jsonb` columns, bounded by `length:` keys and `max_depth:`) with `in:` (Range, or a list snapshotted and cast at load — `in: Post.statuses` works), `format:` (Regexp or a preset: `:email`/`:uuid`/`:url`/`:slug`/`:hostname`), `length:`/`normalize:` (`:squish`/`:strip`/`:downcase`/`:upcase`/`:email` or a Proc; string fields only), `default:` (validated against the field's own contract at load time), `nullable:` (an explicit `null`/`""` yields `nil` instead of counting as absent — the one way to clear a column; wins over `default:`), `message:` (per-field copy, String or code → String; app-wide copy via I18n `permittable.errors.<code>`), `validate:` (Proc — falsy fails as `"invalid"`, a returned Symbol becomes the violation code), `transform:` (see below), `virtual:`, `sensitive:` (registers with the gem-wide filter_parameters registry, same pipe as Encryptable; cascades into nested blocks, `sensitive: false` opts a sub-field out), `desc:`/`example:` (docs). Nested hashes via a block; `array :name, of: :type` (or a block for arrays of hashes) with `length:` as element count — short-circuiting, and there is no default cap — and per-index violation paths (`items[1]`). Reusable **field groups**: `Permittable.fields { … }` + `use Group` (`optional: true` turns a create contract into a PATCH contract; `only:`/`except:`).
2619
2644
 
2620
- **Options** (`permit_params *actions, …`, repeatable; no actions = catch-all; **last matching rule wins**, subclasses inherit copy-on-write): `root:` (key to unwrap, `require(:user)`-style; missing root → **400**; default `false` = top-level params), `model:` (schema-drift guard), `unknown:` (`:ignore` default / `:log` / `:error` — undeclared keys at every nesting level; Rails' `controller`/`action`/`format` keys are exempt at the top level), `enforce:` (`false` = validate lazily on first `permitted_params` call; `true` = validate in a `before_action` so the action body never runs on bad input).
2645
+ **Output reshaping** — the safe replacement for params-mutating before_actions, operating on the validated copy only (the request's `params` is never touched). `transform:` applies a callable **after** cast + validation to reshape that field's output — `transform: ->(v) { v.split(",") }` turns a validated delimited String into an Array (absent fields stay absent, a `default:` on a transformed field is handed out as authored, a partially-invalid array is never transformed). `finalize do |p| … end` (once per contract, top level only) runs after every field validated cleanly, receives the result hash, and returns the final shape — zip parallel fields into value objects, drop scaffolding keys. It executes on a bare runner, **not** the controller, so contracts stay pure; its one extra verb, `violate!(param, code, message: nil)`, records a violation and halts the block immediately — doubling as the cross-field validation seam ("ends_at after starts_at").
2621
2646
 
2622
- **Field DSL**: `required`/`optional` `name, type` (`:string` default, `:integer`, `:float`, `:decimal`, `:boolean`, `:date`, `:datetime`) with `in:` (Range/Array), `format:`/`length:`/`normalize:` (`:squish`/`:strip`/`:downcase`/`:upcase`/`:email` or a Proc; string fields only), `default:` (validated against the field's own contract at load time), `validate:` (Proc — falsy fails as `"invalid"`, a returned Symbol becomes the violation code), `transform:` (see below), `virtual:`, `sensitive:` (registers with the gem-wide filter_parameters registry, same pipe as Encryptable). Nested hashes via a block; `array :name, of: :type` (or a block for arrays of hashes) with `length:` as element count and per-index violation paths (`items[1]`).
2647
+ **Coercion is strict** — deliberately not `ActiveModel::Type` ("abc".to_i == 0 and `Boolean.cast("abc") == true` silently corrupt untrusted input): a value the type can't faithfully represent is an `invalid_type` violation, numeric strings must be canonical (`"1_8"` and `" 99 "` are refused), dates must be complete (never filled in from today), booleans accept only `true/false/"true"/"false"/"1"/"0"/1/0`, and array/hash type-confusion (`?age[]=1`) is rejected instead of 500ing. `nil` and `""` are both **absent**: absent optional fields are *omitted* from the result (partial updates never nil-out columns), absent required fields violate, `default:` fills absence.
2623
2648
 
2624
- **Output reshaping** — the safe replacement for params-mutating before_actions, operating on the validated copy only (the request's `params` is never touched). `transform:` (scalar/array fields) applies a callable **after** cast + validation to reshape that field's output — `transform: ->(v) { v.split(",") }` turns a validated delimited String into an Array (absent fields and `default:` values are untouched; a partially-invalid array is never transformed). `finalize do |p| … end` (once per contract, top level only) runs after every field validated cleanly, receives the result hash, and returns the final shape — zip parallel fields into value objects, drop scaffolding keys. It executes on a bare runner, **not** the controller, so contracts stay pure; its one extra verb, `violate!(param, code)`, records a violation and halts the block immediately — doubling as the cross-field validation seam ("ends_at after starts_at").
2649
+ **Adopting on a live API**: `bin/rails permittable:generate` drafts a contract per controller from the model's columns and the `params.permit`/`params.expect` calls already in the source (in monitor mode, every guess a visible TODO); `mode: :monitor` / `Permittable.mode = :monitor` runs the pipeline but **reports instead of rejecting** (notification with `mode: :monitor`, raw params passed through, `permittable_violations` for the action to branch on); `bin/rails permittable:audit` lists every routed action with its effective mode and flags write actions that accept a body with no contract (`audit[strict]` exits 1 — a CI gate); then flip to enforce controller by controller.
2625
2650
 
2626
- **Coercion is strict** — deliberately not `ActiveModel::Type` ("abc".to_i == 0 and `Boolean.cast("abc") == true` silently corrupt untrusted input): a value the type can't faithfully represent is an `invalid_type` violation, booleans accept only `true/false/"true"/"false"/"1"/"0"/1/0`, and array/hash type-confusion (`?age[]=1`) is rejected instead of 500ing. `nil` and `""` are both **absent**: absent optional fields are *omitted* from the result (partial updates never nil-out columns), absent required fields violate, `default:` fills absence.
2651
+ **Beyond the controller**: `require "permittable/rspec"` gives `permit_param(:age).for_action(:create).as(:integer).within(18..120)` (the declaration) and `accept_params(payload).returning(hash)` / `reject_params(payload).with_violation("user.age", :inclusion)` (the behaviour) without dispatching a request; `Permittable::Contract.define(root: :user) { … }` runs the same DSL on any Hash (`call` → a `Result`, `call!` raises); `bin/rails permittable:openapi[path]` exports an **OpenAPI 3.1** document from the same frozen data the server enforces — anything JSON Schema can't express stays visible as an `x-permittable-*` extension instead of being mistranslated, and the output is deterministic so it can be committed.
2627
2652
 
2628
- **Failure surface**: violations raise `Permittable::InvalidParameters` (carries `details` + `status`), auto-`rescue_from`d into the shared error envelope (`Respondable#render_error` when included, the identical inline JSON otherwise) and instrumented as `invalid_parameters.permittable` for dashboards. Introspect contracts via `permittable_contracts` / `permit_rule_for(action)`. Naming note: legacy InheritedResources controllers also define `permitted_params` — don't mix the two on one controller.
2653
+ **Failure surface**: violations raise `Permittable::InvalidParameters` (carries `details` + `status`; a missing/malformed `root:` is **400**, field violations **422**), auto-`rescue_from`d into the shared error envelope — `Respondable#render_error` when included (so `respondable_by error_format: :problem_details` makes them RFC 9457 along with the rest of the app), the identical inline JSON otherwise; the gem's own `Permittable.error_format = :problem` renders RFC 9457 too but bypasses `render_error`, so pick one switch. Instrumented as `invalid_parameters.permittable` (`controller`, `action`, `mode`, `details`), exactly once per action per request. Introspect contracts via `permittable_contracts` / `permit_rule_for(action)`. Naming note: legacy InheritedResources controllers also define `permitted_params` — don't mix the two on one controller.
2629
2654
 
2630
- > Permittable ships as the standalone [`permittable` gem](https://github.com/VSN2015/permittable) (a runtime dependency of concerns_on_rails); `ConcernsOnRails::Controllers::Permittable` is an alias for `::Permittable`, with `sensitive:` registrations pooled into this gem's shared filter_parameters registry.
2655
+ > Permittable ships as the standalone [`permittable` gem](https://github.com/VSN2015/permittable) (a runtime dependency, `>= 0.8, < 1`; this section describes 0.10); `ConcernsOnRails::Controllers::Permittable` is an alias for `::Permittable`, with `sensitive:` registrations pooled into this gem's shared filter_parameters registry. Full reference: [the gem README](https://github.com/VSN2015/permittable#readme) · [docs page](https://vsn2015.github.io/concerns_on_rails/#/c/permittable).
2631
2656
 
2632
2657
  ---
2633
2658
 
@@ -2697,9 +2722,9 @@ Point your agent at `llms.txt` for an overview, or paste a single concern's `.md
2697
2722
 
2698
2723
  ```sh
2699
2724
  bundle install # install dev dependencies
2700
- bundle exec rspec # run the test suite (2,723 examples)
2725
+ bundle exec rspec # run the test suite (2,827 examples)
2701
2726
  gem build concerns_on_rails.gemspec # build the gem
2702
- gem install ./concerns_on_rails-1.31.0.gem # install locally
2727
+ gem install ./concerns_on_rails-1.32.1.gem # install locally
2703
2728
 
2704
2729
  # Preview the docs site locally (GitHub Pages serves docs/ as-is):
2705
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