concerns_on_rails 1.28.7 → 1.28.8
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 +125 -0
- data/README.md +81 -15
- data/lib/concerns_on_rails/controllers/filterable.rb +213 -10
- data/lib/concerns_on_rails/controllers/respondable.rb +120 -3
- data/lib/concerns_on_rails/controllers/webhook_verifiable.rb +152 -2
- data/lib/concerns_on_rails/encryption.rb +59 -0
- data/lib/concerns_on_rails/models/duplicable.rb +9 -1
- data/lib/concerns_on_rails/models/encryptable.rb +196 -18
- data/lib/concerns_on_rails/models/lockable.rb +138 -16
- data/lib/concerns_on_rails/models/stateable.rb +84 -13
- data/lib/concerns_on_rails/support/encryptor.rb +33 -11
- data/lib/concerns_on_rails/support/error_envelope.rb +20 -4
- data/lib/concerns_on_rails/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3ae4fb29ea12389fc8aaff341a565a482bf3c8ef99fdcfc7791c64c7302d1ca5
|
|
4
|
+
data.tar.gz: 02ea08bceb25e2a479e27ef00342c69cfeac2ffc8129b689269f423819c0c0ae
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4ac2edd1717ef0e4c157b851e75554ba057321e1ae9f283bcdb015e9e6d49d63f1f4bf9bc9ee4419e0b28a111487147516957a03b17ae3915c7ac23f8a721109
|
|
7
|
+
data.tar.gz: 826dd0234e22faa23b089fbedd7900c8bc847f8f7af5670c6a6b0dafc22ec838b29e0cf196b0269b8dde798302d43c677eb4c93d60396db207d6e733d0f16ff2
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,130 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 1.28.8 (2026-09-19)
|
|
4
|
+
|
|
5
|
+
The last six open feature PRs, released as a patch by request. These had never been
|
|
6
|
+
reviewed — they were excluded from the previous two waves because they would not merge —
|
|
7
|
+
so each was rebased onto master, reviewed adversarially, and fixed. **Five of the six
|
|
8
|
+
carried a CRITICAL defect**, two of which could destroy data. The `### Fixed` section is
|
|
9
|
+
the important one; several entries describe behaviour that never worked as documented.
|
|
10
|
+
|
|
11
|
+
Also fixes a live defect in `Support::ErrorEnvelope` found during the review, which
|
|
12
|
+
affected eight concerns on 1.28.7 and earlier. 1828 examples, 0 failures.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- **Models::Encryptable**: key rotation. `ConcernsOnRails.configure_encryption { |c|
|
|
16
|
+
c.key_id = 1; c.previous_keys = { 0 => old } }` — new writes stamp `key_id`, reads
|
|
17
|
+
decrypt with whichever configured key the envelope names (unknown id → `DecryptionError`
|
|
18
|
+
naming it), and blind-index lookups match current + previous digests.
|
|
19
|
+
`Model.needs_reencryption(*fields)`, `Model.reencrypt_all!(*fields)` /
|
|
20
|
+
`record.reencrypt!`, and `<field>_key_id`. Per-field `key:` fields sit outside rotation.
|
|
21
|
+
The envelope format is unchanged: 1.28.x already wrote and authenticated a `key_id` byte,
|
|
22
|
+
so every existing row is a key-id-0 envelope and decrypts untouched after upgrading. (#77)
|
|
23
|
+
- **Models::Lockable**: `lockable_by … unlock_token:` mints a URL-safe token in the same
|
|
24
|
+
write as the lock, for self-service unlock links without Devise.
|
|
25
|
+
`User.unlock_by_token(token)` consumes it once — constant-time compare, one conditional
|
|
26
|
+
UPDATE decides the winner, hooks fire — and the token lives exactly as long as the lock.
|
|
27
|
+
Cleared on every unlock path, reset by Duplicable, and registered with the
|
|
28
|
+
filter_parameters registry. (#61)
|
|
29
|
+
- **Models::Stateable**: `stateable_by … timestamps: true` (or a list of states) stamps
|
|
30
|
+
`<state>_at` in the same write as the state change, for guarded events, direct setters,
|
|
31
|
+
`transition_to!` and `transition_all`. Per-event `before_<event>` / `after_<event>` hooks
|
|
32
|
+
fire inside the generic `before_transition` / `after_transition` pair and the same
|
|
33
|
+
transaction, and may be declared `private`. (#49)
|
|
34
|
+
- **Controllers::Filterable**: `filter_by … operators: true` (or a subset) adds `not`,
|
|
35
|
+
`gt`, `gte`, `lt`, `lte`, `in`, `not_in`, `null`, `contains` and `starts_with` to
|
|
36
|
+
direct-where filters, read as a suffix (`?price_gte=10`) or in bracket form
|
|
37
|
+
(`?price[gte]=10`). Every operator comes from a frozen allow-list mapped to a fixed Arel
|
|
38
|
+
node, so no param string reaches SQL. Values are cast through the column's type or the
|
|
39
|
+
new `type:` option, which also pre-casts what a `with:` lambda receives. Opt-in per
|
|
40
|
+
filter; existing filters are unchanged. (#46)
|
|
41
|
+
- **Controllers::WebhookVerifiable**: `verify_webhook … replay:` / `replay_ttl:` rejects a
|
|
42
|
+
redelivery with 409 `webhook_replayed` — replay protection for the schemes that carry no
|
|
43
|
+
timestamp (GitHub, Shopify, plain HMAC). The check runs only after the signature
|
|
44
|
+
verifies. A short claim is taken before the action and promoted on completion, and
|
|
45
|
+
released on a 5xx so a transient failure does not burn the delivery id. (#60)
|
|
46
|
+
- **Controllers::Respondable**: `render_success` accepts `location:` and `headers:`, and
|
|
47
|
+
gains `render_created(data:, location:)` (201 + `Location`) and `render_invalid(record)`
|
|
48
|
+
(422 + the record's `full_messages`). Header names and values are stripped of bytes a
|
|
49
|
+
header line cannot carry, and a `Link` value is appended to an existing one — matched
|
|
50
|
+
case-insensitively — rather than replacing it. Works in both the envelope and the
|
|
51
|
+
problem-details format. (#105, replacing #74)
|
|
52
|
+
|
|
53
|
+
### Changed
|
|
54
|
+
- **Controllers::Filterable**: an uncastable comparison value (`?price_gte=abc`) returns
|
|
55
|
+
`none` rather than matching. Fail-closed was chosen because ignoring the filter would
|
|
56
|
+
return the very rows the client tried to exclude; it matches what plain `where` already
|
|
57
|
+
does with the same input, and is scoped to the four new comparison operators so no
|
|
58
|
+
pre-existing filter changes on upgrade. `contains` / `starts_with` are case-INsensitive
|
|
59
|
+
on every adapter — there is no `case_sensitive:` option. (#46)
|
|
60
|
+
- **Models::Stateable**: `timestamps:` refuses to derive a stamp column from a state named
|
|
61
|
+
`created` or `updated`, which would otherwise target Rails' own `created_at`/`updated_at`.
|
|
62
|
+
Stamp columns are NOT affixed by `prefix:`/`suffix:`, so a state named `published` or
|
|
63
|
+
`deleted` collides with Publishable's and SoftDeletable's columns — rename the state or
|
|
64
|
+
leave it out of `timestamps:`. (#49)
|
|
65
|
+
- **Models::Encryptable**: `record.reencrypt!` rewrites through a guarded `UPDATE` that
|
|
66
|
+
matches the ciphertext read at load, then reloads. A row written by someone else in the
|
|
67
|
+
meantime is left alone instead of being reverted, and a field with unsaved changes is
|
|
68
|
+
skipped rather than silently committed. (#77)
|
|
69
|
+
|
|
70
|
+
### Fixed
|
|
71
|
+
- **Support::ErrorEnvelope**: the `errors:` keyword was passed to a host app's
|
|
72
|
+
`render_error` whenever details were present, but several concerns document the override
|
|
73
|
+
contract as `render_error(message:, status:, code:)`. Such an app got `ArgumentError:
|
|
74
|
+
unknown keyword: :errors` at request time — a 500 instead of the 4xx, on exactly the path
|
|
75
|
+
that has something to report. The previous guard tested `details` alone, so it covered
|
|
76
|
+
only the empty case, i.e. the one that was never broken. This reached every concern that
|
|
77
|
+
funnels through the envelope, most commonly ErrorHandleable's rescued `RecordInvalid`.
|
|
78
|
+
- **Models::Encryptable**: `previous_keys=` printed the offending hash's keys on a bad
|
|
79
|
+
shape, so an inverted `{ ENV["KEY_V1"] => 0 }` raised **with the live key in the exception
|
|
80
|
+
message** — into logs, backtraces and error trackers. Only the value's class is reported
|
|
81
|
+
now. (#77)
|
|
82
|
+
- **Models::Encryptable**: `needs_reencryption` detected MySQL by adapter name, so Trilogy
|
|
83
|
+
(Rails 7.1+) and MariaDB fell through to a case-folding comparison and returned **zero
|
|
84
|
+
rows**. `reencrypt_all!` then reported 0, an operator would conclude the rotation was
|
|
85
|
+
complete and drop the old key, and every pre-rotation row would become permanently
|
|
86
|
+
undecryptable. (#77)
|
|
87
|
+
- **Models::Lockable**: a `before_unlock` / `after_unlock` hook that vetoed the unlock
|
|
88
|
+
permanently burned the token while leaving the account locked — the claim ran in a bare
|
|
89
|
+
transaction that joined the enclosing one, so `unlock_access!`'s savepoint rolled back
|
|
90
|
+
while the claim committed. The user's emailed link was dead with no way back. The claim
|
|
91
|
+
now takes its own savepoint and is rolled back with the unlock. (#61)
|
|
92
|
+
- **Models::Lockable**: `unlock_by_token` no longer honours a token once the lock has
|
|
93
|
+
lapsed, and no longer uses `unscoped` — a mailed link could otherwise reach a row hidden
|
|
94
|
+
behind a `default_scope` (a soft-deleted or deactivated account). (#61)
|
|
95
|
+
- **Controllers::WebhookVerifiable**: a store answering `#write` but not `#read` passed
|
|
96
|
+
declaration-time validation and then provided **no replay protection at all**, silently —
|
|
97
|
+
the endpoint looked protected and accepted every redelivery. Both methods are now
|
|
98
|
+
required. (#60)
|
|
99
|
+
- **Controllers::WebhookVerifiable**: the Stripe replay key hashed the raw
|
|
100
|
+
`Stripe-Signature` header, which is *parsed* rather than compared — appending an unknown
|
|
101
|
+
`v0=…` pair or re-spacing the commas produced a still-valid header with a fresh key,
|
|
102
|
+
defeating the protection. Stripe now keys off the signed `"<timestamp>.<body>"` payload.
|
|
103
|
+
(#60)
|
|
104
|
+
- **Controllers::WebhookVerifiable**: `replay: false` raised at class load, so
|
|
105
|
+
`replay: Rails.env.production?` broke controller loading in development and test. It is
|
|
106
|
+
now a synonym for "off". (#60)
|
|
107
|
+
- **Controllers::Respondable**: caller-supplied header **names** reached the response
|
|
108
|
+
unsanitized, so a CR/LF in an interpolated name was response splitting. Names now go
|
|
109
|
+
through the same filter as values, widened from CR/LF to the full illegal-byte range.
|
|
110
|
+
(#105)
|
|
111
|
+
- **Controllers::Filterable**: a bracket-form operator with a blank value
|
|
112
|
+
(`?price[gte]=`) became `price > NULL` and returned **nothing**, while the documented
|
|
113
|
+
equivalent `?price_gte=` correctly returned everything — an empty min/max box silently
|
|
114
|
+
emptied the result set. Both forms now skip blanks, and both honour a boolean `false`.
|
|
115
|
+
(#46)
|
|
116
|
+
- **Controllers::Filterable**: `?stock_gt=twelve` silently became `stock > 0` and returned
|
|
117
|
+
the stocked rows (`ActiveModel::Type::Integer#cast("twelve")` is `0`, not `nil`), while
|
|
118
|
+
the same typo on a datetime column returned none — one malformed request answered two
|
|
119
|
+
opposite ways. (#46)
|
|
120
|
+
- **Controllers::Filterable**: LIKE escaping no longer relies on `sanitize_sql_like`, which
|
|
121
|
+
is not public before Rails 5.1 while the gemspec claims `>= 5.0`. (#46)
|
|
122
|
+
|
|
123
|
+
### Internal
|
|
124
|
+
- Docs corrected: "Never `update_column(s)` an encrypted field — those bypass the type and
|
|
125
|
+
write raw plaintext" was **false** (`update_columns` serializes through the attribute
|
|
126
|
+
type), and `reencrypt_all!` is built on it. (#77)
|
|
127
|
+
|
|
3
128
|
## 1.28.7 (2026-09-19)
|
|
4
129
|
|
|
5
130
|
The eight PRs held back from 1.28.6, released as a patch by request. Each carried a
|
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,828 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
|
---
|
|
@@ -1035,6 +1035,26 @@ guard rejects are skipped (not errors). Unlike every other batch verb in this ge
|
|
|
1035
1035
|
the guarded `<event>!` method, because that path runs validations via `update!` while a bulk
|
|
1036
1036
|
`update_all` would silently skip them.
|
|
1037
1037
|
|
|
1038
|
+
**Timestamps and per-event hooks**
|
|
1039
|
+
|
|
1040
|
+
```ruby
|
|
1041
|
+
stateable_by :status, states: %i[draft review published archived], default: :draft,
|
|
1042
|
+
timestamps: true, # or %i[published archived] — stamps <state>_at
|
|
1043
|
+
transitions: { publish: { from: %i[draft review], to: :published }, archive: { to: :archived } }
|
|
1044
|
+
|
|
1045
|
+
article.publish! # status = "published" AND published_at = Time.current, in ONE update!
|
|
1046
|
+
article.archived! # direct setters and transition_to! stamp too; the default state on create does not
|
|
1047
|
+
|
|
1048
|
+
def before_publish = check_embargo! # per-event hooks, fired inside the generic pair and the
|
|
1049
|
+
def after_publish = notify_subscribers # same transaction: before_transition → before_publish →
|
|
1050
|
+
# write → after_publish → after_transition
|
|
1051
|
+
```
|
|
1052
|
+
|
|
1053
|
+
`timestamps:` requires the `<state>_at` columns (checked at class load, one typed migration hint); the column is
|
|
1054
|
+
**not** affixed, and one Rails owns (`created_at` / `updated_at`) is refused. Per-event hooks follow the affixed
|
|
1055
|
+
event name (`before_status_publish` with `prefix: true`), may be private, and — like the generic hooks — fire only
|
|
1056
|
+
for guarded `<event>!` transitions. `Model.stateable_timestamps` lists the stamped states.
|
|
1057
|
+
|
|
1038
1058
|
**Prefix / suffix** — avoid clashes when the state names overlap with other concerns or scopes:
|
|
1039
1059
|
|
|
1040
1060
|
```ruby
|
|
@@ -1360,7 +1380,7 @@ One entry is recorded **per changed field per save** (creates record `"from" =>
|
|
|
1360
1380
|
|
|
1361
1381
|
## 🔐 Lockable
|
|
1362
1382
|
|
|
1363
|
-
Failed-attempt tracking + **account lockout** ("Devise lockable-lite") for apps rolling their own authentication (Rails 8 auth generator / `has_secure_password`) — which ships **no brute-force protection** out of the box. Two columns on the model's own table
|
|
1383
|
+
Failed-attempt tracking + **account lockout** ("Devise lockable-lite") for apps rolling their own authentication (Rails 8 auth generator / `has_secure_password`) — which ships **no brute-force protection** out of the box. Two columns on the model's own table (plus an optional unlock-token column for self-service unlock links); no mailers.
|
|
1364
1384
|
|
|
1365
1385
|
```ruby
|
|
1366
1386
|
class User < ApplicationRecord
|
|
@@ -1380,9 +1400,14 @@ user.unlock_access! # manual unlock (hooks: before/after_unlock)
|
|
|
1380
1400
|
User.locked / User.unlocked # expiry-aware scopes
|
|
1381
1401
|
|
|
1382
1402
|
User.unlock_expired # => 3 — unlocks every row whose unlock_in window has elapsed
|
|
1403
|
+
|
|
1404
|
+
# Self-service unlock (Devise's :email strategy, minus the mailer) — needs a string column:
|
|
1405
|
+
# lockable_by max_attempts: 5, unlock_token: :unlock_token
|
|
1406
|
+
user.lock_access!; user.unlock_token # minted in the same write as the lock — mail it as a link
|
|
1407
|
+
User.unlock_by_token(params[:token]) # constant-time lookup; unlocks once (hooks fire), returns the user or nil
|
|
1383
1408
|
```
|
|
1384
1409
|
|
|
1385
|
-
**Options**: `attempts:` (`:failed_attempts`, must be an integer column), `locked_at:` (`:locked_at`, datetime column), `max_attempts:` (`5`; `nil` = count but never auto-lock), `unlock_in:` (`nil` = locked until manual unlock; a duration makes the lock lapse by itself), `prefix:` / `suffix:` (affix the scope names).
|
|
1410
|
+
**Options**: `attempts:` (`:failed_attempts`, must be an integer column), `locked_at:` (`:locked_at`, datetime column), `max_attempts:` (`5`; `nil` = count but never auto-lock), `unlock_in:` (`nil` = locked until manual unlock; a duration makes the lock lapse by itself), `unlock_token:` (`nil`; a string column that receives a 43-char URL-safe token on lock, is cleared by every unlock path, and is honoured only while the lock is live — so `unlock_in:` doubles as the link's TTL), `prefix:` / `suffix:` (affix the scope names).
|
|
1386
1411
|
|
|
1387
1412
|
**Bulk operations**
|
|
1388
1413
|
|
|
@@ -1532,13 +1557,31 @@ Patient.where_email("a@b.com") # chainable Relation (accepts arrays too)
|
|
|
1532
1557
|
|
|
1533
1558
|
**Options** (`encryptable *fields, …`, repeatable): `type:` (cast the decrypted value — `:string` default, `:integer`, `:float`, `:decimal`, `:boolean`, `:date`, `:datetime`), `key:` (per-field override; a String or lazy Proc), `blind_index:` (`true`, or `{ column:, expression: }` — maintains a deterministic keyed-HMAC companion column, default `<field>_bidx`, for equality lookups; `expression:` normalizes symmetrically on write and query).
|
|
1534
1559
|
|
|
1560
|
+
**Key rotation** — bump the key id, keep the old key for decrypting, re-encrypt, drop the old key:
|
|
1561
|
+
|
|
1562
|
+
```ruby
|
|
1563
|
+
ConcernsOnRails.configure_encryption do |c|
|
|
1564
|
+
c.key = ENV["ENCRYPTION_KEY_V2"] # encrypts every new write
|
|
1565
|
+
c.key_id = 1 # stamped into the envelope header (any id 0..255)
|
|
1566
|
+
c.previous_keys = { 0 => ENV["ENCRYPTION_KEY_V1"] } # still DECRYPTS rows written before the rotation
|
|
1567
|
+
end
|
|
1568
|
+
|
|
1569
|
+
Patient.needs_reencryption.count # rows still under an old key — a prefix compare on the envelope, no decryption
|
|
1570
|
+
Patient.reencrypt_all! # rewrite them (and their blind indexes) under the current key → count
|
|
1571
|
+
patient.ssn_key_id # => 1
|
|
1572
|
+
# then remove `0 =>` from previous_keys
|
|
1573
|
+
```
|
|
1574
|
+
|
|
1575
|
+
Reads pick the key by the envelope's id, so old and new rows coexist; `find_by_<field>` / `where_<field>` match blind-index digests under the current **and** previous keys during the window. Per-field `key:` fields sit outside rotation.
|
|
1576
|
+
|
|
1535
1577
|
**Notes**
|
|
1536
1578
|
- 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.
|
|
1537
1579
|
- Ciphertext is non-deterministic (random IV), so `where(ssn: ...)` matches nothing — query through a blind index. `nil` stays `nil`; presence checks work normally.
|
|
1538
1580
|
- `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.
|
|
1539
|
-
-
|
|
1581
|
+
- `update_column(s)` on an encrypted field DOES encrypt (the value still serializes through the attribute type), but it skips validations, callbacks, dirty tracking and the blind-index refresh — so a value written that way is unsearchable until the row is saved normally. Declaring a field with both `encryptable` and `auditable_by` raises (either order).
|
|
1540
1582
|
- 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.
|
|
1541
|
-
-
|
|
1583
|
+
- Rotation is gem-level (`key_id` / `previous_keys`); `reencrypt_all!` streams with `find_each` and rewrites each row with one UPDATE — no validations/callbacks (only the ciphertext changes), guarded on the ciphertext it read so a concurrent write is never reverted, and skipping any field with an unsaved change. A row whose key id is no longer configured raises `DecryptionError` naming the id.
|
|
1584
|
+
- Reach for [`lockbox`](https://github.com/ankane/lockbox) or Rails 7+ native `encrypts` when you need Rails-managed key infrastructure (KMS, per-record keys) or deterministic encryption.
|
|
1542
1585
|
|
|
1543
1586
|
---
|
|
1544
1587
|
|
|
@@ -1753,6 +1796,8 @@ class ArticlesController < ApplicationController
|
|
|
1753
1796
|
filter_by :status, :category # ?status=draft → .where(status: 'draft')
|
|
1754
1797
|
filter_by :published, scope: :published # ?published=1 → Article.published
|
|
1755
1798
|
filter_by :q, with: ->(rel, v) { rel.where("title ILIKE ?", "%#{v}%") }
|
|
1799
|
+
filter_by :price, :created_at, operators: true # ?price_gte=10&created_at_lt=2026-01-01
|
|
1800
|
+
filter_by :min_stock, type: :integer, with: ->(rel, v) { rel.where(rel.model.arel_table[:stock].gteq(v)) }
|
|
1756
1801
|
|
|
1757
1802
|
def index
|
|
1758
1803
|
render json: filtered(Article.all)
|
|
@@ -1760,6 +1805,24 @@ class ArticlesController < ApplicationController
|
|
|
1760
1805
|
end
|
|
1761
1806
|
```
|
|
1762
1807
|
|
|
1808
|
+
**Operators** (opt-in per filter, direct-where mode only — `operators: true` or a subset like `%i[gte lte]`),
|
|
1809
|
+
accepted as a suffix `?price_gte=10` or in bracket form `?price[gte]=10&price[lte]=50`:
|
|
1810
|
+
|
|
1811
|
+
| Operator | Param | SQL |
|
|
1812
|
+
|----------|------------------------------------|---------------------------------------|
|
|
1813
|
+
| `not` | `?status_not=draft` | `status != 'draft'` |
|
|
1814
|
+
| `gt` `gte` `lt` `lte` | `?price_gte=10` | `price >= 10` (cast through the column type) |
|
|
1815
|
+
| `in` `not_in` | `?status_in=a,b` or `?status_in[]=a` | `status IN ('a','b')` |
|
|
1816
|
+
| `null` | `?deleted_at_null=true` | `deleted_at IS NULL` (`false` → `IS NOT NULL`) |
|
|
1817
|
+
| `contains` `starts_with` | `?title_contains=rails` | `title LIKE '%rails%'` (wildcards escaped; ILIKE on PostgreSQL) |
|
|
1818
|
+
|
|
1819
|
+
Comparison values are cast the way ActiveRecord casts them (the column's own type), or through `type:`
|
|
1820
|
+
(any ActiveModel type name); `type:` also pre-casts the value handed to a `with:` lambda. Blank values
|
|
1821
|
+
are skipped and unknown operators / non-scalar values ignored. A `gt`/`gte`/`lt`/`lte` value the type
|
|
1822
|
+
cannot represent (`?price_gte=abc`) matches **nothing** rather than silently comparing against `0` —
|
|
1823
|
+
nothing raises at request time. `contains`/`starts_with` are case-insensitive on PostgreSQL, MySQL and
|
|
1824
|
+
SQLite alike. For strict, validated contracts reach for `Permittable`.
|
|
1825
|
+
|
|
1763
1826
|
**Modes**
|
|
1764
1827
|
|
|
1765
1828
|
| Mode | Declaration | What it does |
|
|
@@ -1770,7 +1833,7 @@ end
|
|
|
1770
1833
|
|
|
1771
1834
|
**Notes**
|
|
1772
1835
|
- Blank params are skipped — unset filters don't narrow the relation.
|
|
1773
|
-
- Passing both `:scope` and `:with` raises `ArgumentError
|
|
1836
|
+
- Passing both `:scope` and `:with` raises `ArgumentError`; so do `operators:` on a `scope:`/`with:` filter, an unknown operator name, or an unknown `type:` — all at class load.
|
|
1774
1837
|
- Scope mode pairs naturally with `Publishable.published`, `SoftDeletable.active`, `Expirable.active`, etc.
|
|
1775
1838
|
|
|
1776
1839
|
---
|
|
@@ -1825,9 +1888,9 @@ class Api::ArticlesController < ApplicationController
|
|
|
1825
1888
|
def create
|
|
1826
1889
|
article = Article.new(article_params)
|
|
1827
1890
|
if article.save
|
|
1828
|
-
|
|
1891
|
+
render_created(data: article, location: article_url(article)) # 201 + Location
|
|
1829
1892
|
else
|
|
1830
|
-
|
|
1893
|
+
render_invalid(article) # 422 record_invalid + full_messages
|
|
1831
1894
|
end
|
|
1832
1895
|
end
|
|
1833
1896
|
end
|
|
@@ -1861,7 +1924,9 @@ respondable_by error_format: :problem_details, problem_type_base: "https://api.e
|
|
|
1861
1924
|
|
|
1862
1925
|
| Method | Signature |
|
|
1863
1926
|
|-------------------|--------------------------------------------------------------------------------------------|
|
|
1864
|
-
| `render_success` | `render_success(data: nil, status: :ok, meta: {})`
|
|
1927
|
+
| `render_success` | `render_success(data: nil, status: :ok, meta: {}, location: nil, headers: {})` — `location:` sets the `Location` header (a String, or anything `url_for` resolves); `headers:` sets extra response headers (names and values are coerced to String and stripped of CR/LF, so caller data cannot split the response) |
|
|
1928
|
+
| `render_created` | `render_created(data: nil, location: nil, meta: {}, headers: {})` — `render_success` with `status: :created` |
|
|
1929
|
+
| `render_invalid` | `render_invalid(record_or_errors, message: "Validation failed", status: :unprocessable_entity, code: "record_invalid")` — `render_error` with `errors.full_messages` as `details` (omitted when empty, and when an app's `render_error` override cannot take an `errors:` keyword); same shape as ErrorHandleable's `RecordInvalid` handler, problem-details aware |
|
|
1865
1930
|
| `render_error` | `render_error(message:, status: :unprocessable_entity, code: nil, errors: nil)` |
|
|
1866
1931
|
| `respondable_by` | `respondable_by(error_format: :envelope, problem_type_base: nil)` — class-level; `error_format:` is `:envelope` (default) or `:problem_details` |
|
|
1867
1932
|
|
|
@@ -2229,12 +2294,13 @@ end
|
|
|
2229
2294
|
| `:stripe` | `Stripe-Signature` | `t=<unix>,v1=<hex>[,v1=…]` — signs `"#{t}.#{body}"`, every `v1` tried, `tolerance:` rejects stale **and** future timestamps |
|
|
2230
2295
|
| `:hex` / `:base64` | — (`header:` required) | plain hex / strict Base64 HMAC of the body |
|
|
2231
2296
|
|
|
2232
|
-
**Options**: `*actions` (none = catch-all; the first matching rule wins), `secret:` (String, callable `instance_exec`'d per request, or Array for rotation — any match passes), `scheme:` (`:hex`), `header:` (overrides the preset), `tolerance:` (Stripe only, `300`s default), `digest:` (`:sha256`; `:sha1`/`:sha512` for `:hex`/`:base64` only).
|
|
2297
|
+
**Options**: `*actions` (none = catch-all; the first matching rule wins), `secret:` (String, callable `instance_exec`'d per request, or Array for rotation — any match passes), `scheme:` (`:hex`), `header:` (overrides the preset), `tolerance:` (Stripe only, `300`s default), `digest:` (`:sha256`; `:sha1`/`:sha512` for `:hex`/`:base64` only), `replay:` (`true` = the gem-wide `cache_store`, a store object, or `false`/`nil` for off) + `replay_ttl:` (`24.hours`) — replay protection for the schemes that carry no timestamp.
|
|
2233
2298
|
|
|
2234
2299
|
**Notes**
|
|
2235
2300
|
- Comparison is constant-time and the attacker-controlled header is **never decoded** — garbage (including invalid UTF-8 bytes) just fails with 401, it cannot raise.
|
|
2236
2301
|
- A secret that resolves **blank at request time raises `ArgumentError`** — a misconfigured endpoint should page you, not 401 into the provider's silent retry loop.
|
|
2237
|
-
-
|
|
2302
|
+
- **Replay protection** (`replay:`): GitHub, Shopify and plain-HMAC signatures carry no timestamp, so a captured delivery verifies forever. After a signature verifies, a SHA256 of what identifies the delivery — the signature header, or for Stripe the signed `"#{t}.#{body}"` payload, since unknown `v0=` keys and stray whitespace let a captured Stripe header be mutated without invalidating it — is written to the store with `unless_exist:` (atomic — memcached `add` / Redis `SET NX` via `Rails.cache`); a second delivery with the same signature within `replay_ttl:` is rejected with **409 `webhook_replayed`**. Forged traffic never consumes a slot; the key is scoped per controller action. The store must answer `#write(key, value, expires_in:, unless_exist:)` and `#read(key)` (`#delete(key)` is optional but recommended) — `Rails.cache` does. The marker is a short 60-second claim until the action finishes, then promoted to `replay_ttl:` — so a handler that 500s releases it at once, and one that raises (or a filter that halts after verification) leaves only the 60-second claim to expire, either way the provider's retry (identical body, identical signature) gets through. If the store is unreachable the check fails **open**: Rails' Redis and memcached stores return false from `#write` on a connection error, and rejecting every inbound delivery during a cache blip would be worse than accepting a rare duplicate. A per-process `MemoryStore` gives no protection across workers. Stripe's own retries re-sign with a new `t=`, so they pass; a manual GitHub redelivery has the identical signature and is treated as a replay — override `webhook_verification_failed` if you'd rather answer 200.
|
|
2303
|
+
- Failure codes: `webhook_signature_missing` / `webhook_signature_invalid` / `webhook_timestamp_stale` → 401; `webhook_signature_malformed` (unparseable Stripe header) → 400; `webhook_replayed` → 409. With `Respondable`, bodies delegate to `render_error`; override `webhook_verification_failed` to customize.
|
|
2238
2304
|
- Declare **before** `Idempotentable` (a 401 cached by its around filter would be replayed) and before `Throttleable` (forged traffic shouldn't burn rate budget). Webhook endpoints also need `skip_before_action :verify_authenticity_token`.
|
|
2239
2305
|
- In tests: `skip_before_action :verify_webhook_signature!`, or sign payloads for real with `OpenSSL::HMAC`. After a pass, `webhook_verified?` is true.
|
|
2240
2306
|
|
|
@@ -2401,12 +2467,12 @@ Both forms reference the same module, so you can freely mix them.
|
|
|
2401
2467
|
|
|
2402
2468
|
| Need | Use instead |
|
|
2403
2469
|
|------|-------------|
|
|
2404
|
-
| Complex state machines (
|
|
2470
|
+
| Complex state machines — guard clauses, multi-state events, a full transition audit log (`Stateable` has per-event hooks and `<state>_at` stamps, but records no transition history) | [`aasm`](https://github.com/aasm/aasm) |
|
|
2405
2471
|
| Sentinel-aware unique indexes on soft-deleted rows (`deleted_at` in the index) | [`paranoia`](https://github.com/rubysherpas/paranoia) or [`discard`](https://github.com/jhawthorn/discard) |
|
|
2406
2472
|
| Tagging with contexts, ownership, or tag clouds | [`acts-as-taggable-on`](https://github.com/mbleigh/acts-as-taggable-on) |
|
|
2407
2473
|
| Indexed full-text search — stemming, tsvector/GIN, typo tolerance (`Searchable` ranks LIKE matches, but never builds an index) | [`pg_search`](https://github.com/Casecommons/pg_search) / Elasticsearch |
|
|
2408
2474
|
| Versioned audit trails with undo/reify, who-dunnit queries, or association tracking | [`paper_trail`](https://github.com/paper-trail-gem/paper_trail) / [`audited`](https://github.com/collectiveidea/audited) |
|
|
2409
|
-
| Field encryption with
|
|
2475
|
+
| Field encryption with KMS-backed / per-record keys or Rails-native key infrastructure | [`lockbox`](https://github.com/ankane/lockbox) / Rails 7+ native `encrypts` |
|
|
2410
2476
|
| Deep clone with per-attribute regex/prepend rules or belongs_to graph copying | [`amoeba`](https://github.com/amoeba-rb/amoeba) |
|
|
2411
2477
|
|
|
2412
2478
|
`Sluggable` wraps [`friendly_id`](https://github.com/norman/friendly_id) and `Sortable` wraps [`acts_as_list`](https://github.com/brendon/acts_as_list), so you get those leaders' engines behind the declarative macro.
|
|
@@ -2436,9 +2502,9 @@ Point your agent at `llms.txt` for an overview, or paste a single concern's `.md
|
|
|
2436
2502
|
|
|
2437
2503
|
```sh
|
|
2438
2504
|
bundle install # install dev dependencies
|
|
2439
|
-
bundle exec rspec # run the test suite (1,
|
|
2505
|
+
bundle exec rspec # run the test suite (1,828 examples)
|
|
2440
2506
|
gem build concerns_on_rails.gemspec # build the gem
|
|
2441
|
-
gem install ./concerns_on_rails-1.28.
|
|
2507
|
+
gem install ./concerns_on_rails-1.28.8.gem # install locally
|
|
2442
2508
|
|
|
2443
2509
|
# Preview the docs site locally (GitHub Pages serves docs/ as-is):
|
|
2444
2510
|
cd docs && python3 -m http.server 8000 # → http://localhost:8000
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "active_support/concern"
|
|
2
|
+
require "active_model/type"
|
|
2
3
|
require "concerns_on_rails/support/scalar_param"
|
|
3
4
|
|
|
4
5
|
module ConcernsOnRails
|
|
@@ -9,6 +10,24 @@ module ConcernsOnRails
|
|
|
9
10
|
# filter_by :published, scope: :published # ?published=1 -> .published
|
|
10
11
|
# filter_by :q, with: ->(rel, v) { rel.where(...) } # ?q=foo -> lambda is called
|
|
11
12
|
#
|
|
13
|
+
# Direct-where filters can opt into comparison operators, accepted either as
|
|
14
|
+
# a suffix (`?price_gte=10`) or in bracket form (`?price[gte]=10`):
|
|
15
|
+
#
|
|
16
|
+
# filter_by :price, :stock, operators: true # every operator below
|
|
17
|
+
# filter_by :created_at, operators: %i[gte lte] # a subset
|
|
18
|
+
#
|
|
19
|
+
# not · gt · gte · lt · lte · in · not_in (comma list or array) ·
|
|
20
|
+
# null (true/false) · contains · starts_with (LIKE, wildcards escaped)
|
|
21
|
+
#
|
|
22
|
+
# Comparison values are cast the way ActiveRecord casts them — through the
|
|
23
|
+
# column's own attribute type — or through `type:` (any ActiveModel type
|
|
24
|
+
# name: :integer, :decimal, :boolean, :date, :datetime, ...). `type:` also
|
|
25
|
+
# pre-casts the value handed to a `with:` lambda. Blank values are skipped,
|
|
26
|
+
# unknown operators and non-scalar values are ignored, and a comparison
|
|
27
|
+
# value the type cannot represent (`?price_gte=abc`) matches nothing;
|
|
28
|
+
# nothing here raises at request time — for strict, validated params use
|
|
29
|
+
# Permittable.
|
|
30
|
+
#
|
|
12
31
|
# Usage:
|
|
13
32
|
# class ArticlesController < ApplicationController
|
|
14
33
|
# include ConcernsOnRails::Controllers::Filterable
|
|
@@ -22,24 +41,64 @@ module ConcernsOnRails
|
|
|
22
41
|
module Filterable
|
|
23
42
|
extend ActiveSupport::Concern
|
|
24
43
|
|
|
44
|
+
LABEL = "ConcernsOnRails::Controllers::Filterable".freeze
|
|
45
|
+
OPERATORS = %i[not gt gte lt lte in not_in null contains starts_with].freeze
|
|
46
|
+
COMPARISONS = { gt: :gt, gte: :gteq, lt: :lt, lte: :lteq }.freeze
|
|
47
|
+
LIKE_ESCAPE = "\\".freeze
|
|
48
|
+
LIKE_SPECIAL = /[\\%_]/
|
|
49
|
+
NUMERIC_TYPES = %i[integer float decimal].freeze
|
|
50
|
+
NUMERIC_STRING = /\A\s*[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?\s*\z/
|
|
51
|
+
UNCASTABLE = Object.new.freeze
|
|
52
|
+
|
|
25
53
|
included do
|
|
26
54
|
class_attribute :filterable_rules, default: {}
|
|
27
55
|
end
|
|
28
56
|
|
|
29
|
-
|
|
57
|
+
module ClassMethods
|
|
30
58
|
# Declare one or more filterable params. Modes are mutually exclusive
|
|
31
59
|
# per call; pass either `scope:` or `with:`, or neither (direct where).
|
|
32
|
-
|
|
33
|
-
|
|
60
|
+
# `operators:` (true or a list) is direct-where only; `type:` applies to
|
|
61
|
+
# direct-where comparisons and to `with:` lambdas.
|
|
62
|
+
def filter_by(*fields, scope: nil, with: nil, type: nil, operators: nil)
|
|
63
|
+
raise ArgumentError, "#{LABEL}: at least one field is required" if fields.empty?
|
|
64
|
+
raise ArgumentError, "#{LABEL}: pass either :scope or :with, not both" if scope && with
|
|
34
65
|
|
|
35
|
-
|
|
66
|
+
operators = filterable_normalize_operators(operators, direct: scope.nil? && with.nil?)
|
|
67
|
+
type = filterable_normalize_type(type)
|
|
36
68
|
|
|
37
69
|
new_rules = filterable_rules.dup
|
|
38
70
|
fields.each do |field|
|
|
39
|
-
new_rules[field.to_sym] = { scope: scope, with: with }
|
|
71
|
+
new_rules[field.to_sym] = { scope: scope, with: with, type: type, operators: operators }
|
|
40
72
|
end
|
|
41
73
|
self.filterable_rules = new_rules
|
|
42
74
|
end
|
|
75
|
+
|
|
76
|
+
def filterable_normalize_operators(operators, direct:)
|
|
77
|
+
return nil if operators.nil? || operators == false
|
|
78
|
+
raise ArgumentError, "#{LABEL}: operators: only apply to direct-where filters (not scope:/with:)" unless direct
|
|
79
|
+
|
|
80
|
+
operators == true ? OPERATORS : filterable_operator_list(operators)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def filterable_operator_list(operators)
|
|
84
|
+
list = Array(operators).map(&:to_sym)
|
|
85
|
+
unknown = list - OPERATORS
|
|
86
|
+
return list if unknown.empty?
|
|
87
|
+
|
|
88
|
+
raise ArgumentError,
|
|
89
|
+
"#{LABEL}: unknown operator(s) #{unknown.map(&:inspect).join(', ')} — " \
|
|
90
|
+
"valid: #{OPERATORS.map(&:inspect).join(', ')}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Resolved eagerly so a typo fails at class load, not on the first request.
|
|
94
|
+
def filterable_normalize_type(type)
|
|
95
|
+
return nil if type.nil?
|
|
96
|
+
|
|
97
|
+
ActiveModel::Type.lookup(type.to_sym)
|
|
98
|
+
rescue ArgumentError
|
|
99
|
+
raise ArgumentError, "#{LABEL}: type: #{type.inspect} is not an ActiveModel type (try :integer, :decimal, :date, ...)"
|
|
100
|
+
end
|
|
101
|
+
private :filterable_normalize_operators, :filterable_operator_list, :filterable_normalize_type
|
|
43
102
|
end
|
|
44
103
|
|
|
45
104
|
# Apply all declared filters to a relation based on params. Unset values
|
|
@@ -47,9 +106,8 @@ module ConcernsOnRails
|
|
|
47
106
|
def filtered(relation)
|
|
48
107
|
self.class.filterable_rules.each do |field, options|
|
|
49
108
|
value = params[field]
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
relation = apply_filter(relation, field, value, options)
|
|
109
|
+
relation = apply_filter(relation, field, value, options) unless filterable_unset?(value)
|
|
110
|
+
relation = apply_filter_operator_suffixes(relation, field, options) if options[:operators]
|
|
53
111
|
end
|
|
54
112
|
relation
|
|
55
113
|
end
|
|
@@ -71,7 +129,7 @@ module ConcernsOnRails
|
|
|
71
129
|
|
|
72
130
|
def apply_filter(relation, field, value, options)
|
|
73
131
|
if options[:with]
|
|
74
|
-
|
|
132
|
+
apply_filter_lambda(relation, value, options)
|
|
75
133
|
elsif options[:scope]
|
|
76
134
|
# Scope mode discards the value, so an explicit `false` can only mean
|
|
77
135
|
# "do not apply this scope" — applying it would hand the client the
|
|
@@ -81,14 +139,159 @@ module ConcernsOnRails
|
|
|
81
139
|
value == false ? relation : relation.public_send(options[:scope])
|
|
82
140
|
elsif filterable_scalar?(value)
|
|
83
141
|
relation.where(field => value)
|
|
142
|
+
elsif options[:operators] && value.respond_to?(:each_pair)
|
|
143
|
+
apply_filter_operator_hash(relation, field, value, options)
|
|
84
144
|
else
|
|
85
145
|
# A nested/structured param (e.g. ?status[gt]=5) in direct-where mode
|
|
86
146
|
# would raise TypeError ("can't quote Hash") and surface as a 500.
|
|
87
|
-
# Ignore it instead — hash/array shaping must go through a `with:`
|
|
147
|
+
# Ignore it instead — hash/array shaping must go through a `with:`
|
|
148
|
+
# lambda or `operators:`.
|
|
88
149
|
relation
|
|
89
150
|
end
|
|
90
151
|
end
|
|
91
152
|
|
|
153
|
+
# `type:` pre-casts a with: lambda's value; the column's own type must
|
|
154
|
+
# NOT, or every existing lambda on a column-backed param silently starts
|
|
155
|
+
# receiving true / a Time where it used to get "1" / "2020-01-02".
|
|
156
|
+
def apply_filter_lambda(relation, value, options)
|
|
157
|
+
options[:with].call(relation, options[:type] ? options[:type].cast(value) : value)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# ?price[gte]=10&price[lte]=50 — unknown keys are ignored.
|
|
161
|
+
def apply_filter_operator_hash(relation, field, hash, options)
|
|
162
|
+
hash.each do |key, raw|
|
|
163
|
+
operator = key.to_s.to_sym
|
|
164
|
+
next unless options[:operators].include?(operator)
|
|
165
|
+
|
|
166
|
+
relation = apply_filter_operator(relation, field, operator, raw, options)
|
|
167
|
+
end
|
|
168
|
+
relation
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# ?price_gte=10 — one param per declared operator.
|
|
172
|
+
def apply_filter_operator_suffixes(relation, field, options)
|
|
173
|
+
options[:operators].each do |operator|
|
|
174
|
+
relation = apply_filter_operator(relation, field, operator, params[:"#{field}_#{operator}"], options)
|
|
175
|
+
end
|
|
176
|
+
relation
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# The unset guard lives here, not in the two callers: the suffix form used
|
|
180
|
+
# to skip blanks and the bracket form did not, so `?price[gte]=` cast ""
|
|
181
|
+
# to nil and `price > NULL` handed back ZERO rows, while `?price_gte=` —
|
|
182
|
+
# documented as the same filter — correctly returned everything. It is
|
|
183
|
+
# `filterable_unset?`, not `blank?`, for the same reason `#filtered` uses
|
|
184
|
+
# it: a JSON body's `false` is a value, not an absent param.
|
|
185
|
+
def apply_filter_operator(relation, field, operator, raw, options)
|
|
186
|
+
return relation if filterable_unset?(raw)
|
|
187
|
+
|
|
188
|
+
case operator
|
|
189
|
+
when :in, :not_in then apply_filter_list(relation, field, operator, raw)
|
|
190
|
+
when :null then apply_filter_null(relation, field, raw)
|
|
191
|
+
when :contains, :starts_with then apply_filter_like(relation, field, operator, raw)
|
|
192
|
+
when :not then filterable_operand?(raw) ? relation.where.not(field => raw) : relation
|
|
193
|
+
else apply_filter_comparison(relation, field, operator, raw, options)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# `ScalarParam.scalar?` deliberately excludes booleans (nothing there is
|
|
198
|
+
# safe to `.to_i`), but a JSON body carries a real `true`/`false` and
|
|
199
|
+
# `?deleted_at[null]=true` means something — so `null` and `not` take one.
|
|
200
|
+
def filterable_operand?(raw)
|
|
201
|
+
ConcernsOnRails::Support::ScalarParam.scalar?(raw) || raw == true || raw == false
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def apply_filter_comparison(relation, field, operator, raw, options)
|
|
205
|
+
return relation unless ConcernsOnRails::Support::ScalarParam.scalar?(raw)
|
|
206
|
+
|
|
207
|
+
value = filter_cast(relation, field, raw, options)
|
|
208
|
+
# A value the column's type cannot represent matches nothing. Casting it
|
|
209
|
+
# anyway is worse than useless: `?stock_gt=twelve` becomes `stock > 0`
|
|
210
|
+
# (Integer#cast("twelve") is 0, not nil) and quietly returns rows the
|
|
211
|
+
# caller never asked for, while the same typo against a datetime column
|
|
212
|
+
# casts to nil and returns none — one request answered two opposite ways.
|
|
213
|
+
return relation.none if value.equal?(UNCASTABLE)
|
|
214
|
+
|
|
215
|
+
column = relation.model.arel_table[field]
|
|
216
|
+
relation.where(column.public_send(COMPARISONS.fetch(operator), value))
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def apply_filter_list(relation, field, operator, raw)
|
|
220
|
+
list = filterable_list(raw)
|
|
221
|
+
return relation if list.nil?
|
|
222
|
+
|
|
223
|
+
operator == :in ? relation.where(field => list) : relation.where.not(field => list)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# A comma list ("a, b") or an array (?status_in[]=a) of scalars → the
|
|
227
|
+
# cleaned list, or nil when unsafe or empty. A hash-shaped param is NOT a
|
|
228
|
+
# list: `?status_in[x]=1` used to reach `to_s` and filter on the literal
|
|
229
|
+
# string `{"x"=>"1"}` instead of being ignored as documented.
|
|
230
|
+
def filterable_list(raw)
|
|
231
|
+
list = filterable_raw_list(raw)
|
|
232
|
+
return nil if list.nil? || !ConcernsOnRails::Support::ScalarParam.where_safe?(list)
|
|
233
|
+
|
|
234
|
+
list = list.map { |item| item.is_a?(String) ? item.strip : item }.reject { |item| item.to_s.empty? }
|
|
235
|
+
list.empty? ? nil : list
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def filterable_raw_list(raw)
|
|
239
|
+
return raw if raw.is_a?(Array)
|
|
240
|
+
|
|
241
|
+
raw.to_s.split(",") if ConcernsOnRails::Support::ScalarParam.scalar?(raw)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def apply_filter_null(relation, field, raw)
|
|
245
|
+
return relation unless filterable_operand?(raw)
|
|
246
|
+
|
|
247
|
+
ActiveModel::Type::Boolean.new.cast(raw) ? relation.where(field => nil) : relation.where.not(field => nil)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# LIKE with the user's wildcards escaped — with an explicit ESCAPE clause,
|
|
251
|
+
# since SQLite has no default escape character (Searchable does the same).
|
|
252
|
+
# Arel `matches` is ILIKE on PostgreSQL and the adapter's LIKE elsewhere,
|
|
253
|
+
# so matching is case-insensitive on all three supported adapters.
|
|
254
|
+
# Escaped here rather than through `sanitize_sql_like`, which is only
|
|
255
|
+
# public from Rails 5.1 while the gemspec supports >= 5.0 — Searchable
|
|
256
|
+
# hand-rolls the identical gsub for the same reason.
|
|
257
|
+
def apply_filter_like(relation, field, operator, raw)
|
|
258
|
+
return relation unless ConcernsOnRails::Support::ScalarParam.scalar?(raw)
|
|
259
|
+
|
|
260
|
+
escaped = raw.to_s.gsub(LIKE_SPECIAL) { |char| "#{LIKE_ESCAPE}#{char}" }
|
|
261
|
+
pattern = operator == :contains ? "%#{escaped}%" : "#{escaped}%"
|
|
262
|
+
relation.where(relation.model.arel_table[field].matches(pattern, LIKE_ESCAPE))
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# `type:` wins; otherwise the column's own attribute type (what
|
|
266
|
+
# `where(field => value)` would use); a virtual field passes through raw.
|
|
267
|
+
# Returns UNCASTABLE when the value is not representable in that type.
|
|
268
|
+
def filter_cast(relation, field, value, options)
|
|
269
|
+
type = options[:type] || filterable_column_type(relation, field)
|
|
270
|
+
return value if type.nil?
|
|
271
|
+
return UNCASTABLE if filterable_uncastable?(type, value)
|
|
272
|
+
|
|
273
|
+
type.cast(value)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def filterable_column_type(relation, field)
|
|
277
|
+
model = relation.model
|
|
278
|
+
return nil unless model.respond_to?(:attribute_types) && model.attribute_types.key?(field.to_s)
|
|
279
|
+
|
|
280
|
+
model.type_for_attribute(field.to_s)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Numeric types are checked against the string BEFORE casting, because
|
|
284
|
+
# `Integer#cast`/`Decimal#cast` answer 0 for any non-numeric string rather
|
|
285
|
+
# than nil — the cast result alone cannot tell "0" from "twelve". Every
|
|
286
|
+
# other type reports the failure by casting to nil (blank values never get
|
|
287
|
+
# this far; `apply_filter_operator` skipped them).
|
|
288
|
+
def filterable_uncastable?(type, value)
|
|
289
|
+
return type.cast(value).nil? unless NUMERIC_TYPES.include?(type.type)
|
|
290
|
+
return false if value.is_a?(Numeric)
|
|
291
|
+
|
|
292
|
+
!NUMERIC_STRING.match?(value.to_s)
|
|
293
|
+
end
|
|
294
|
+
|
|
92
295
|
# Scalars (and arrays of scalars, which AR turns into `IN (...)`) are safe
|
|
93
296
|
# to pass to .where; a Hash / ActionController::Parameters is not — and
|
|
94
297
|
# since 1.22 neither is an array CONTAINING one (`?status[][x]=1` used to
|