concerns_on_rails 1.32.0 → 1.32.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +109 -0
- data/README.md +22 -8
- 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 +2 -2
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,114 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 1.32.1 (2026-10-08)
|
|
4
|
+
|
|
5
|
+
A bug-fix release from a six-angle audit of 1.32.0 — data and encryption, state and counters,
|
|
6
|
+
scopes and batch verbs, field transforms, request params, HTTP policy. Every finding was
|
|
7
|
+
reproduced by a failing spec before it was fixed, and every fix ships with a regression spec;
|
|
8
|
+
the batch then went through an adversarial review round. No new concerns, no new options and
|
|
9
|
+
no migrations. A few fixes now raise where the gem used to misbehave silently — read
|
|
10
|
+
**Changed** first. 2827 examples, 0 failures (2795 on Rails 6.0).
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- **Encryptable: Lockable's `unlock_token:` cannot be `encryptable`** (`ArgumentError` at
|
|
14
|
+
declaration, either order). The token is minted, claimed and cleared by callback-skipping
|
|
15
|
+
SQL keyed on the column value, so an encrypted token was never found: every unlock link was
|
|
16
|
+
dead. Keep it a plain column — it is random, single-use and cleared with the lock.
|
|
17
|
+
- **Monetizable: an `encryptable` cents column is refused** (`ArgumentError`, either order):
|
|
18
|
+
`sum_`/`average_`/`minimum_`/`maximum_` ran SQL aggregates over ciphertext.
|
|
19
|
+
- **Auditable: `audited_changes_since` reads its argument in `Time.zone`** — a `Date` is
|
|
20
|
+
midnight in `Time.zone`, a zone-less String is read in `Time.zone` (not the server's system
|
|
21
|
+
zone), and a String that names no time raises `ArgumentError` (`"junk"` used to mean June 1).
|
|
22
|
+
- **Encryptable: blind indexes refresh in `before_create`/`before_update`**, after every
|
|
23
|
+
`before_save`, instead of in an include-time `before_save`. An app `before_save` that rewrites
|
|
24
|
+
the field (`self.email = email.downcase`) is now fingerprinted as stored, and a new row always
|
|
25
|
+
refreshes every indexed field, so a copy (`dup`, Duplicable's `reset:`) no longer keeps its
|
|
26
|
+
original's digest. Rows written by such a callback before this release keep a stale digest
|
|
27
|
+
until re-saved with a change.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
- **Encryptable**: decrypted text reads back as UTF-8 (binary only when the bytes are not valid
|
|
31
|
+
UTF-8). Non-ASCII values compared unequal to what was written after a reload, were dirty on
|
|
32
|
+
every reassignment, and `reencrypt!` fingerprinted different bytes under a `downcase`
|
|
33
|
+
`expression:`, so rotated rows could no longer be found.
|
|
34
|
+
- **Encryptable**: an empty-String column default (`text null: false, default: ""`) reads as
|
|
35
|
+
`""` instead of raising `DecryptionError` on every new record.
|
|
36
|
+
- **Encryptable**: `find_by_<field>` / `where_<field>` / `<field>_fingerprint` run the lookup
|
|
37
|
+
value through the field's Normalizable rule and its Rails 7.1+ `normalizes`, as Rails' own
|
|
38
|
+
finders do.
|
|
39
|
+
- **Storable**: a `:float` key given `"Infinity"`/`"NaN"` casts to nil instead of raising
|
|
40
|
+
`JSON::GeneratorError` (a 500 from a form field).
|
|
41
|
+
- **SoftDeletable**: `without_deleted.really_destroy_all` (and any relation repeating the
|
|
42
|
+
default `deleted_at IS NULL`) no longer hard-deletes the trash, and `without_deleted.restore_all`
|
|
43
|
+
no longer restores it: only the default scope's own predicate is peeled off.
|
|
44
|
+
- **`*_all` batch verbs** (`Support::BatchOps`) roll the whole batch back inside a caller's
|
|
45
|
+
transaction too: the batch runs in its own savepoint, so a caller rescuing the
|
|
46
|
+
`RecordNotSaved` no longer commits the records written before the failing one.
|
|
47
|
+
- **Sluggable**: a record moved to another `scope:` value whose slug that scope already holds
|
|
48
|
+
rebuilds it against the new scope instead of duplicating it; a slug that is free there is kept.
|
|
49
|
+
- **Sortable**: the position acts_as_list assigns in `before_create` is reported through
|
|
50
|
+
`Support::GeneratedValues`, so an Auditable model tracking it records it on create.
|
|
51
|
+
- **CounterCacheable**: a save that changes a row's STI type (`becomes!`, `update!(type:)`)
|
|
52
|
+
judges the old state under the old type's rule and the new state under the new type's, and
|
|
53
|
+
destroy uses the persisted type's rule — the live counter no longer drifts from
|
|
54
|
+
`recount_counter_caches!`.
|
|
55
|
+
- **Stateable `lock: true` / Activatable `toggle_active!`**: the reload under the row lock keeps
|
|
56
|
+
a loaded `belongs_to` parent whose foreign key it didn't change, so CounterCacheable's mirror
|
|
57
|
+
reaches the caller's parent instead of leaving it to raise `StaleObjectError`.
|
|
58
|
+
- **Stateable**: `transition_all` no longer skips rows already in the target state when the
|
|
59
|
+
event's `from:` lists that state (a declared self-transition).
|
|
60
|
+
- **Lockable**: `attempts_remaining` returns the full `max_attempts` once a lock has lapsed
|
|
61
|
+
(`unlock_in`), instead of 0 on an unlocked account.
|
|
62
|
+
- **Sanitizable** `on: :write`, **Taggable** and **Addressable** also normalize in a prepended
|
|
63
|
+
`before_save` backstop (Normalizable's pattern), so `update_attribute` / `save(validate: false)`
|
|
64
|
+
no longer store raw markup, un-normalized tags or un-normalized address parts. A validated
|
|
65
|
+
save stores exactly what it stored before (the backstop never re-runs a rule on a value
|
|
66
|
+
validation already accepted).
|
|
67
|
+
- **Maskable / Sanitizable**: `as_json(masked: true)` / `as_json(sanitized: true)` carry the flag
|
|
68
|
+
through every nested `include:` level, so a grandchild reached through a model without the
|
|
69
|
+
concern is no longer serialized raw.
|
|
70
|
+
- **Masker `:email`** fails closed (full mask) unless the value is exactly one address, so a
|
|
71
|
+
second address in a list is no longer revealed.
|
|
72
|
+
- **Monetizable**: a `Rational` amount converts (`Rational(1999, 100)` → 1999 cents) instead of
|
|
73
|
+
being stored as nil.
|
|
74
|
+
- **Searchable / Taggable**: queries and tags are trimmed (and `mode: :all` split) on Unicode
|
|
75
|
+
whitespace — a no-break or ideographic space no longer becomes part of the LIKE pattern or a
|
|
76
|
+
distinct tag.
|
|
77
|
+
- **Filterable**: a nested-array param (`?name[][]=a&name[][]=b`) is ignored instead of raising
|
|
78
|
+
a `TypeError` 500; a JSON-body number on a date/datetime column — compared, in equality,
|
|
79
|
+
`in`/`not_in`, or handed to a `type: :date` lambda — matches nothing instead of being bound raw
|
|
80
|
+
(a 500 on PostgreSQL); a date/time operand longer than 128 characters no longer raises
|
|
81
|
+
`Date._parse`'s `ArgumentError` (a 500) on Rails 6.0–7.0 and is answered as 7.1+ answers it.
|
|
82
|
+
- **Paginatable**: a number where a nested page Hash belongs (`{"page": 2}` under
|
|
83
|
+
`style: :jsonapi`) falls back to the defaults instead of a `TypeError` 500.
|
|
84
|
+
- **CursorPaginatable**: a page ending on a stored ±Infinity/NaN float no longer raises
|
|
85
|
+
`JSON::GeneratorError` on every request — the boundary is minted as a string and walked
|
|
86
|
+
correctly on PostgreSQL and SQLite; a tampered number/boolean for a date/time boundary, or an
|
|
87
|
+
over-long date String on Rails 6.0–7.0, is an `InvalidCursor` (400), not a decoded position
|
|
88
|
+
or a 500.
|
|
89
|
+
- **Paginatable / CursorPaginatable**: `Link` header URLs are percent-encoded inside `<…>`, so a
|
|
90
|
+
crafted request path can no longer inject extra Link entries.
|
|
91
|
+
- **Throttleable**: the first-hit seed is written `raw: true` (to any store whose `write` takes
|
|
92
|
+
options), so a memcached `Rails.cache` on Rails 6.0–7.0 enforces the limit again (every later
|
|
93
|
+
`increment` returned nil and the 429 never fired).
|
|
94
|
+
- **Timezoneable**: a `Time-Zone` header or `time_zone` cookie that is not valid UTF-8 is ignored
|
|
95
|
+
instead of raising a 500 (persistently, for the cookie); the `available:` allow-list matches by
|
|
96
|
+
zone, not spelling, so `America/New_York` matches an allow-listed `Eastern Time (US & Canada)`.
|
|
97
|
+
- **Cacheable**: per-call `stale_resource?(r, extras: [...])` downgrades `Cache-Control` to
|
|
98
|
+
`private`, like a Vary-less `etag_with` source, so a CDN no longer serves one caller's
|
|
99
|
+
representation to the next.
|
|
100
|
+
- **Localizable**: `Accept-Language: pt-BR` (and `?locale=pt-br`) selects an available `:pt_BR`;
|
|
101
|
+
`-` and `_` are one separator (an exact spelling still wins).
|
|
102
|
+
|
|
103
|
+
### Known issues (not fixed in this release)
|
|
104
|
+
- CounterCacheable: a parent destroyed inside the same rolled-back transaction keeps the
|
|
105
|
+
mirrored counter/lock_version in memory.
|
|
106
|
+
- Aliasable: eager-loading an alias together with its source in one query loads each child twice.
|
|
107
|
+
- Encryptable: on the pre-7.1 Marshal format, a record whose field has a per-field Proc `key:`
|
|
108
|
+
cannot be `Marshal.dump`ed (`Rails.cache`).
|
|
109
|
+
- CursorPaginatable: on PostgreSQL, a page ending on a timestamp stored as `'infinity'` now mints
|
|
110
|
+
its cursor (it used to be a 500), but the next page answers 400 — the walk cannot step past it.
|
|
111
|
+
|
|
3
112
|
## 1.32.0 (2026-10-07)
|
|
4
113
|
|
|
5
114
|
A dependency release (#127): the `permittable` floor rises to `>= 0.8`, and the Permittable docs
|
data/README.md
CHANGED
|
@@ -148,7 +148,7 @@ across all 43 concerns — press <kbd>/</kbd> and type.
|
|
|
148
148
|
- **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable), and both load **lazily**: an app that never includes those concerns never loads them. Depends on `activerecord`/`actionpack`/`activesupport`, not the full `rails` meta-gem; controller concerns have zero extra deps
|
|
149
149
|
- **Schema-validated configuration** — every macro checks that the configured columns exist and raises `ArgumentError` early — listing *every* missing column at once, with one ready-to-paste `rails generate migration` command that adds them all
|
|
150
150
|
- **Composable** — concerns are independent; mix and match per model
|
|
151
|
-
- **Tested like an app, not a snippet** — **2,
|
|
151
|
+
- **Tested like an app, not a snippet** — **2,827 RSpec examples** run against a real database on every CI build
|
|
152
152
|
- **Documented twice** — everything in this README also lives as a per-concern page on the [docs site](https://vsn2015.github.io/concerns_on_rails), searchable and deep-linkable
|
|
153
153
|
|
|
154
154
|
---
|
|
@@ -203,8 +203,22 @@ still wins over the gem-wide fallback.
|
|
|
203
203
|
- **Rails**: 6.0 through 8.1
|
|
204
204
|
|
|
205
205
|
Every one of those lines runs the full suite on every push — Rails 6.0, 6.1, 7.0, 7.1, 7.2,
|
|
206
|
-
8.0 and 8.1 on SQLite, plus PostgreSQL and MySQL
|
|
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.
|
|
@@ -2708,9 +2722,9 @@ Point your agent at `llms.txt` for an overview, or paste a single concern's `.md
|
|
|
2708
2722
|
|
|
2709
2723
|
```sh
|
|
2710
2724
|
bundle install # install dev dependencies
|
|
2711
|
-
bundle exec rspec # run the test suite (2,
|
|
2725
|
+
bundle exec rspec # run the test suite (2,827 examples)
|
|
2712
2726
|
gem build concerns_on_rails.gemspec # build the gem
|
|
2713
|
-
gem install ./concerns_on_rails-1.32.
|
|
2727
|
+
gem install ./concerns_on_rails-1.32.1.gem # install locally
|
|
2714
2728
|
|
|
2715
2729
|
# Preview the docs site locally (GitHub Pages serves docs/ as-is):
|
|
2716
2730
|
cd docs && python3 -m http.server 8000 # → http://localhost:8000
|
|
@@ -64,7 +64,8 @@ module ConcernsOnRails
|
|
|
64
64
|
# method, a block, or a preset with `vary: false` — makes the response
|
|
65
65
|
# unshareable, so `Cache-Control` is emitted as `private` however the
|
|
66
66
|
# rule declared its `visibility:`. `:query` is exempt: the URL already
|
|
67
|
-
# carries it.
|
|
67
|
+
# carries it. Per-call `extras:` are such a source too (they carry no
|
|
68
|
+
# Vary), so a request that passes any is `private` as well.
|
|
68
69
|
#
|
|
69
70
|
# Notes:
|
|
70
71
|
# * `no_store: true` overrides everything (emits the lone `no-store`),
|
|
@@ -279,10 +280,13 @@ module ConcernsOnRails
|
|
|
279
280
|
# the computed { etag:, last_modified: } pair. Class-level `etag_with`
|
|
280
281
|
# values and per-call `extras:` are folded into the ETag (an explicit
|
|
281
282
|
# `etag:` stays verbatim only when there are none), and the sources' Vary
|
|
282
|
-
# headers are merged into the response.
|
|
283
|
+
# headers are merged into the response. Per-call extras advertise no Vary,
|
|
284
|
+
# so — exactly like a Vary-less etag_with source — they make this
|
|
285
|
+
# response `private` (see http_cache_visibility_for).
|
|
283
286
|
def set_cache_validators(resource = nil, etag: nil, last_modified: nil, extras: nil)
|
|
284
287
|
etag ||= cache_etag_for(resource) unless resource.nil?
|
|
285
288
|
last_modified ||= cache_last_modified_for(resource) unless resource.nil?
|
|
289
|
+
http_cache_note_request_extras(extras)
|
|
286
290
|
context = cache_etag_extras(extras)
|
|
287
291
|
etag = http_cache_combine_etag(etag, context) if etag && context.any?
|
|
288
292
|
http_cache_write_validators(etag, last_modified)
|
|
@@ -378,7 +382,10 @@ module ConcernsOnRails
|
|
|
378
382
|
# `vary: false` — is a dimension no cache can key on (`Vary` has no way to
|
|
379
383
|
# say "who is asking"), so the representation is simply not shareable and
|
|
380
384
|
# the declared `visibility:` is downgraded rather than trusted. `:query`
|
|
381
|
-
# is exempt: the query string is already part of the cache key.
|
|
385
|
+
# is exempt: the query string is already part of the cache key. Per-call
|
|
386
|
+
# `extras:` (`stale_resource?(r, extras: [current_user.role])`) are the
|
|
387
|
+
# same kind of dimension and used to keep `public`, so a CDN keyed on the
|
|
388
|
+
# URL handed one caller's representation to the next.
|
|
382
389
|
def http_cache_visibility_for(rule)
|
|
383
390
|
return "private" if http_cache_unshareable_etag?
|
|
384
391
|
|
|
@@ -386,9 +393,17 @@ module ConcernsOnRails
|
|
|
386
393
|
end
|
|
387
394
|
|
|
388
395
|
def http_cache_unshareable_etag?
|
|
396
|
+
return true if @http_cache_request_extras
|
|
397
|
+
|
|
389
398
|
self.class.cacheable_etag_extras.any? { |entry| entry[:vary].empty? && entry[:source] != :query }
|
|
390
399
|
end
|
|
391
400
|
|
|
401
|
+
# Any per-call extra counts, a nil one included — just as a block source
|
|
402
|
+
# downgrades whatever it returns: the dimension exists either way.
|
|
403
|
+
def http_cache_note_request_extras(extras)
|
|
404
|
+
@http_cache_request_extras = true unless Array(extras).empty?
|
|
405
|
+
end
|
|
406
|
+
|
|
392
407
|
# Previously a hand-rolled, case-SENSITIVE merge that also appended to
|
|
393
408
|
# `Vary: *` — and, run from set_cache_validators before render, it left
|
|
394
409
|
# the header non-blank so Rails skipped its own `Vary: Accept`.
|
|
@@ -558,11 +558,15 @@ module ConcernsOnRails
|
|
|
558
558
|
# redefined #is_a? returns true for Time). iso8601(6) keeps microsecond
|
|
559
559
|
# precision so boundary equality survives the round trip. The UTC form is
|
|
560
560
|
# a copy: `to_time.utc` converted the boundary record's own attribute in
|
|
561
|
-
# place (TimeWithZone#to_time is memoized).
|
|
561
|
+
# place (TimeWithZone#to_time is memoized). A non-finite Float — a
|
|
562
|
+
# stored ±Infinity or NaN (see cursor_non_finite_minted?) — is written
|
|
563
|
+
# as the String ActiveModel's Float type casts back ("Infinity",
|
|
564
|
+
# "-Infinity", "NaN"): JSON has no such number, and JSON.generate raised
|
|
565
|
+
# on it, so one such row on a page boundary made that page a 500.
|
|
562
566
|
def serialize_cursor_value(value)
|
|
563
567
|
return ConcernsOnRails::Support::TimeValue.utc(value).iso8601(6) if value.is_a?(Time) || value.is_a?(DateTime)
|
|
564
568
|
return value.iso8601 if value.is_a?(Date)
|
|
565
|
-
return value.to_s if value.is_a?(BigDecimal)
|
|
569
|
+
return value.to_s if value.is_a?(BigDecimal) || (value.is_a?(Float) && !value.finite?)
|
|
566
570
|
|
|
567
571
|
value
|
|
568
572
|
end
|
|
@@ -596,24 +600,38 @@ module ConcernsOnRails
|
|
|
596
600
|
# drops unboundable values to 1=0) and a RangeError 500 on 6.0. A
|
|
597
601
|
# timestamp outside years 0001..9999 casts fine too, then raised
|
|
598
602
|
# DatetimeFieldOverflow on PostgreSQL (a 500) and compared as text on
|
|
599
|
-
# SQLite.
|
|
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
|
|
@@ -186,12 +186,22 @@ module ConcernsOnRails
|
|
|
186
186
|
return options[:with].call(relation, type ? type.cast(value) : value) unless type
|
|
187
187
|
|
|
188
188
|
operand = ConcernsOnRails::Support::NumericOperand.classify(value, type, column_type: nil)
|
|
189
|
-
return options[:with].call(relation, type
|
|
189
|
+
return options[:with].call(relation, filterable_lambda_cast(type, value)) if operand.nil?
|
|
190
190
|
return relation.none unless operand.status == :exact
|
|
191
191
|
|
|
192
192
|
options[:with].call(relation, operand.value)
|
|
193
193
|
end
|
|
194
194
|
|
|
195
|
+
# What the type answers for any other String it cannot read: nil — also
|
|
196
|
+
# for the one Rails 6.0–7.0 raised on (filterable_type_cast).
|
|
197
|
+
# A date/time `type:` hands over nil, too, for an operand that is no
|
|
198
|
+
# date or time (a JSON body's Integer, which the date cast returns as
|
|
199
|
+
# is): the lambda would bind it raw, exactly the comparison bug.
|
|
200
|
+
def filterable_lambda_cast(type, value)
|
|
201
|
+
cast = filterable_type_cast(type, value)
|
|
202
|
+
filterable_uncastable?(type, value, cast) ? nil : cast
|
|
203
|
+
end
|
|
204
|
+
|
|
195
205
|
# ?price[gte]=10&price[lte]=50 — unknown keys are ignored.
|
|
196
206
|
def apply_filter_operator_hash(relation, field, hash, options)
|
|
197
207
|
hash.each do |key, raw|
|
|
@@ -333,7 +343,7 @@ module ConcernsOnRails
|
|
|
333
343
|
# list left empty matches nothing (`in`) or every non-NULL row
|
|
334
344
|
# (`not_in`, exactly what `!=` / NOT IN would answer).
|
|
335
345
|
def apply_filter_equality(relation, field, value, negate: false)
|
|
336
|
-
kept = filterable_equality_values(relation, field, value.is_a?(Array) ? value : [value])
|
|
346
|
+
kept = filterable_equality_values(relation, field, value.is_a?(Array) ? value : [value], negate: negate)
|
|
337
347
|
return filterable_where(relation, field, value, negate) if kept.nil?
|
|
338
348
|
return relation.none if kept == :uncastable
|
|
339
349
|
return negate ? relation.where.not(field => nil) : relation.none if kept.empty?
|
|
@@ -361,28 +371,51 @@ module ConcernsOnRails
|
|
|
361
371
|
|
|
362
372
|
# The members to keep on a numeric or date/time column, or nil to keep
|
|
363
373
|
# the raw value unchanged.
|
|
364
|
-
def filterable_equality_values(relation, field, values)
|
|
374
|
+
def filterable_equality_values(relation, field, values, negate: false)
|
|
365
375
|
filterable_numeric_equality_values(relation, field, values) ||
|
|
366
|
-
filterable_time_equality_values(relation, field, values)
|
|
367
|
-
end
|
|
368
|
-
|
|
369
|
-
# A date or time no stored value can equal
|
|
370
|
-
#
|
|
371
|
-
# used to raise
|
|
372
|
-
#
|
|
373
|
-
#
|
|
374
|
-
|
|
376
|
+
filterable_time_equality_values(relation, field, values, negate)
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# A date or time no stored value can equal drops out of the list, like
|
|
380
|
+
# an out-of-range number: one outside TimeValue::YEARS (on PostgreSQL it
|
|
381
|
+
# used to raise, a 500). A value the cast hands back unchanged — no
|
|
382
|
+
# date or time at all (a JSON body's Integer: a 500 on PostgreSQL, a
|
|
383
|
+
# coerced date on MySQL) — fails the whole filter closed (:uncastable),
|
|
384
|
+
# as an uncastable number does. A String Rails 6.0–7.0 cannot parse
|
|
385
|
+
# (filterable_type_cast; `where`'s own cast raised a 500) is answered as
|
|
386
|
+
# 7.1+ answers it, casting it to nil: it equals nothing (dropped from
|
|
387
|
+
# `=`/`in`), and `!=`/NOT IN against NULL matches nothing (`not`/`not_in`
|
|
388
|
+
# fail closed). nil when no member drops, so the caller keeps its
|
|
389
|
+
# unchanged path. A String that casts to nil stays as it was.
|
|
390
|
+
def filterable_time_equality_values(relation, field, values, negate)
|
|
375
391
|
column_type = filterable_column_type(relation, field)
|
|
376
392
|
return nil unless column_type
|
|
377
393
|
|
|
378
|
-
|
|
394
|
+
casts = values.map { |member| [member, filterable_equality_cast(column_type, member)] }
|
|
395
|
+
return :uncastable if filterable_any_time_uncastable?(column_type, casts, negate)
|
|
396
|
+
|
|
397
|
+
kept = casts.reject { |_member, cast| filterable_time_never_equal?(cast) }.map(&:first)
|
|
379
398
|
kept.size == values.size ? nil : kept
|
|
380
399
|
end
|
|
381
400
|
|
|
401
|
+
def filterable_time_never_equal?(cast)
|
|
402
|
+
cast.equal?(UNCASTABLE) || filterable_time_beyond(cast)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def filterable_any_time_uncastable?(column_type, casts, negate)
|
|
406
|
+
casts.any? do |member, cast|
|
|
407
|
+
next negate if cast.equal?(UNCASTABLE)
|
|
408
|
+
next false if member.nil? || cast.nil?
|
|
409
|
+
|
|
410
|
+
filterable_uncastable?(column_type, member, cast)
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
|
|
382
414
|
# Only to classify the member: `where` still casts the raw value itself,
|
|
383
|
-
# so a type whose cast raises is left to fail (or not) exactly as before
|
|
415
|
+
# so a type whose cast raises is left to fail (or not) exactly as before
|
|
416
|
+
# — except the date/time cast filterable_type_cast answers UNCASTABLE for.
|
|
384
417
|
def filterable_equality_cast(column_type, member)
|
|
385
|
-
column_type
|
|
418
|
+
filterable_type_cast(column_type, member)
|
|
386
419
|
rescue StandardError
|
|
387
420
|
nil
|
|
388
421
|
end
|
|
@@ -457,9 +490,23 @@ module ConcernsOnRails
|
|
|
457
490
|
def filter_cast(relation, field, value, options)
|
|
458
491
|
type = options[:type] || filterable_column_type(relation, field)
|
|
459
492
|
return value if type.nil?
|
|
460
|
-
return UNCASTABLE if
|
|
493
|
+
return UNCASTABLE if filterable_non_numeric?(type, value)
|
|
494
|
+
|
|
495
|
+
cast = filterable_type_cast(type, value)
|
|
496
|
+
filterable_uncastable?(type, value, cast) ? UNCASTABLE : cast
|
|
497
|
+
end
|
|
461
498
|
|
|
499
|
+
# type.cast, or UNCASTABLE for a String a date/time type cannot parse on
|
|
500
|
+
# Rails 6.0–7.0: Date._parse refuses one over 128 characters with an
|
|
501
|
+
# ArgumentError, which their date and time casts only rescue (to nil)
|
|
502
|
+
# from 7.1 on, so `?happened_on_gte=<129 characters>` was a 500. Any
|
|
503
|
+
# other type's ArgumentError is left alone.
|
|
504
|
+
def filterable_type_cast(type, value)
|
|
462
505
|
type.cast(value)
|
|
506
|
+
rescue ArgumentError
|
|
507
|
+
raise unless ConcernsOnRails::Support::TimeValue.temporal_type?(type)
|
|
508
|
+
|
|
509
|
+
UNCASTABLE
|
|
463
510
|
end
|
|
464
511
|
|
|
465
512
|
# PostgreSQL's Column answers `array` (its sql_type drops the "[]", so
|
|
@@ -484,20 +531,35 @@ module ConcernsOnRails
|
|
|
484
531
|
|
|
485
532
|
# Numeric types are checked against the string BEFORE casting, because
|
|
486
533
|
# `Integer#cast`/`Decimal#cast` answer 0 for any non-numeric string rather
|
|
487
|
-
# than nil — the cast result alone cannot tell "0" from "twelve".
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
def filterable_uncastable?(type, value)
|
|
491
|
-
return type.cast(value).nil? unless NUMERIC_TYPES.include?(type.type)
|
|
534
|
+
# than nil — the cast result alone cannot tell "0" from "twelve".
|
|
535
|
+
def filterable_non_numeric?(type, value)
|
|
536
|
+
return false unless NUMERIC_TYPES.include?(type.type)
|
|
492
537
|
return false if value.is_a?(Numeric)
|
|
493
538
|
|
|
494
539
|
!NUMERIC_STRING.match?(value.to_s)
|
|
495
540
|
end
|
|
496
541
|
|
|
542
|
+
# Every other type reports the failure by casting to nil (blank values
|
|
543
|
+
# never get this far; `apply_filter_operator` skipped them) — except the
|
|
544
|
+
# date/time types, whose casts return a non-String they cannot read
|
|
545
|
+
# unchanged: a JSON body's `{"happened_on_gte": 12345}` bound
|
|
546
|
+
# `happened_on >= 12345`, which SQLite answered with every row (it sorts
|
|
547
|
+
# any number before a date's text) and PostgreSQL with a 500. Their cast
|
|
548
|
+
# must be a date or time, or the ±Float::INFINITY PostgreSQL's own types
|
|
549
|
+
# read "infinity" as — which only a String can ask for.
|
|
550
|
+
def filterable_uncastable?(type, value, cast)
|
|
551
|
+
return true if cast.nil? || cast.equal?(UNCASTABLE)
|
|
552
|
+
return false unless ConcernsOnRails::Support::TimeValue.temporal_type?(type)
|
|
553
|
+
return false if ConcernsOnRails::Support::TimeValue.temporal?(cast)
|
|
554
|
+
|
|
555
|
+
!(value.is_a?(String) && cast.is_a?(Float) && cast.infinite?)
|
|
556
|
+
end
|
|
557
|
+
|
|
497
558
|
# Scalars (and arrays of scalars, which AR turns into `IN (...)`) are safe
|
|
498
559
|
# to pass to .where; a Hash / ActionController::Parameters is not — and
|
|
499
560
|
# since 1.22 neither is an array CONTAINING one (`?status[][x]=1` used to
|
|
500
|
-
# slip through as [Parameters] and 500 with a TypeError)
|
|
561
|
+
# slip through as [Parameters] and 500 with a TypeError), nor, now, an
|
|
562
|
+
# array of arrays (`?status[][]=a&status[][]=b`, the same TypeError).
|
|
501
563
|
def filterable_scalar?(value)
|
|
502
564
|
ConcernsOnRails::Support::ScalarParam.where_safe?(value)
|
|
503
565
|
end
|
|
@@ -142,11 +142,11 @@ module ConcernsOnRails
|
|
|
142
142
|
|
|
143
143
|
def parse_accept_language(header, allowed)
|
|
144
144
|
ranked_accept_languages(header).each do |lang|
|
|
145
|
-
# Full tag first (fr-CA matches an available :"fr-CA"),
|
|
146
|
-
# primary subtag (fr). Pre-1.22 the region was discarded
|
|
147
|
-
# so a regional locale in available: could never match its
|
|
148
|
-
# Accept-Language tag.
|
|
149
|
-
match = match_locale(lang, allowed) || match_locale(lang.split(
|
|
145
|
+
# Full tag first (fr-CA matches an available :"fr-CA" or :fr_CA),
|
|
146
|
+
# then the primary subtag (fr). Pre-1.22 the region was discarded
|
|
147
|
+
# outright, so a regional locale in available: could never match its
|
|
148
|
+
# own Accept-Language tag.
|
|
149
|
+
match = match_locale(lang, allowed) || match_locale(lang.split(/[-_]/).first, allowed)
|
|
150
150
|
return match if match
|
|
151
151
|
end
|
|
152
152
|
nil
|
|
@@ -184,11 +184,24 @@ module ConcernsOnRails
|
|
|
184
184
|
QVALUE.match?(weight) ? weight.to_f : 0.0
|
|
185
185
|
end
|
|
186
186
|
|
|
187
|
+
# Case-insensitive, with `_` and `-` as ONE separator: Accept-Language
|
|
188
|
+
# tags are BCP 47 (`pt-BR`) while Rails locales are often `:pt_BR`. The
|
|
189
|
+
# concern's own Content-Language already writes one as the other, yet a
|
|
190
|
+
# client sending that tag back fell through to `pt` or the default. The
|
|
191
|
+
# app's own symbol is what comes back.
|
|
192
|
+
# An exact (case-insensitive) spelling wins, so an app offering both
|
|
193
|
+
# :pt_BR and :"pt-BR" gets the one the client named.
|
|
187
194
|
def match_locale(candidate, allowed)
|
|
188
195
|
return nil if candidate.blank?
|
|
189
196
|
|
|
190
|
-
|
|
191
|
-
|
|
197
|
+
exact = candidate.to_s.downcase
|
|
198
|
+
wanted = locale_match_key(candidate)
|
|
199
|
+
allowed.find { |loc| loc.to_s.downcase == exact } ||
|
|
200
|
+
allowed.find { |loc| locale_match_key(loc) == wanted }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def locale_match_key(locale)
|
|
204
|
+
locale.to_s.downcase.tr("_", "-")
|
|
192
205
|
end
|
|
193
206
|
end
|
|
194
207
|
end
|
|
@@ -377,10 +377,13 @@ module ConcernsOnRails
|
|
|
377
377
|
|
|
378
378
|
# Dig the configured path out of params: `["page"]` → params[:page];
|
|
379
379
|
# `["page", "number"]` → params[:page][:number]. A scalar where a Hash
|
|
380
|
-
# is expected yields nil (→ the default), like any other garbage.
|
|
380
|
+
# is expected yields nil (→ the default), like any other garbage. Only
|
|
381
|
+
# a Hash / Parameters is dug into: a JSON body's `{"page": 2}` under
|
|
382
|
+
# `style: :jsonapi` reached `2["number"]` (Integer#[] reads a bit) and
|
|
383
|
+
# raised TypeError, a 500.
|
|
381
384
|
def pagination_param(path)
|
|
382
385
|
path.reduce(params) do |node, key|
|
|
383
|
-
break nil unless
|
|
386
|
+
break nil unless ConcernsOnRails::Support::ScalarParam.hash_like?(node)
|
|
384
387
|
|
|
385
388
|
node[key]
|
|
386
389
|
end
|