concerns_on_rails 1.28.3 → 1.28.5
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 +168 -0
- data/README.md +59 -8
- data/lib/concerns_on_rails/controllers/deprecatable.rb +15 -3
- data/lib/concerns_on_rails/controllers/filterable.rb +22 -4
- data/lib/concerns_on_rails/controllers/localizable.rb +58 -5
- data/lib/concerns_on_rails/controllers/paginatable.rb +46 -4
- data/lib/concerns_on_rails/controllers/secure_headable.rb +86 -7
- data/lib/concerns_on_rails/controllers/webhook_verifiable.rb +53 -4
- data/lib/concerns_on_rails/models/addressable.rb +109 -1
- data/lib/concerns_on_rails/models/aliasable.rb +9 -0
- data/lib/concerns_on_rails/models/duplicable.rb +37 -6
- data/lib/concerns_on_rails/models/encryptable.rb +28 -5
- data/lib/concerns_on_rails/models/expirable.rb +59 -9
- data/lib/concerns_on_rails/models/lockable.rb +9 -1
- data/lib/concerns_on_rails/models/monetizable.rb +36 -6
- data/lib/concerns_on_rails/models/schedulable.rb +82 -1
- data/lib/concerns_on_rails/models/stateable.rb +24 -3
- data/lib/concerns_on_rails/models/taggable.rb +43 -1
- data/lib/concerns_on_rails/support/encryptor.rb +23 -0
- data/lib/concerns_on_rails/support/error_envelope.rb +11 -2
- data/lib/concerns_on_rails/support/money.rb +29 -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: 5c6fcd158fd0dce6d1744e9ad6be5d921bf283e921e717a8327f04f53e8668f4
|
|
4
|
+
data.tar.gz: fe3b93a06be3a0cbb4b0e1904a976393b77d24c4075f1d8dccb98e13d3ac6597
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1a113631bd4810decc66aad97ba5fc9fe8866d92dcfd1ab1908c2cdafe7e990e961c3655933ad124ae8275f1a199c4275a7ba72a06cc172ecf248e2825eb42de
|
|
7
|
+
data.tar.gz: 7102f8a5cb83e21eabe46066f7a62956adf0950dbe926dc8706c9abd217a87577b6004d625c71d179dc2cf4083dc7fdefd25b47883e1a085fee1ec5bb691b41c
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,173 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 1.28.5 (2026-09-17)
|
|
4
|
+
|
|
5
|
+
Eight feature PRs deepening existing concerns, released as a patch by request:
|
|
6
|
+
no new concerns and no dependency changes, though several add an optional
|
|
7
|
+
column or option. Every PR was reviewed before merge and carries the review's
|
|
8
|
+
fixes; the notes below call out the ones that change a documented behaviour.
|
|
9
|
+
1522 examples, 0 failures.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- **Models::Schedulable**: `overlapping(from, to)` scope and `overlaps?`
|
|
13
|
+
predicate for booking-clash checks. Open-ended windows (a `NULL` start or
|
|
14
|
+
end), touching intervals, reversed arguments and Range forms behave
|
|
15
|
+
identically in SQL and in Ruby. Affixable like the other scopes. (#58)
|
|
16
|
+
- **Models::Duplicable**: per-call `only:` / `except:` association selection —
|
|
17
|
+
`invoice.duplicate!(except: :line_items)`. The macro's list stays the
|
|
18
|
+
ceiling, so a controller param cannot smuggle in an undeclared association.
|
|
19
|
+
An explicit `nil` counts as passed, not absent, so an empty checkbox list
|
|
20
|
+
copies nothing rather than everything. (#73)
|
|
21
|
+
- **Models::Monetizable**: scope-aware `sum_`, `average_`, `minimum_` and
|
|
22
|
+
`maximum_<name>` aggregates plus their `formatted_` twins, with per-call
|
|
23
|
+
formatting overrides. BigDecimal throughout; a grouped relation returns a
|
|
24
|
+
Hash of converted values instead of raising. (#67)
|
|
25
|
+
- **Models::Taggable**: `tag_counts` — tag to record count in one `GROUP BY`
|
|
26
|
+
query, relation-aware, with `limit:` for the top N. A `select`, `group` or
|
|
27
|
+
`order` on the relation is stripped; a `limit`/`offset` window is honoured by
|
|
28
|
+
resolving it to ids first. (#56)
|
|
29
|
+
- **Models::Expirable**: `before_expire` / `after_expire` lifecycle hooks,
|
|
30
|
+
`expire_in!(duration)` and `clear_expiry!`. The hooks fire when a write
|
|
31
|
+
actually expires the record; a future time only schedules expiry and fires
|
|
32
|
+
nothing, so `after_expire { account.downgrade! }` is safe next to
|
|
33
|
+
`trial.expire_in!(14.days)`. Overriding either hook moves `expire_all` to the
|
|
34
|
+
per-record path. (#54)
|
|
35
|
+
- **Controllers::SecureHeadable**: HSTS, cross-origin (COOP/COEP/CORP) and
|
|
36
|
+
Permissions-Policy presets, plus the `:recommended` and
|
|
37
|
+
`:cross_origin_isolation` bundles. `:recommended` uses `(self)`-scoped
|
|
38
|
+
permission values, so it denies third-party frames without disabling the
|
|
39
|
+
app's own camera, microphone, geolocation or Payment Request. HSTS is skipped
|
|
40
|
+
on a plaintext request (RFC 6797 §7.2) and never overwrites a stricter value
|
|
41
|
+
already on the response. (#55)
|
|
42
|
+
- **Models::Addressable**: `address_fingerprint` (SHA-256 of the normalized
|
|
43
|
+
address), `same_address_as?`, `address_parts_changed?` and a `fingerprint:`
|
|
44
|
+
column option with a `with_address` finder for deduplication reports. The
|
|
45
|
+
column is stamped in `before_save`, after every `before_validation`, so a
|
|
46
|
+
sibling concern rewriting a mapped column cannot desync it. (#79)
|
|
47
|
+
- **Controllers::Localizable**: `Content-Language` response header and
|
|
48
|
+
`Vary: Accept-Language`, both written before the action so a
|
|
49
|
+
`rescue_from`-rendered error carries them. Rails' own `Vary: Accept` is
|
|
50
|
+
preserved rather than suppressed, and `Vary` is advertised only when
|
|
51
|
+
`Accept-Language` can actually change the resolved locale. (#53)
|
|
52
|
+
|
|
53
|
+
### Notes
|
|
54
|
+
- **Addressable** defines `address_changed?` only when the model has no
|
|
55
|
+
`address` column of its own, so it never shadows ActiveModel's dirty
|
|
56
|
+
predicate; `address_parts_changed?` is always available.
|
|
57
|
+
- Existing rows keep a `NULL` address fingerprint until they are re-saved.
|
|
58
|
+
Backfill with `Model.find_each(&:save)`; `update_columns`, `insert_all` and
|
|
59
|
+
`upsert_all` bypass callbacks and leave it stale.
|
|
60
|
+
- The address digest is unkeyed, so treat the column as revealing the address.
|
|
61
|
+
Do not pair it with an `encryptable` address column.
|
|
62
|
+
|
|
63
|
+
## 1.28.4 (2026-09-16)
|
|
64
|
+
|
|
65
|
+
Eleven bug-fix PRs (#91–#101) from the audit of the shipped gem, released as a
|
|
66
|
+
patch: no new concerns, no new options, no migrations, no dependency changes.
|
|
67
|
+
Two close fail-open holes (WebhookVerifiable, Encryptable); the rest are
|
|
68
|
+
correctness fixes for behaviour the docs already promised. Every fix ships with
|
|
69
|
+
a regression spec that fails on 1.28.3. 1460 examples, 0 failures.
|
|
70
|
+
|
|
71
|
+
### Security
|
|
72
|
+
- **Controllers::WebhookVerifiable**: verification could be skipped entirely,
|
|
73
|
+
leaving the action to run on an unverified — possibly forged — payload. Two
|
|
74
|
+
paths: `webhook_verification_failed` returned `nil` when there was no response
|
|
75
|
+
object to render into, which left the `before_action` chain unhalted; and
|
|
76
|
+
`webhook_rule_for_action` returned `nil` (read as "no rule applies, carry on")
|
|
77
|
+
when `action_name` was unresolvable or `""`. Both fail closed now — the first
|
|
78
|
+
raises, the second falls back to the catch-all rule, or to a lone declared
|
|
79
|
+
rule, and verifies. With several action-specific rules and no catch-all it
|
|
80
|
+
raises rather than verifying against an arbitrary provider's secret, which
|
|
81
|
+
would reject a valid delivery as "signature invalid". The render guard also
|
|
82
|
+
honours a `render_error` override on its own, so a controller supplying one
|
|
83
|
+
but no response object renders its rejection instead of raising. Mirrors the
|
|
84
|
+
fix Authorizable got in 1.22. A resolvable action simply not covered by any
|
|
85
|
+
rule still passes through untouched. (#92)
|
|
86
|
+
- **Models::Encryptable**: `<field>_ciphertext` — documented for "asserting no
|
|
87
|
+
plaintext is at rest" — returned the caller's **plaintext** whenever the value
|
|
88
|
+
had not round-tripped through the database (a new record, or any pending
|
|
89
|
+
assignment: exactly the state inside a `before_save`, a validator, or an
|
|
90
|
+
error-reporting path), so `log.info(user.ssn_ciphertext)` wrote the SSN
|
|
91
|
+
straight to the log. It returns `nil` in that state now. `<field>_encrypted?`
|
|
92
|
+
used a bare `.present?`, true for plaintext too; it now checks that what is
|
|
93
|
+
stored really is an encryption envelope, via the new
|
|
94
|
+
`Support::Encryptor.envelope?`. (#97)
|
|
95
|
+
|
|
96
|
+
### Fixed
|
|
97
|
+
- **Models::Aliasable**: an aliased `belongs_to` carrying `counter_cache:`
|
|
98
|
+
double-counted. The alias copy kept the `:counter_cache` option, and because
|
|
99
|
+
the `#association` override maps the alias back to the same association
|
|
100
|
+
object, ActiveRecord's counter-cache pass fired once per name — the parent's
|
|
101
|
+
count came out doubled on create and doubled on destroy, drifting permanently
|
|
102
|
+
negative once rows predating the alias were removed. The copy no longer
|
|
103
|
+
carries the option; the source reflection still owns the counter. (#93)
|
|
104
|
+
- **Controllers::Paginatable**: `?page=99999999999999999999` was an
|
|
105
|
+
unauthenticated 500 — `(page - 1) * per_page` produced an offset no backend
|
|
106
|
+
accepts (`StatementInvalid` on a relation, `RangeError` on an Array). `page`
|
|
107
|
+
is now clamped to `MAX_PAGE` (1,000,000) and comes back as an empty page past
|
|
108
|
+
the end; `per_page` is held under the matching `MAX_PER_PAGE`, since with
|
|
109
|
+
`max_per_page: 0` ("no cap") the identical value overflowed `LIMIT` instead.
|
|
110
|
+
`paginate_by` also validates `per_page` now: 0 and negatives raise
|
|
111
|
+
`ArgumentError` at class-load time instead of misbehaving on every request
|
|
112
|
+
(`per_page: -1` means `LIMIT -1`, i.e. NO LIMIT on SQLite and MySQL —
|
|
113
|
+
serialising the whole table; `per_page: 0` made every page permanently empty).
|
|
114
|
+
A negative `max_per_page` still means "no cap", as documented. (#94)
|
|
115
|
+
- **Models::Taggable**: `all_tags` raised on PostgreSQL for any model that also
|
|
116
|
+
includes `Models::Sortable` — `SELECT DISTINCT` cannot be ordered by a column
|
|
117
|
+
outside the select list, and Sortable installs exactly such a `default_scope`.
|
|
118
|
+
The inherited `ORDER BY` is dropped with `reorder(nil)`; the result is sorted
|
|
119
|
+
in Ruby anyway. Passed on SQLite, which permits it. (#95)
|
|
120
|
+
- **Models::Lockable, Models::Stateable**: `ActiveRecord::Rollback` raised from
|
|
121
|
+
an `after_lock` / `after_transition` hook did nothing when the call was nested
|
|
122
|
+
inside a caller's own transaction — a bare `transaction` joins the enclosing
|
|
123
|
+
one and Rails swallows `Rollback` without rolling anything back. Both open a
|
|
124
|
+
savepoint now (`requires_new: true`), so the documented abort works: Lockable
|
|
125
|
+
no longer leaves a row locked in the database while reporting `false` in
|
|
126
|
+
memory (with `lock_access!`'s idempotency guard then making every retry a
|
|
127
|
+
no-op), and Stateable no longer commits a state change its hook asked to
|
|
128
|
+
abort. Stateable's `<event>!` also took its return value from `update!`, which
|
|
129
|
+
runs *before* the hook, so an aborted transition reported success —
|
|
130
|
+
`raise unless ticket.archive!` never fired and `transition_all` counted a row
|
|
131
|
+
it had rolled back. It reports `false` now, which `transition_all` treats as
|
|
132
|
+
the documented failed-record signal. Note `transition_all` opens one savepoint
|
|
133
|
+
per record. (#96)
|
|
134
|
+
- **Support::ErrorEnvelope**: the `render_error` lookup was public-only, but
|
|
135
|
+
`render_error` is very often declared under `private` — the idiomatic way to
|
|
136
|
+
keep a controller helper from becoming a routable action. Those overrides were
|
|
137
|
+
silently ignored and the gem's inline envelope rendered instead, so an app
|
|
138
|
+
rendering RFC 9457 problem+json got the wrong shape for every Authorizable
|
|
139
|
+
403, WebhookVerifiable 401, Throttleable 429 and CursorPaginatable 400, with
|
|
140
|
+
no error or warning. Now `respond_to?(:render_error, true)`, the spelling
|
|
141
|
+
Authorizable already used for `current_user`. Controllers::Deprecatable keeps
|
|
142
|
+
its own copy of that check before rendering a sunset 410, and it had the same
|
|
143
|
+
blind spot — a private `render_error` with no response object skipped the 410
|
|
144
|
+
and served the sunset action. (#98)
|
|
145
|
+
- **Controllers::Deprecatable**: `deprecate_actions` mutated the caller's own
|
|
146
|
+
`Time`. `Time#utc` is an alias of `#gmtime` and converts the receiver IN
|
|
147
|
+
PLACE, so a host passing a frozen constant (`SUNSET = Time.new(...).freeze`)
|
|
148
|
+
got a `FrozenError` while the controller class body was still loading — the
|
|
149
|
+
app would not boot — and an unfrozen `Time` was silently rewritten to UTC
|
|
150
|
+
behind the caller's back. Now `getutc`. (#99)
|
|
151
|
+
- **Controllers::Filterable**: a boolean `false` read as "filter not supplied",
|
|
152
|
+
so `filter_by :active` could never select the inactive rows — `false.blank?`
|
|
153
|
+
is true, the rule was skipped and the UNFILTERED relation came back. Only JSON
|
|
154
|
+
request bodies were affected; a query string carries the String `"false"`,
|
|
155
|
+
which is not blank. Everything genuinely empty — `nil`, `""`, `" "`, `[]`,
|
|
156
|
+
`{}` — is still skipped, and in `scope:` mode (which discards the value) an
|
|
157
|
+
explicit `false` still means "do not apply this scope". (#100)
|
|
158
|
+
- **Models::Stateable**: `transition_all` silently skipped rows whose state is
|
|
159
|
+
NULL. `where.not(state: to)` compiles to `NOT (state = 'x')`, which SQL
|
|
160
|
+
three-valued logic evaluates to NULL — never TRUE — for a NULL state, so those
|
|
161
|
+
rows were dropped from the batch and from the returned count even though they
|
|
162
|
+
ARE eligible (`may_<event>?` returns true for them and the per-record
|
|
163
|
+
`<event>!` succeeds). The predicate is NULL-safe now. (#101)
|
|
164
|
+
|
|
165
|
+
### Internal
|
|
166
|
+
- **Specs**: the Aliasable join-alias SQL assertion accepts both the Rails 8.1
|
|
167
|
+
`AS`-qualified table alias and the older unqualified form, so the suite passes
|
|
168
|
+
on Rails 8.1 — unblocking the pending Rails 8.1 dependency bumps. No library
|
|
169
|
+
change. (#91)
|
|
170
|
+
|
|
3
171
|
## 1.28.3 (2026-09-16)
|
|
4
172
|
|
|
5
173
|
Three merged PRs from the September loop (#41, #43, #52), shipped as a patch at
|
data/README.md
CHANGED
|
@@ -148,7 +148,7 @@ across all 43 concerns — press <kbd>/</kbd> and type.
|
|
|
148
148
|
- **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable), and both load **lazily**: an app that never includes those concerns never loads them. Depends on `activerecord`/`actionpack`/`activesupport`, not the full `rails` meta-gem; controller concerns have zero extra deps
|
|
149
149
|
- **Schema-validated configuration** — every macro checks that the configured columns exist and raises `ArgumentError` early — listing *every* missing column at once, with one ready-to-paste `rails generate migration` command that adds them all
|
|
150
150
|
- **Composable** — concerns are independent; mix and match per model
|
|
151
|
-
- **Tested like an app, not a snippet** — **1,
|
|
151
|
+
- **Tested like an app, not a snippet** — **1,522 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
|
---
|
|
@@ -601,6 +601,9 @@ Promotion.current # WHERE starts_at <= NOW AND (ends_at IS NU
|
|
|
601
601
|
Promotion.upcoming # WHERE starts_at > NOW
|
|
602
602
|
Promotion.expired # WHERE ends_at <= NOW
|
|
603
603
|
Promotion.active_at(time) # active at an arbitrary time
|
|
604
|
+
Promotion.overlapping(from, to) # windows intersecting [from, to) — clashing bookings, a calendar page
|
|
605
|
+
Promotion.overlapping(from..to) # Range form; `..` makes the end inclusive; nil on either side = unbounded
|
|
606
|
+
promo.overlaps?(from, to) # the instance-side predicate
|
|
604
607
|
```
|
|
605
608
|
|
|
606
609
|
**Configuration**
|
|
@@ -667,13 +670,21 @@ ApiToken.expiring_within(1.day) # future expiry within the next 1 day
|
|
|
667
670
|
```ruby
|
|
668
671
|
token.expire! # expires_at = now
|
|
669
672
|
token.expire!(2.hours.from_now) # explicit time
|
|
673
|
+
token.expire_in!(15.minutes) # absolute lifetime from now, whatever the current expiry
|
|
670
674
|
token.extend_expiry!(by: 1.day) # pushes expiry forward
|
|
675
|
+
token.clear_expiry! # never expires (nil)
|
|
671
676
|
```
|
|
672
677
|
|
|
673
678
|
`extend_expiry!` is smart about the base:
|
|
674
679
|
- If `expires_at` is `nil` or in the past → new value is `now + by`
|
|
675
680
|
- If `expires_at` is still in the future → `by` is added to the existing value
|
|
676
681
|
|
|
682
|
+
**Lifecycle hooks** — override `before_expire` / `after_expire` on the model; they fire around a write that
|
|
683
|
+
actually expires the record (`expire!` with a past-or-now time, and `expire_all`) inside one transaction, so
|
|
684
|
+
a raising `after_expire` rolls the expiry back. A future time only *schedules* expiry, so `expire_in!(14.days)`
|
|
685
|
+
fires nothing — as with renewals (`extend_expiry!`) and `clear_expiry!`. Overriding either hook moves
|
|
686
|
+
`expire_all` from its single `UPDATE` to the per-record path so the hooks run for every row.
|
|
687
|
+
|
|
677
688
|
**Bulk operations**
|
|
678
689
|
|
|
679
690
|
```ruby
|
|
@@ -681,7 +692,7 @@ ApiToken.expiring_within(1.day).expire_all # => 12
|
|
|
681
692
|
```
|
|
682
693
|
|
|
683
694
|
`expire_all(time = Time.zone.now)` expires every currently-active record in the relation and
|
|
684
|
-
returns the Integer count, in a transaction. With `expire!` unoverridden and no validations on
|
|
695
|
+
returns the Integer count, in a transaction. With `expire!` and both hooks unoverridden and no validations on
|
|
685
696
|
the model — neither `validates`/`validates_with`, a custom `validate :method`, nor an
|
|
686
697
|
association's autosave validation (a bare `has_many` registers one, so most models with
|
|
687
698
|
associations take the streaming path) — it collapses
|
|
@@ -1042,6 +1053,7 @@ end
|
|
|
1042
1053
|
| `allow_blank:` | `false` | Per-field opt-out for the length check: an Array of parts (e.g. `%i[line2 state]`), or `true` for all parts. A blank value for an allowed part skips its length check. Independent of `required:`. |
|
|
1043
1054
|
| `normalize_country:` | `false` | When `true`, canonicalize the country to its ISO 3166-1 alpha-2 code: an English name (`"Canada"`, `"United States"`) or a 3-letter alpha-3 (`"CAN"`, `"USA"`) maps to the alpha-2 (`"CA"`, `"US"`); unrecognized values are left untouched. This also lets postal/state validation recognize a named country. |
|
|
1044
1055
|
| `verify_with:` | `nil` | A callable for real-world verification (see below). |
|
|
1056
|
+
| `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)`. |
|
|
1045
1057
|
| `if:` / `unless:` | `nil` | Standard Rails validation conditions (Symbol, Proc, or Array) gating the address **validations** — e.g. `if: :on_addresses?`. Normalization still runs unconditionally. |
|
|
1046
1058
|
|
|
1047
1059
|
**What it normalizes** (in `before_validation`)
|
|
@@ -1065,6 +1077,18 @@ end
|
|
|
1065
1077
|
| `String` | added as a `:base` error |
|
|
1066
1078
|
| `Array` | each element added as a `:base` error |
|
|
1067
1079
|
|
|
1080
|
+
**Dedupe helpers**
|
|
1081
|
+
|
|
1082
|
+
```ruby
|
|
1083
|
+
loc.address_fingerprint # => "9f2c…" — SHA-256 of the normalized parts: case, whitespace, postal spacing
|
|
1084
|
+
# and a blank country (→ default_country) don't change it; nil for a blank address
|
|
1085
|
+
loc.same_address_as?(other) # fingerprints equal (never true for two blanks)
|
|
1086
|
+
loc.address_changed? # any mapped column dirty (address_parts_changed? when you have an `address` column)
|
|
1087
|
+
|
|
1088
|
+
addressable_by fingerprint: :address_fingerprint # add a string column + index
|
|
1089
|
+
Location.with_address(loc).where.not(id: loc.id) # the duplicates of loc (or pass a fingerprint)
|
|
1090
|
+
```
|
|
1091
|
+
|
|
1068
1092
|
**Notes**
|
|
1069
1093
|
- Scope is **format/structure only** — it checks shape, not real-world deliverability. Plug a USPS/Google/Smarty client into `verify_with:` for that.
|
|
1070
1094
|
- Error messages are plain English strings — no host-app i18n setup required.
|
|
@@ -1096,6 +1120,7 @@ article.save!
|
|
|
1096
1120
|
Article.tagged_with("ruby", "rails") # records carrying BOTH tags
|
|
1097
1121
|
Article.tagged_with("ruby", "go", any: true) # records carrying ANY tag
|
|
1098
1122
|
Article.all_tags # => sorted unique tags in use
|
|
1123
|
+
Article.published.tag_counts(limit: 20) # => { "ruby" => 12, "rails" => 7, ... } — a tag cloud, relation-aware
|
|
1099
1124
|
```
|
|
1100
1125
|
|
|
1101
1126
|
**Options**
|
|
@@ -1108,7 +1133,8 @@ Article.all_tags # => sorted unique tags in use
|
|
|
1108
1133
|
**Notes**
|
|
1109
1134
|
- Matching is **boundary-safe** — searching `rail` does not match `rails`. An explicit SQL `ESCAPE` clause makes tags containing `_` / `%` match literally on every adapter.
|
|
1110
1135
|
- Tags are normalized in `before_validation`, so a direct `record.tags = "a, b"` assignment is cleaned too. An empty list stores `NULL`.
|
|
1111
|
-
-
|
|
1136
|
+
- `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.
|
|
1137
|
+
- Reach for [`acts-as-taggable-on`](https://github.com/mbleigh/acts-as-taggable-on) when you need tag contexts, ownership, or polymorphic tags shared across models.
|
|
1112
1138
|
|
|
1113
1139
|
---
|
|
1114
1140
|
|
|
@@ -1206,9 +1232,17 @@ product.formatted_price # => "$19.99"
|
|
|
1206
1232
|
|-------------------|-----------------------------------------------|
|
|
1207
1233
|
| `price` | the amount as a `BigDecimal` (cents ÷ 100) |
|
|
1208
1234
|
| `price=` | assign in major units; rounded to whole cents |
|
|
1209
|
-
| `formatted_price` | a display string (`"$1,234.56"`)
|
|
1235
|
+
| `formatted_price` | a display string (`"$1,234.56"`); accepts per-call overrides: `formatted_price(unit: "€", delimiter: ".", separator: ",")` |
|
|
1210
1236
|
|
|
1211
|
-
**
|
|
1237
|
+
**Aggregates** — class methods that follow the current scope, exact and float-free:
|
|
1238
|
+
|
|
1239
|
+
```ruby
|
|
1240
|
+
Product.sum_price # => BigDecimal SUM(price_cents) / 100
|
|
1241
|
+
Product.in_stock.average_price # average_ / minimum_ / maximum_ too — nil on an empty set (sum is 0)
|
|
1242
|
+
Order.paid.formatted_sum_total # => "€3.500,50" every aggregate has a formatted_ twin (overrides accepted)
|
|
1243
|
+
```
|
|
1244
|
+
|
|
1245
|
+
**Options**: `as:` (explicit method name — required when the column does not end in `_cents`), `unit:` (`"$"`), `precision:` (`2`), `delimiter:` (`","`), `separator:` (`"."`), `subunit_to_unit:` (`100`). `nil` stays `nil` across all the accessors.
|
|
1212
1246
|
|
|
1213
1247
|
---
|
|
1214
1248
|
|
|
@@ -1417,6 +1451,7 @@ Patient.where_email("a@b.com") # chainable Relation (accepts arrays too)
|
|
|
1417
1451
|
**Notes**
|
|
1418
1452
|
- The declared column must be `text`/binary (it stores an opaque envelope, not the logical type); a blind-index column holds a 64-char hex digest — add an index on it.
|
|
1419
1453
|
- Ciphertext is non-deterministic (random IV), so `where(ssn: ...)` matches nothing — query through a blind index. `nil` stays `nil`; presence checks work normally.
|
|
1454
|
+
- `ssn_ciphertext` is `nil` while the field has an unsaved change (so it can never return the plaintext you just assigned), and `ssn_encrypted?` asks whether what is stored really is an envelope.
|
|
1420
1455
|
- Never `update_column(s)` an encrypted field — that bypasses the type and writes raw plaintext. Declaring a field with both `encryptable` and `auditable_by` raises (either order).
|
|
1421
1456
|
- Wrong key / tampered ciphertext / malformed envelope raise `Encryption::DecryptionError`. Encrypted field names are auto-registered with Rails' `filter_parameters` (via the gem's railtie), so they're redacted from request logs.
|
|
1422
1457
|
- Reach for [`lockbox`](https://github.com/ankane/lockbox) or Rails 7+ native `encrypts` when you need key rotation today or Rails-managed key infrastructure (rotation is planned — the envelope already reserves the `key_id` byte).
|
|
@@ -1470,6 +1505,8 @@ end
|
|
|
1470
1505
|
|
|
1471
1506
|
copy = invoice.duplicate # unsaved deep copy
|
|
1472
1507
|
copy = invoice.duplicate!(title: "Q3") # saved (one transaction, autosaved children)
|
|
1508
|
+
copy = invoice.duplicate!(except: :line_items) # this copy skips the line items
|
|
1509
|
+
copy = invoice.duplicate!(only: []) # shallow copy — attributes only
|
|
1473
1510
|
```
|
|
1474
1511
|
|
|
1475
1512
|
**Auto-reset identity columns** (no configuration): `created_at`/`updated_at`, Sluggable slug, Tokenizable/Hashable tokens, Sequenceable sequence + `into:` columns, Auditable trail, SoftDeletable timestamp, Lockable attempts/locked_at. Business state (Publishable, Stateable, …) is a judgment call — list it in `reset:`.
|
|
@@ -1478,6 +1515,7 @@ copy = invoice.duplicate!(title: "Q3") # saved (one transaction, autosaved chil
|
|
|
1478
1515
|
|
|
1479
1516
|
**Notes**
|
|
1480
1517
|
- The macro is optional — bare `include` gives `duplicate`/`duplicate!` with the auto resets.
|
|
1518
|
+
- `only:` / `except:` on `duplicate` / `duplicate!` pick which of the declared associations this particular copy carries ("Duplicate with line items?" checkbox); names outside the allow-list raise. They are reserved keys — pass overrides for attributes literally named `only`/`except` as a braced Hash.
|
|
1481
1519
|
- Override `on_duplicate(copy)` for custom tweaks; it receives the unsaved copy last.
|
|
1482
1520
|
- Reach for [`amoeba`](https://github.com/amoeba-rb/amoeba) when you need per-attribute regex/prepend rules or belongs_to graph copying.
|
|
1483
1521
|
|
|
@@ -1867,6 +1905,9 @@ class ApplicationController < ActionController::Base
|
|
|
1867
1905
|
# Preset headers, plus any custom "Header-Name" => "value" pairs:
|
|
1868
1906
|
secure_headers :nosniff, :sameorigin_frame, :no_referrer_leak, :disable_legacy_xss
|
|
1869
1907
|
secure_headers "Permissions-Policy" => "geolocation=()"
|
|
1908
|
+
# ...or the break-nothing baseline in one line, then relax what you must (later wins):
|
|
1909
|
+
secure_headers :recommended
|
|
1910
|
+
secure_headers :sameorigin_frame
|
|
1870
1911
|
|
|
1871
1912
|
# Delegates to Rails' native CSP DSL — roll out report-only FIRST:
|
|
1872
1913
|
content_security_policy_for(report_only: true) do |policy|
|
|
@@ -1887,6 +1928,14 @@ end
|
|
|
1887
1928
|
| `:no_referrer_leak` | `Referrer-Policy: strict-origin-when-cross-origin` |
|
|
1888
1929
|
| `:no_cross_domain` | `X-Permitted-Cross-Domain-Policies: none` |
|
|
1889
1930
|
| `:disable_legacy_xss` | `X-XSS-Protection: 0` (the only correct modern value) |
|
|
1931
|
+
| `:hsts` | `Strict-Transport-Security: max-age=31536000; includeSubDomains` (no `preload` — opt in via a custom pair) |
|
|
1932
|
+
| `:same_origin_opener` / `:same_origin_opener_allow_popups` | `Cross-Origin-Opener-Policy: same-origin` / `same-origin-allow-popups` |
|
|
1933
|
+
| `:require_corp_embedder` | `Cross-Origin-Embedder-Policy: require-corp` |
|
|
1934
|
+
| `:same_origin_resource` | `Cross-Origin-Resource-Policy: same-origin` |
|
|
1935
|
+
| `:no_sensitive_permissions` | `Permissions-Policy` denying camera, microphone, geolocation, payment, usb and motion sensors to everyone, your own pages included |
|
|
1936
|
+
| `:self_sensitive_permissions` | The same list scoped to `(self)` — denies third-party frames, keeps first-party use |
|
|
1937
|
+
|
|
1938
|
+
**Bundles** (expand to presets in place, so a later preset or custom pair still wins): `:recommended` = nosniff, deny_frame, no_referrer_leak, no_cross_domain, disable_legacy_xss, same_origin_opener_allow_popups, self_sensitive_permissions — deliberately *without* COEP/CORP (they block cross-origin embeds of your resources and CDN assets lacking CORP headers) and HSTS (belongs with `force_ssl`); relax `deny_frame` with `:sameorigin_frame` if the app frames itself; `:cross_origin_isolation` = same_origin_opener + require_corp_embedder + same_origin_resource (what SharedArrayBuffer / high-resolution timers require).
|
|
1890
1939
|
|
|
1891
1940
|
**Notes**
|
|
1892
1941
|
- Headers are applied in an `after_action`, so they reinforce Rails' middleware defaults; later `secure_headers` declarations win on a colliding name.
|
|
@@ -1911,7 +1960,9 @@ end
|
|
|
1911
1960
|
|
|
1912
1961
|
Resolution order: `params[param]` → first match in `Accept-Language` → `default` → `I18n.default_locale`. The chosen locale is always validated against `I18n.available_locales`, so a stray param or a mismatched `available:` list can never raise `I18n::InvalidLocale`.
|
|
1913
1962
|
|
|
1914
|
-
|
|
1963
|
+
Every response carries **`Content-Language: <resolved locale>`** (BCP 47 form — `pt_BR` → `pt-BR`) and, when `Accept-Language` is a locale source, **`Vary: Accept-Language`** appended to any existing `Vary` (de-duplicated) so shared caches key on the header. Both are written *before* the action runs, so a `rescue_from`-rendered error still carries them; `response_headers: false` turns them off.
|
|
1964
|
+
|
|
1965
|
+
**Options**: `available:` (allow-list for matching; defaults to `I18n.available_locales`), `default:`, `param:` (default `:locale`), `header:` (default `true`), `response_headers:` (default `true`).
|
|
1915
1966
|
|
|
1916
1967
|
---
|
|
1917
1968
|
|
|
@@ -2249,9 +2300,9 @@ Point your agent at `llms.txt` for an overview, or paste a single concern's `.md
|
|
|
2249
2300
|
|
|
2250
2301
|
```sh
|
|
2251
2302
|
bundle install # install dev dependencies
|
|
2252
|
-
bundle exec rspec # run the test suite (1,
|
|
2303
|
+
bundle exec rspec # run the test suite (1,522 examples)
|
|
2253
2304
|
gem build concerns_on_rails.gemspec # build the gem
|
|
2254
|
-
gem install ./concerns_on_rails-1.28.
|
|
2305
|
+
gem install ./concerns_on_rails-1.28.5.gem # install locally
|
|
2255
2306
|
|
|
2256
2307
|
# Preview the docs site locally (GitHub Pages serves docs/ as-is):
|
|
2257
2308
|
cd docs && python3 -m http.server 8000 # → http://localhost:8000
|
|
@@ -141,8 +141,15 @@ module ConcernsOnRails
|
|
|
141
141
|
# Module#=== checks the real ancestry, so `when Time` alone would
|
|
142
142
|
# miss it — and Time.current / 1.month.from_now are exactly the
|
|
143
143
|
# values Rails hosts pass.
|
|
144
|
-
|
|
145
|
-
|
|
144
|
+
# getutc, NOT utc: Time#utc is an alias of #gmtime, which converts the
|
|
145
|
+
# receiver IN PLACE and returns self. A host passing a frozen
|
|
146
|
+
# constant (SUNSET = Time.new(...).freeze) got a FrozenError while
|
|
147
|
+
# the controller class body was still loading — the app would not
|
|
148
|
+
# boot — and an unfrozen Time was silently rewritten to UTC behind
|
|
149
|
+
# the caller's back. TimeWithZone#utc is a harmless reader, but
|
|
150
|
+
# getutc is correct for both, so the branch stays single.
|
|
151
|
+
when ActiveSupport::TimeWithZone, Time then value.getutc
|
|
152
|
+
when DateTime then value.to_time.getutc
|
|
146
153
|
when Date then Time.utc(value.year, value.month, value.day)
|
|
147
154
|
when String then parse_deprecation_string(value)
|
|
148
155
|
end
|
|
@@ -247,7 +254,12 @@ module ConcernsOnRails
|
|
|
247
254
|
return unless deprecation_sunset_reached?(rule)
|
|
248
255
|
|
|
249
256
|
message = "This endpoint was sunset on #{rule[:sunset_at].httpdate}."
|
|
250
|
-
|
|
257
|
+
# respond_to?(..., true) for the same reason Support::ErrorEnvelope
|
|
258
|
+
# uses it: render_error is very often declared under `private`. The
|
|
259
|
+
# public-only check skipped the 410 for exactly those controllers and
|
|
260
|
+
# let the sunset action run — a fail-open on the branch whose job is to
|
|
261
|
+
# stop serving the endpoint.
|
|
262
|
+
return unless respond_to?(:render_error, true) || (respond_to?(:response) && response)
|
|
251
263
|
|
|
252
264
|
ConcernsOnRails::Support::ErrorEnvelope.render(self, message: message, status: :gone, code: "endpoint_sunset")
|
|
253
265
|
end
|
|
@@ -42,12 +42,12 @@ module ConcernsOnRails
|
|
|
42
42
|
end
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
-
# Apply all declared filters to a relation based on params.
|
|
46
|
-
# are skipped so
|
|
45
|
+
# Apply all declared filters to a relation based on params. Unset values
|
|
46
|
+
# are skipped so absent filters don't narrow the relation.
|
|
47
47
|
def filtered(relation)
|
|
48
48
|
self.class.filterable_rules.each do |field, options|
|
|
49
49
|
value = params[field]
|
|
50
|
-
next if value
|
|
50
|
+
next if filterable_unset?(value)
|
|
51
51
|
|
|
52
52
|
relation = apply_filter(relation, field, value, options)
|
|
53
53
|
end
|
|
@@ -56,11 +56,29 @@ module ConcernsOnRails
|
|
|
56
56
|
|
|
57
57
|
private
|
|
58
58
|
|
|
59
|
+
# NOT `value.blank?`: `false.blank?` is true, so a genuine boolean false
|
|
60
|
+
# read as "filter not supplied" and the relation came back UNFILTERED —
|
|
61
|
+
# `filter_by :active` could never select the inactive rows. Query strings
|
|
62
|
+
# were unaffected (they carry the String "false", which is not blank), so
|
|
63
|
+
# this only bit JSON request bodies, where the value really is `false`.
|
|
64
|
+
# Everything actually empty — nil, "", " ", [], {} — is still skipped.
|
|
65
|
+
def filterable_unset?(value)
|
|
66
|
+
return false if value == false
|
|
67
|
+
return true if value.nil?
|
|
68
|
+
|
|
69
|
+
value.respond_to?(:blank?) ? value.blank? : false
|
|
70
|
+
end
|
|
71
|
+
|
|
59
72
|
def apply_filter(relation, field, value, options)
|
|
60
73
|
if options[:with]
|
|
61
74
|
options[:with].call(relation, value)
|
|
62
75
|
elsif options[:scope]
|
|
63
|
-
|
|
76
|
+
# Scope mode discards the value, so an explicit `false` can only mean
|
|
77
|
+
# "do not apply this scope" — applying it would hand the client the
|
|
78
|
+
# exact opposite of what it asked for. (A query string still carries
|
|
79
|
+
# the String "false", which has always triggered the scope; only a
|
|
80
|
+
# real boolean is read as a negation.)
|
|
81
|
+
value == false ? relation : relation.public_send(options[:scope])
|
|
64
82
|
elsif filterable_scalar?(value)
|
|
65
83
|
relation.where(field => value)
|
|
66
84
|
else
|
|
@@ -18,9 +18,15 @@ module ConcernsOnRails
|
|
|
18
18
|
# against `I18n.available_locales` before use, so a stray param or a
|
|
19
19
|
# mismatched `available:` list can never raise `I18n::InvalidLocale`.
|
|
20
20
|
#
|
|
21
|
+
# Every response carries `Content-Language: <resolved locale>` (BCP 47
|
|
22
|
+
# form, `pt_BR` → `pt-BR`) and, when the header is a locale source,
|
|
23
|
+
# `Vary: Accept-Language` appended to any existing Vary — written before
|
|
24
|
+
# the action runs, so a rescued error still carries them. Both are behind
|
|
25
|
+
# `response_headers:` (default `true`).
|
|
26
|
+
#
|
|
21
27
|
# Options: `available:` (allow-list for param/header matching; defaults to
|
|
22
28
|
# `I18n.available_locales`), `default:`, `param:` (default `:locale`),
|
|
23
|
-
# `header:` (default `true`).
|
|
29
|
+
# `header:` (default `true`), `response_headers:` (default `true`).
|
|
24
30
|
module Localizable
|
|
25
31
|
extend ActiveSupport::Concern
|
|
26
32
|
|
|
@@ -30,19 +36,23 @@ module ConcernsOnRails
|
|
|
30
36
|
end
|
|
31
37
|
|
|
32
38
|
class_methods do
|
|
33
|
-
def localizable(available: nil, default: nil, param: :locale, header: true)
|
|
39
|
+
def localizable(available: nil, default: nil, param: :locale, header: true, response_headers: true)
|
|
34
40
|
self.localizable_options = {
|
|
35
41
|
available: available&.map(&:to_sym),
|
|
36
42
|
default: default&.to_sym,
|
|
37
43
|
param: param&.to_sym,
|
|
38
|
-
header: header
|
|
44
|
+
header: header,
|
|
45
|
+
response_headers: response_headers ? true : false
|
|
39
46
|
}
|
|
40
47
|
end
|
|
41
48
|
end
|
|
42
49
|
|
|
43
|
-
# Public so subclasses can override;
|
|
50
|
+
# Public so subclasses can override; writes the response headers, then
|
|
51
|
+
# runs the action under the resolved locale.
|
|
44
52
|
def switch_locale(&)
|
|
45
|
-
|
|
53
|
+
locale = resolved_locale
|
|
54
|
+
apply_locale_response_headers(locale)
|
|
55
|
+
I18n.with_locale(locale, &)
|
|
46
56
|
end
|
|
47
57
|
|
|
48
58
|
# The locale chosen for this request — always one I18n can switch to.
|
|
@@ -60,6 +70,49 @@ module ConcernsOnRails
|
|
|
60
70
|
|
|
61
71
|
private
|
|
62
72
|
|
|
73
|
+
# Content-Language always; Vary: Accept-Language only when the header can
|
|
74
|
+
# influence the choice (a param-only setup already differs by URL).
|
|
75
|
+
# Vary is appended and de-duplicated, never clobbered (Cacheable, the
|
|
76
|
+
# paginators' Link header — same rule).
|
|
77
|
+
def apply_locale_response_headers(locale)
|
|
78
|
+
return unless locale_response_headers?
|
|
79
|
+
|
|
80
|
+
response.set_header("Content-Language", locale.to_s.tr("_", "-"))
|
|
81
|
+
# Only advertise the dimension the resolver actually consults: with
|
|
82
|
+
# `header: false` (or no `localizable` call at all) Accept-Language
|
|
83
|
+
# cannot change the answer.
|
|
84
|
+
append_vary_accept_language if self.class.localizable_options[:header]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def locale_response_headers?
|
|
88
|
+
opts = self.class.localizable_options
|
|
89
|
+
return false if opts.key?(:response_headers) && !opts[:response_headers]
|
|
90
|
+
|
|
91
|
+
respond_to?(:response) && response.respond_to?(:set_header)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def append_vary_accept_language
|
|
95
|
+
existing = response.headers["Vary"].to_s.split(",").map(&:strip).reject(&:empty?)
|
|
96
|
+
return if existing.include?("*")
|
|
97
|
+
|
|
98
|
+
# Rails adds its own `Vary: Accept` during render, but ONLY while the
|
|
99
|
+
# header is still blank (ActionController::Rendering#_set_vary_header).
|
|
100
|
+
# Writing ours before the action would therefore SUPPRESS it and cost a
|
|
101
|
+
# cache dimension, so seed Accept ourselves whenever Rails would have.
|
|
102
|
+
merged = existing + vary_accept_dimension + ["Accept-Language"]
|
|
103
|
+
deduped = merged.each_with_object([]) do |value, list|
|
|
104
|
+
list << value unless list.any? { |seen| seen.casecmp?(value) }
|
|
105
|
+
end
|
|
106
|
+
response.set_header("Vary", deduped.join(", "))
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def vary_accept_dimension
|
|
110
|
+
return [] unless respond_to?(:request, true) && (req = request)
|
|
111
|
+
return [] unless req.respond_to?(:should_apply_vary_header?) && req.should_apply_vary_header?
|
|
112
|
+
|
|
113
|
+
["Accept"]
|
|
114
|
+
end
|
|
115
|
+
|
|
63
116
|
def locale_from_param(opts, allowed)
|
|
64
117
|
return nil unless opts[:param] && respond_to?(:params) && params
|
|
65
118
|
|
|
@@ -36,6 +36,21 @@ module ConcernsOnRails
|
|
|
36
36
|
LABEL = "ConcernsOnRails::Controllers::Paginatable".freeze
|
|
37
37
|
DEFAULT_PER_PAGE = 25
|
|
38
38
|
DEFAULT_MAX_PER_PAGE = 200
|
|
39
|
+
# Upper bound on the requested page. `page` is untrusted input and its
|
|
40
|
+
# only job is to become `(page - 1) * per_page`, so an unbounded value
|
|
41
|
+
# produced an offset no backend can take: the relation branch raised
|
|
42
|
+
# StatementInvalid and Array#[] raised RangeError ("bignum too big to
|
|
43
|
+
# convert into `long'") — a 500 from `?page=99999999999999999999`.
|
|
44
|
+
# Clamping keeps the request in range; the page is far past any real
|
|
45
|
+
# dataset, so it simply comes back empty. Deep pagination at this depth
|
|
46
|
+
# wants Controllers::CursorPaginatable instead.
|
|
47
|
+
MAX_PAGE = 1_000_000
|
|
48
|
+
# The same guard for per_page. `max_per_page: 0` is documented as "no
|
|
49
|
+
# cap", and with no cap the identical untrusted value overflowed LIMIT
|
|
50
|
+
# instead of OFFSET — the same unauthenticated 500, one option away. "No
|
|
51
|
+
# cap" means no CONFIGURED cap, not an unbounded LIMIT; a page of a
|
|
52
|
+
# million records is already far past what any client can render.
|
|
53
|
+
MAX_PER_PAGE = 1_000_000
|
|
39
54
|
|
|
40
55
|
included do
|
|
41
56
|
class_attribute :paginatable_per_page, default: DEFAULT_PER_PAGE
|
|
@@ -59,8 +74,8 @@ module ConcernsOnRails
|
|
|
59
74
|
# paginate_by per_page: 50, max_per_page: 500, link_header: false
|
|
60
75
|
def paginate_by(per_page: DEFAULT_PER_PAGE, max_per_page: DEFAULT_MAX_PER_PAGE, link_header: true,
|
|
61
76
|
page_param: nil, per_page_param: nil, style: :flat, window: nil)
|
|
62
|
-
self.paginatable_per_page = per_page
|
|
63
|
-
self.paginatable_max_per_page = max_per_page
|
|
77
|
+
self.paginatable_per_page = paginatable_per_page!(per_page)
|
|
78
|
+
self.paginatable_max_per_page = paginatable_max_per_page!(max_per_page)
|
|
64
79
|
self.paginatable_link_header = link_header ? true : false
|
|
65
80
|
self.paginatable_window = paginatable_window!(window)
|
|
66
81
|
defaults = paginatable_style_params!(style)
|
|
@@ -79,6 +94,29 @@ module ConcernsOnRails
|
|
|
79
94
|
end
|
|
80
95
|
end
|
|
81
96
|
|
|
97
|
+
# per_page must be positive. A bare `.to_i` let a negative through, and
|
|
98
|
+
# `LIMIT -1` means NO LIMIT on SQLite and MySQL — so `per_page: -1`
|
|
99
|
+
# silently serialized the entire table on every request, while
|
|
100
|
+
# `per_page: 0` made every page permanently empty. Both are broken
|
|
101
|
+
# configuration with no sane reading, so they raise at class-load time
|
|
102
|
+
# rather than misbehaving on every request.
|
|
103
|
+
def paginatable_per_page!(value)
|
|
104
|
+
size = value.to_i
|
|
105
|
+
return size if size.positive?
|
|
106
|
+
|
|
107
|
+
raise ArgumentError, "#{LABEL}: per_page: must be a positive integer (got #{value.inspect})"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# max_per_page does NOT raise: "0 or a negative integer disables the
|
|
111
|
+
# cap" is this option's documented contract, so rejecting a negative
|
|
112
|
+
# would fail the boot of an app that is configured exactly as written —
|
|
113
|
+
# and on a patch upgrade at that. Normalize to 0 instead; the reader's
|
|
114
|
+
# guard only asks whether the cap is positive.
|
|
115
|
+
def paginatable_max_per_page!(value)
|
|
116
|
+
size = value.to_i
|
|
117
|
+
size.negative? ? 0 : size
|
|
118
|
+
end
|
|
119
|
+
|
|
82
120
|
# nil / false disable the window (no `pages:` key). `0` is meaningful:
|
|
83
121
|
# first, current and last only.
|
|
84
122
|
def paginatable_window!(value)
|
|
@@ -206,14 +244,18 @@ module ConcernsOnRails
|
|
|
206
244
|
# Both readers route through ScalarParam: `?page[]=1` / `?page[x]=1`
|
|
207
245
|
# arrive as Array/Parameters, and calling .to_i on those was a 500.
|
|
208
246
|
def pagination_page
|
|
209
|
-
|
|
247
|
+
requested = ConcernsOnRails::Support::ScalarParam.to_i(pagination_param(self.class.paginatable_page_param), default: 0)
|
|
248
|
+
requested.clamp(1, MAX_PAGE)
|
|
210
249
|
end
|
|
211
250
|
|
|
212
251
|
def pagination_per_page
|
|
213
252
|
requested = ConcernsOnRails::Support::ScalarParam.to_i(pagination_param(self.class.paginatable_per_page_param), default: 0)
|
|
214
253
|
requested = self.class.paginatable_per_page if requested < 1
|
|
215
254
|
cap = self.class.paginatable_max_per_page
|
|
216
|
-
|
|
255
|
+
requested = [requested, cap].min if cap.positive?
|
|
256
|
+
# Applied even when a cap IS configured: `max_per_page: 10**30` is its
|
|
257
|
+
# own way of asking for the overflow back.
|
|
258
|
+
[requested, MAX_PER_PAGE].min
|
|
217
259
|
end
|
|
218
260
|
|
|
219
261
|
# Dig the configured path out of params: `["page"]` → params[:page];
|