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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +129 -0
- data/README.md +44 -19
- data/lib/concerns_on_rails/controllers/cacheable.rb +18 -3
- data/lib/concerns_on_rails/controllers/cursor_paginatable.rb +73 -16
- data/lib/concerns_on_rails/controllers/filterable.rb +84 -22
- data/lib/concerns_on_rails/controllers/localizable.rb +20 -7
- data/lib/concerns_on_rails/controllers/paginatable.rb +5 -2
- data/lib/concerns_on_rails/controllers/throttleable.rb +20 -1
- data/lib/concerns_on_rails/controllers/timezoneable.rb +40 -6
- data/lib/concerns_on_rails/models/addressable.rb +64 -9
- data/lib/concerns_on_rails/models/auditable.rb +16 -1
- data/lib/concerns_on_rails/models/counter_cacheable.rb +123 -38
- data/lib/concerns_on_rails/models/encryptable.rb +99 -17
- data/lib/concerns_on_rails/models/lockable.rb +8 -1
- data/lib/concerns_on_rails/models/maskable.rb +10 -1
- data/lib/concerns_on_rails/models/monetizable.rb +22 -0
- data/lib/concerns_on_rails/models/sanitizable.rb +58 -3
- data/lib/concerns_on_rails/models/searchable.rb +7 -2
- data/lib/concerns_on_rails/models/sluggable.rb +28 -2
- data/lib/concerns_on_rails/models/soft_deletable.rb +34 -15
- data/lib/concerns_on_rails/models/sortable.rb +20 -0
- data/lib/concerns_on_rails/models/stateable.rb +8 -2
- data/lib/concerns_on_rails/models/storable.rb +9 -0
- data/lib/concerns_on_rails/models/taggable.rb +48 -2
- data/lib/concerns_on_rails/support/batch_ops.rb +6 -1
- data/lib/concerns_on_rails/support/encryptor.rb +11 -1
- data/lib/concerns_on_rails/support/generated_values.rb +2 -1
- data/lib/concerns_on_rails/support/link_header.rb +13 -1
- data/lib/concerns_on_rails/support/locking.rb +44 -2
- data/lib/concerns_on_rails/support/masker.rb +12 -4
- data/lib/concerns_on_rails/support/money.rb +13 -1
- data/lib/concerns_on_rails/support/scalar_param.rb +17 -7
- data/lib/concerns_on_rails/support/time_value.rb +14 -0
- data/lib/concerns_on_rails/version.rb +1 -1
- metadata +12 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3e44cfe4621a22897c4d3f6e2ade5b1485d7c686f10dd72fb0dd582a50eadd73
|
|
4
|
+
data.tar.gz: 478bc126777b1627ffba7275f1f81421c0ce112b01e3348c30b4010a023af75c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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,
|
|
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.
|
|
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
|
|
207
|
-
lines
|
|
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
|
|
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; Jerry`). `<` / `>` are deliberately kept encoded — the stored value can never become markup, even rendered with `raw` — so a typed `a < b` is stored as `a < b`. An `&` that would read back as a character reference (a literal `&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
|
|
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:
|
|
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
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
|
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
|
|
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,
|
|
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.
|
|
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.
|
|
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
|
|
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).
|
|
613
|
-
|
|
614
|
-
|
|
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
|
|
626
|
-
#
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|