concerns_on_rails 1.28.4 → 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 +60 -0
- data/README.md +58 -8
- data/lib/concerns_on_rails/controllers/localizable.rb +58 -5
- data/lib/concerns_on_rails/controllers/secure_headable.rb +86 -7
- data/lib/concerns_on_rails/models/addressable.rb +109 -1
- data/lib/concerns_on_rails/models/duplicable.rb +37 -6
- data/lib/concerns_on_rails/models/expirable.rb +59 -9
- 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/taggable.rb +34 -1
- 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,65 @@
|
|
|
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
|
+
|
|
3
63
|
## 1.28.4 (2026-09-16)
|
|
4
64
|
|
|
5
65
|
Eleven bug-fix PRs (#91–#101) from the audit of the shipped gem, released as 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,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
|
|
|
@@ -1471,6 +1505,8 @@ end
|
|
|
1471
1505
|
|
|
1472
1506
|
copy = invoice.duplicate # unsaved deep copy
|
|
1473
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
|
|
1474
1510
|
```
|
|
1475
1511
|
|
|
1476
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:`.
|
|
@@ -1479,6 +1515,7 @@ copy = invoice.duplicate!(title: "Q3") # saved (one transaction, autosaved chil
|
|
|
1479
1515
|
|
|
1480
1516
|
**Notes**
|
|
1481
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.
|
|
1482
1519
|
- Override `on_duplicate(copy)` for custom tweaks; it receives the unsaved copy last.
|
|
1483
1520
|
- Reach for [`amoeba`](https://github.com/amoeba-rb/amoeba) when you need per-attribute regex/prepend rules or belongs_to graph copying.
|
|
1484
1521
|
|
|
@@ -1868,6 +1905,9 @@ class ApplicationController < ActionController::Base
|
|
|
1868
1905
|
# Preset headers, plus any custom "Header-Name" => "value" pairs:
|
|
1869
1906
|
secure_headers :nosniff, :sameorigin_frame, :no_referrer_leak, :disable_legacy_xss
|
|
1870
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
|
|
1871
1911
|
|
|
1872
1912
|
# Delegates to Rails' native CSP DSL — roll out report-only FIRST:
|
|
1873
1913
|
content_security_policy_for(report_only: true) do |policy|
|
|
@@ -1888,6 +1928,14 @@ end
|
|
|
1888
1928
|
| `:no_referrer_leak` | `Referrer-Policy: strict-origin-when-cross-origin` |
|
|
1889
1929
|
| `:no_cross_domain` | `X-Permitted-Cross-Domain-Policies: none` |
|
|
1890
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).
|
|
1891
1939
|
|
|
1892
1940
|
**Notes**
|
|
1893
1941
|
- Headers are applied in an `after_action`, so they reinforce Rails' middleware defaults; later `secure_headers` declarations win on a colliding name.
|
|
@@ -1912,7 +1960,9 @@ end
|
|
|
1912
1960
|
|
|
1913
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`.
|
|
1914
1962
|
|
|
1915
|
-
|
|
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`).
|
|
1916
1966
|
|
|
1917
1967
|
---
|
|
1918
1968
|
|
|
@@ -2250,9 +2300,9 @@ Point your agent at `llms.txt` for an overview, or paste a single concern's `.md
|
|
|
2250
2300
|
|
|
2251
2301
|
```sh
|
|
2252
2302
|
bundle install # install dev dependencies
|
|
2253
|
-
bundle exec rspec # run the test suite (1,
|
|
2303
|
+
bundle exec rspec # run the test suite (1,522 examples)
|
|
2254
2304
|
gem build concerns_on_rails.gemspec # build the gem
|
|
2255
|
-
gem install ./concerns_on_rails-1.28.
|
|
2305
|
+
gem install ./concerns_on_rails-1.28.5.gem # install locally
|
|
2256
2306
|
|
|
2257
2307
|
# Preview the docs site locally (GitHub Pages serves docs/ as-is):
|
|
2258
2308
|
cd docs && python3 -m http.server 8000 # → http://localhost:8000
|
|
@@ -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
|
|
|
@@ -13,6 +13,9 @@ module ConcernsOnRails
|
|
|
13
13
|
# # Apply preset headers, plus any custom "Header-Name" => "value" pairs:
|
|
14
14
|
# secure_headers :nosniff, :sameorigin_frame, :no_referrer_leak, :disable_legacy_xss
|
|
15
15
|
# secure_headers "Permissions-Policy" => "geolocation=()"
|
|
16
|
+
# # ...or the break-nothing baseline in one line, then relax what you must:
|
|
17
|
+
# secure_headers :recommended
|
|
18
|
+
# secure_headers :sameorigin_frame # later declarations win
|
|
16
19
|
#
|
|
17
20
|
# # Delegates to Rails' native CSP DSL — roll out report-only FIRST:
|
|
18
21
|
# content_security_policy_for(report_only: true) do |policy|
|
|
@@ -36,9 +39,32 @@ module ConcernsOnRails
|
|
|
36
39
|
# :no_referrer_leak — Referrer-Policy: strict-origin-when-cross-origin
|
|
37
40
|
# :no_cross_domain — X-Permitted-Cross-Domain-Policies: none
|
|
38
41
|
# :disable_legacy_xss — X-XSS-Protection: 0 (the only correct modern value)
|
|
42
|
+
# :hsts — Strict-Transport-Security: max-age=31536000; includeSubDomains
|
|
43
|
+
# (for API-only apps behind a TLS-terminating proxy; when
|
|
44
|
+
# you use force_ssl / config.ssl_options Rails sets it)
|
|
45
|
+
# :same_origin_opener — Cross-Origin-Opener-Policy: same-origin
|
|
46
|
+
# :same_origin_opener_allow_popups — Cross-Origin-Opener-Policy: same-origin-allow-popups
|
|
47
|
+
# (keeps OAuth / payment popups working)
|
|
48
|
+
# :require_corp_embedder — Cross-Origin-Embedder-Policy: require-corp
|
|
49
|
+
# :same_origin_resource — Cross-Origin-Resource-Policy: same-origin
|
|
50
|
+
# :no_sensitive_permissions — Permissions-Policy denying camera, microphone,
|
|
51
|
+
# geolocation, payment, usb and motion sensors
|
|
52
|
+
#
|
|
53
|
+
# Bundles (expand to presets, so a later preset or custom pair still wins):
|
|
54
|
+
# :recommended — nosniff, deny_frame, no_referrer_leak, no_cross_domain,
|
|
55
|
+
# disable_legacy_xss, same_origin_opener_allow_popups,
|
|
56
|
+
# no_sensitive_permissions. Deliberately WITHOUT COEP/CORP
|
|
57
|
+
# (they block cross-origin embeds of your resources and
|
|
58
|
+
# CDN assets without CORP headers) and HSTS (belongs
|
|
59
|
+
# with force_ssl) — the baseline that breaks nothing.
|
|
60
|
+
# :cross_origin_isolation — same_origin_opener, require_corp_embedder,
|
|
61
|
+
# same_origin_resource (SharedArrayBuffer / high-res
|
|
62
|
+
# timers need all three).
|
|
39
63
|
module SecureHeadable
|
|
40
64
|
extend ActiveSupport::Concern
|
|
41
65
|
|
|
66
|
+
HSTS_HEADER = "Strict-Transport-Security".freeze
|
|
67
|
+
|
|
42
68
|
# Frozen, string-only header presets, each "Header-Name" => "value".
|
|
43
69
|
# :disable_legacy_xss emits "0" deliberately — the legacy browser XSS
|
|
44
70
|
# auditor was itself exploitable and is gone from modern browsers
|
|
@@ -49,8 +75,34 @@ module ConcernsOnRails
|
|
|
49
75
|
deny_frame: %w[X-Frame-Options DENY],
|
|
50
76
|
no_referrer_leak: %w[Referrer-Policy strict-origin-when-cross-origin],
|
|
51
77
|
no_cross_domain: %w[X-Permitted-Cross-Domain-Policies none],
|
|
52
|
-
disable_legacy_xss: %w[X-XSS-Protection 0]
|
|
53
|
-
|
|
78
|
+
disable_legacy_xss: %w[X-XSS-Protection 0],
|
|
79
|
+
# One year, subdomains included, no `preload` — the preload list is a
|
|
80
|
+
# months-long commitment that must be an explicit, custom-pair decision.
|
|
81
|
+
hsts: ["Strict-Transport-Security", "max-age=31536000; includeSubDomains"],
|
|
82
|
+
same_origin_opener: %w[Cross-Origin-Opener-Policy same-origin],
|
|
83
|
+
same_origin_opener_allow_popups: %w[Cross-Origin-Opener-Policy same-origin-allow-popups],
|
|
84
|
+
require_corp_embedder: %w[Cross-Origin-Embedder-Policy require-corp],
|
|
85
|
+
same_origin_resource: %w[Cross-Origin-Resource-Policy same-origin],
|
|
86
|
+
no_sensitive_permissions: ["Permissions-Policy",
|
|
87
|
+
"accelerometer=(), camera=(), geolocation=(), gyroscope=(), " \
|
|
88
|
+
"magnetometer=(), microphone=(), payment=(), usb=()"],
|
|
89
|
+
# The same list scoped to `(self)`: third-party frames are denied, the
|
|
90
|
+
# app's own pages keep getting/asking for permission. This is the one
|
|
91
|
+
# that belongs in a general-purpose bundle -- an empty allowlist denies
|
|
92
|
+
# the app itself, which silently breaks getUserMedia, geolocation and
|
|
93
|
+
# Payment Request with only a console warning.
|
|
94
|
+
self_sensitive_permissions: ["Permissions-Policy",
|
|
95
|
+
"accelerometer=(self), camera=(self), geolocation=(self), gyroscope=(self), " \
|
|
96
|
+
"magnetometer=(self), microphone=(self), payment=(self), usb=(self)"]
|
|
97
|
+
}.each_value { |pair| pair.each(&:freeze).freeze }.freeze
|
|
98
|
+
|
|
99
|
+
# Named sets of presets. Expanded in declaration position, so
|
|
100
|
+
# `secure_headers :recommended, :sameorigin_frame` relaxes the frame rule.
|
|
101
|
+
BUNDLES = {
|
|
102
|
+
cross_origin_isolation: %i[same_origin_opener require_corp_embedder same_origin_resource],
|
|
103
|
+
recommended: %i[nosniff deny_frame no_referrer_leak no_cross_domain disable_legacy_xss
|
|
104
|
+
same_origin_opener_allow_popups self_sensitive_permissions]
|
|
105
|
+
}.each_value(&:freeze).freeze
|
|
54
106
|
|
|
55
107
|
included do
|
|
56
108
|
class_attribute :secure_headable_headers, instance_accessor: false, default: {}
|
|
@@ -67,14 +119,16 @@ module ConcernsOnRails
|
|
|
67
119
|
end
|
|
68
120
|
|
|
69
121
|
class_methods do
|
|
70
|
-
# Register preset headers (by symbol) plus
|
|
71
|
-
# "Header-Name" => "value" pairs. Later declarations
|
|
122
|
+
# Register preset headers (by symbol, bundles expanded in place) plus
|
|
123
|
+
# optional custom "Header-Name" => "value" pairs. Later declarations —
|
|
124
|
+
# and later positions within one call — win on collision.
|
|
72
125
|
def secure_headers(*presets, **custom)
|
|
73
|
-
|
|
126
|
+
expanded = presets.flat_map { |key| BUNDLES.fetch(key, [key]) }
|
|
127
|
+
resolved = expanded.to_h do |key|
|
|
74
128
|
PRESETS.fetch(key) do
|
|
75
129
|
raise ArgumentError,
|
|
76
130
|
"ConcernsOnRails::Controllers::SecureHeadable: unknown preset '#{key}'. " \
|
|
77
|
-
"Valid presets: #{PRESETS.keys.join(', ')}"
|
|
131
|
+
"Valid presets: #{PRESETS.keys.join(', ')}. Bundles: #{BUNDLES.keys.join(', ')}"
|
|
78
132
|
end
|
|
79
133
|
end
|
|
80
134
|
|
|
@@ -107,7 +161,32 @@ module ConcernsOnRails
|
|
|
107
161
|
def apply_secure_headers
|
|
108
162
|
return unless respond_to?(:response) && response
|
|
109
163
|
|
|
110
|
-
self.class.secure_headable_headers.each
|
|
164
|
+
self.class.secure_headable_headers.each do |name, value|
|
|
165
|
+
next if secure_headable_skip?(name)
|
|
166
|
+
|
|
167
|
+
response.set_header(name, value)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# HSTS is the one preset that is wrong to send unconditionally: RFC 6797
|
|
172
|
+
# section 7.2 forbids it over plaintext, and overwriting a value the app
|
|
173
|
+
# already set would silently shorten a longer max-age or drop `preload`.
|
|
174
|
+
def secure_headable_skip?(name)
|
|
175
|
+
return false unless name == HSTS_HEADER
|
|
176
|
+
|
|
177
|
+
secure_headable_plain_http? || secure_headable_existing_hsts.present?
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def secure_headable_plain_http?
|
|
181
|
+
return false unless respond_to?(:request, true) && (req = request)
|
|
182
|
+
|
|
183
|
+
req.respond_to?(:ssl?) && !req.ssl?
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def secure_headable_existing_hsts
|
|
187
|
+
return response.get_header(HSTS_HEADER) if response.respond_to?(:get_header)
|
|
188
|
+
|
|
189
|
+
response.headers[HSTS_HEADER] if response.respond_to?(:headers)
|
|
111
190
|
end
|
|
112
191
|
end
|
|
113
192
|
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "active_support/concern"
|
|
2
|
+
require "digest"
|
|
2
3
|
require "concerns_on_rails/support/column_guard"
|
|
3
4
|
require "concerns_on_rails/support/address_data"
|
|
4
5
|
|
|
@@ -49,10 +50,18 @@ module ConcernsOnRails
|
|
|
49
50
|
class_attribute :addressable_allow_blank, instance_accessor: false, default: [].freeze
|
|
50
51
|
class_attribute :addressable_normalize_country, instance_accessor: false, default: false
|
|
51
52
|
class_attribute :addressable_validation_registered, instance_accessor: false, default: false
|
|
53
|
+
class_attribute :addressable_fingerprint_column, instance_accessor: false, default: nil
|
|
52
54
|
|
|
53
55
|
# `validate :validate_address` is registered by `addressable_by` (not here) so it can
|
|
54
56
|
# carry the optional if:/unless: condition. Normalization always runs.
|
|
55
57
|
before_validation :normalize_address
|
|
58
|
+
# Stamping is its OWN callback, not a tail call inside normalize_address:
|
|
59
|
+
# a sibling concern's later before_validation (Normalizable, say) can
|
|
60
|
+
# still rewrite a mapped column, and a fingerprint computed before that
|
|
61
|
+
# would disagree with `address_fingerprint` -- leaving `with_address`
|
|
62
|
+
# unable to find the record itself. before_save also covers
|
|
63
|
+
# `save(validate: false)`.
|
|
64
|
+
before_save :stamp_address_fingerprint
|
|
56
65
|
end
|
|
57
66
|
|
|
58
67
|
# Defined as a real module (not `class_methods do`) so the public macro and
|
|
@@ -65,7 +74,7 @@ module ConcernsOnRails
|
|
|
65
74
|
# keyword pairs; everything else tunes behavior. See the module docs.
|
|
66
75
|
def addressable_by(required: DEFAULT_REQUIRED, default_country: "US",
|
|
67
76
|
validate_state: false, verify_with: nil,
|
|
68
|
-
lengths: {}, allow_blank: false, normalize_country: false, **mapping)
|
|
77
|
+
lengths: {}, allow_blank: false, normalize_country: false, fingerprint: nil, **mapping)
|
|
69
78
|
condition = extract_validation_condition!(mapping)
|
|
70
79
|
self.addressable_fields = resolve_addressable_fields(mapping)
|
|
71
80
|
self.addressable_required = Array(required).map(&:to_sym)
|
|
@@ -75,12 +84,53 @@ module ConcernsOnRails
|
|
|
75
84
|
self.addressable_lengths = resolve_lengths(lengths)
|
|
76
85
|
self.addressable_allow_blank = resolve_allow_blank(allow_blank)
|
|
77
86
|
self.addressable_normalize_country = normalize_country
|
|
87
|
+
self.addressable_fingerprint_column = resolve_fingerprint_column(fingerprint)
|
|
78
88
|
ensure_required_columns!
|
|
79
89
|
register_address_validation(condition)
|
|
90
|
+
define_address_changed_alias
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Records stored with the same address fingerprint as `value` (a record,
|
|
94
|
+
# or a fingerprint String). Needs `fingerprint:` — the digest has to be
|
|
95
|
+
# persisted to be queryable. A nil fingerprint (blank address) matches
|
|
96
|
+
# nothing rather than every other blank row.
|
|
97
|
+
def with_address(value)
|
|
98
|
+
column = addressable_fingerprint_column
|
|
99
|
+
unless column
|
|
100
|
+
raise ArgumentError,
|
|
101
|
+
"#{LABEL}: with_address needs `addressable_by fingerprint:` (a column to store address_fingerprint in)"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
fingerprint = value.respond_to?(:address_fingerprint) ? value.address_fingerprint : value
|
|
105
|
+
fingerprint.nil? ? none : where(column => fingerprint)
|
|
80
106
|
end
|
|
81
107
|
|
|
82
108
|
private
|
|
83
109
|
|
|
110
|
+
# `address_changed?` would override ActiveModel's generated predicate on
|
|
111
|
+
# a model that also carries a plain `address` column -- the legacy-blob
|
|
112
|
+
# shape apps arrive with -- silently reporting false for a real change.
|
|
113
|
+
# Define it only when the name is free; `address_parts_changed?` is
|
|
114
|
+
# always available.
|
|
115
|
+
def define_address_changed_alias
|
|
116
|
+
has_address_column = begin
|
|
117
|
+
column_names.include?("address")
|
|
118
|
+
rescue StandardError
|
|
119
|
+
false # schema unreachable (db:create, assets:precompile) -- as ColumnGuard does
|
|
120
|
+
end
|
|
121
|
+
return if has_address_column
|
|
122
|
+
|
|
123
|
+
alias_method :address_changed?, :address_parts_changed?
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def resolve_fingerprint_column(fingerprint)
|
|
127
|
+
return nil if fingerprint.nil?
|
|
128
|
+
|
|
129
|
+
column = fingerprint.to_sym
|
|
130
|
+
ensure_columns!(LABEL, column, types: :string)
|
|
131
|
+
column
|
|
132
|
+
end
|
|
133
|
+
|
|
84
134
|
def resolve_addressable_fields(mapping)
|
|
85
135
|
unknown = mapping.keys.map(&:to_sym) - DEFAULT_FIELDS.keys
|
|
86
136
|
raise ArgumentError, "#{LABEL}: unknown address part(s): #{unknown.join(', ')}" if unknown.any?
|
|
@@ -237,8 +287,66 @@ module ConcernsOnRails
|
|
|
237
287
|
end
|
|
238
288
|
end
|
|
239
289
|
|
|
290
|
+
# SHA-256 of the normalized address — every part downcased and squished,
|
|
291
|
+
# the postal code without spaces, the country resolved the way validation
|
|
292
|
+
# resolves it (blank → default_country) — so rows that differ only in
|
|
293
|
+
# case, whitespace, postal formatting or an omitted default country hash
|
|
294
|
+
# the same. nil for a blank address (a country alone is not an address).
|
|
295
|
+
def address_fingerprint
|
|
296
|
+
return nil unless address_fingerprintable?
|
|
297
|
+
|
|
298
|
+
parts = DEFAULT_FIELDS.keys.map { |part| address_fingerprint_part(part) }
|
|
299
|
+
Digest::SHA256.hexdigest(parts.join("\n"))
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Equal fingerprints — never true for two blank addresses.
|
|
303
|
+
def same_address_as?(other)
|
|
304
|
+
# Tolerant like the sibling `with_address`, which accepts a record, a
|
|
305
|
+
# digest String or nil: a bare `other.address_fingerprint` turned
|
|
306
|
+
# `same_address_as?(nil)` into a NoMethodError.
|
|
307
|
+
return false unless other.respond_to?(:address_fingerprint)
|
|
308
|
+
|
|
309
|
+
fingerprint = address_fingerprint
|
|
310
|
+
!fingerprint.nil? && fingerprint == other.address_fingerprint
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Any mapped address column has an unsaved change. `address_changed?` is
|
|
314
|
+
# the documented spelling, but a model with its own `address` column
|
|
315
|
+
# already has that method from ActiveModel dirty tracking, so the macro
|
|
316
|
+
# only aliases it where nothing would be shadowed.
|
|
317
|
+
def address_parts_changed?
|
|
318
|
+
self.class.addressable_fields.values.any? { |column| attribute_changed?(column) }
|
|
319
|
+
end
|
|
320
|
+
|
|
240
321
|
private
|
|
241
322
|
|
|
323
|
+
# Something beyond the country must be present — a country alone is not
|
|
324
|
+
# an address, and the default country is always "present".
|
|
325
|
+
def address_fingerprintable?
|
|
326
|
+
self.class.addressable_fields.any? { |part, column| part != :country && self[column].present? }
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def address_fingerprint_part(part)
|
|
330
|
+
column = self.class.addressable_fields[part]
|
|
331
|
+
return "" unless column
|
|
332
|
+
|
|
333
|
+
raw = self[column]
|
|
334
|
+
return (resolved_country || raw).to_s.squish.downcase if part == :country
|
|
335
|
+
|
|
336
|
+
value = raw.to_s.squish.downcase
|
|
337
|
+
part == :postal_code ? value.delete(" ") : value
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Keep the fingerprint column (when configured) in step with the
|
|
341
|
+
# normalized address; written only when it actually differs.
|
|
342
|
+
def stamp_address_fingerprint
|
|
343
|
+
column = self.class.addressable_fingerprint_column
|
|
344
|
+
return unless column
|
|
345
|
+
|
|
346
|
+
fingerprint = address_fingerprint
|
|
347
|
+
self[column] = fingerprint unless self[column] == fingerprint
|
|
348
|
+
end
|
|
349
|
+
|
|
242
350
|
def ordered_parts
|
|
243
351
|
DEFAULT_FIELDS.keys.select { |part| self.class.addressable_fields.key?(part) }
|
|
244
352
|
end
|
|
@@ -113,26 +113,57 @@ module ConcernsOnRails
|
|
|
113
113
|
# Unsaved deep copy: attributes via `dup`, identity columns blanked,
|
|
114
114
|
# `reset:` columns blanked, `suffix:` strings appended, `overrides`
|
|
115
115
|
# assigned, allow-listed associations copied, then `on_duplicate`.
|
|
116
|
-
|
|
116
|
+
#
|
|
117
|
+
# `only:` / `except:` pick which of the macro's associations THIS copy
|
|
118
|
+
# carries (`duplicate!(except: :line_items)`; `only: []` is a shallow
|
|
119
|
+
# copy). An explicit nil counts as passed, not as absent, so
|
|
120
|
+
# `only: params[:associations]` with nothing checked copies NO
|
|
121
|
+
# associations rather than silently deep-copying every one. Braceless overrides arrive through **options too (Ruby 3
|
|
122
|
+
# keyword rules), so `only`/`except` are reserved keys — an attribute
|
|
123
|
+
# literally named that goes in a braced Hash.
|
|
124
|
+
def duplicate(overrides = {}, **options)
|
|
125
|
+
overrides = overrides.merge(options.except(:only, :except))
|
|
126
|
+
associations = duplicable_selected_associations(options.slice(:only, :except))
|
|
127
|
+
|
|
117
128
|
copy = dup
|
|
118
129
|
duplicable_reset_attributes(copy)
|
|
119
130
|
duplicable_apply_suffixes(copy)
|
|
120
131
|
overrides.each { |attribute, value| copy.public_send("#{attribute}=", value) }
|
|
121
|
-
duplicable_copy_associations(copy)
|
|
132
|
+
duplicable_copy_associations(copy, associations)
|
|
122
133
|
on_duplicate(copy)
|
|
123
134
|
copy
|
|
124
135
|
end
|
|
125
136
|
|
|
126
137
|
# Persisted deep copy — the copy and its copied children save together
|
|
127
138
|
# (autosave) inside one transaction. Returns the saved copy.
|
|
128
|
-
def duplicate!(overrides = {})
|
|
129
|
-
copy = duplicate(overrides)
|
|
139
|
+
def duplicate!(overrides = {}, **)
|
|
140
|
+
copy = duplicate(overrides, **)
|
|
130
141
|
transaction { copy.save! }
|
|
131
142
|
copy
|
|
132
143
|
end
|
|
133
144
|
|
|
134
145
|
private
|
|
135
146
|
|
|
147
|
+
# The macro's list is the ceiling: a per-call name outside it raises, so
|
|
148
|
+
# a controller param can never smuggle in an unvetted association.
|
|
149
|
+
def duplicable_selected_associations(selection)
|
|
150
|
+
declared = self.class.duplicable_config[:associations]
|
|
151
|
+
raise ArgumentError, "#{LABEL}: pass either :only or :except, not both" if selection.size > 1
|
|
152
|
+
return declared if selection.empty?
|
|
153
|
+
|
|
154
|
+
mode, names = selection.first
|
|
155
|
+
chosen = duplicable_validate_selection!(Array(names).map(&:to_sym), declared)
|
|
156
|
+
mode == :only ? declared & chosen : declared - chosen
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def duplicable_validate_selection!(chosen, declared)
|
|
160
|
+
chosen.each do |name|
|
|
161
|
+
next if declared.include?(name)
|
|
162
|
+
|
|
163
|
+
raise ArgumentError, "#{LABEL}: #{name} is not a duplicable association (declared: #{declared.join(', ')})"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
136
167
|
def duplicable_reset_attributes(copy)
|
|
137
168
|
(duplicable_auto_reset_columns + self.class.duplicable_config[:reset]).each do |column|
|
|
138
169
|
copy[column] = nil if copy.class.column_names.include?(column.to_s)
|
|
@@ -177,8 +208,8 @@ module ConcernsOnRails
|
|
|
177
208
|
self.class.include?(concern)
|
|
178
209
|
end
|
|
179
210
|
|
|
180
|
-
def duplicable_copy_associations(copy)
|
|
181
|
-
|
|
211
|
+
def duplicable_copy_associations(copy, associations)
|
|
212
|
+
associations.each do |name|
|
|
182
213
|
reflection = self.class.reflect_on_association(name)
|
|
183
214
|
case reflection.macro
|
|
184
215
|
when :has_many
|
|
@@ -31,9 +31,10 @@ module ConcernsOnRails
|
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
# Expire every currently-active record in the relation. Returns the
|
|
34
|
-
# Integer count.
|
|
35
|
-
#
|
|
36
|
-
# validations (see Support::BatchOps.fast_path?)
|
|
34
|
+
# Integer count. A single UPDATE unless the model overrode `expire!`
|
|
35
|
+
# or a lifecycle hook (before_expire / after_expire), or declares
|
|
36
|
+
# validations (see Support::BatchOps.fast_path?) — then it streams
|
|
37
|
+
# per record so the hooks run.
|
|
37
38
|
def expire_all(time = Time.zone.now)
|
|
38
39
|
active = all.public_send(expirable_scope_names.fetch(:active))
|
|
39
40
|
if expirable_batch_fast_path?
|
|
@@ -52,11 +53,11 @@ module ConcernsOnRails
|
|
|
52
53
|
private
|
|
53
54
|
|
|
54
55
|
# Whether the single-UPDATE fast path is safe — the whole decision
|
|
55
|
-
# (bang method unoverridden AND the model declares no
|
|
56
|
-
# plus why) lives in Support::BatchOps.fast_path?.
|
|
57
|
-
# no lifecycle hooks, so `expire!` is the only method to check.
|
|
56
|
+
# (bang method and hooks unoverridden AND the model declares no
|
|
57
|
+
# validations, plus why) lives in Support::BatchOps.fast_path?.
|
|
58
58
|
def expirable_batch_fast_path?
|
|
59
|
-
ConcernsOnRails::Support::BatchOps.fast_path?(self, ConcernsOnRails::Models::Expirable,
|
|
59
|
+
ConcernsOnRails::Support::BatchOps.fast_path?(self, ConcernsOnRails::Models::Expirable,
|
|
60
|
+
:expire!, :before_expire, :after_expire)
|
|
60
61
|
end
|
|
61
62
|
|
|
62
63
|
# Scopes live here (not in `included do`) so their names can be affixed —
|
|
@@ -96,8 +97,47 @@ module ConcernsOnRails
|
|
|
96
97
|
value <= Time.zone.now
|
|
97
98
|
end
|
|
98
99
|
|
|
100
|
+
# Lifecycle hooks — override in the model. Fired when a write actually
|
|
101
|
+
# expires the record, i.e. `expire!` with a past-or-now time (and so by
|
|
102
|
+
# `expire_all`). A FUTURE time only schedules expiry, so it fires
|
|
103
|
+
# nothing — same as `extend_expiry!` (a renewal) or `clear_expiry!`.
|
|
104
|
+
# Otherwise `after_expire { account.downgrade! }` paired with
|
|
105
|
+
# `trial.expire_in!(14.days)` would downgrade the account immediately.
|
|
106
|
+
# Overriding either hook moves `expire_all` to the per-record path.
|
|
107
|
+
def before_expire; end
|
|
108
|
+
def after_expire; end
|
|
109
|
+
|
|
110
|
+
# Write the expiry (default: now, i.e. expire immediately). The hooks and
|
|
111
|
+
# the write share one transaction, so a raising after_expire rolls the
|
|
112
|
+
# expiry back (SoftDeletable's pattern); a failed write (validation)
|
|
113
|
+
# returns false and skips after_expire.
|
|
99
114
|
def expire!(time = Time.zone.now)
|
|
100
|
-
|
|
115
|
+
hooks = !expirable_scheduled?(time)
|
|
116
|
+
result = false
|
|
117
|
+
transaction do
|
|
118
|
+
before_expire if hooks
|
|
119
|
+
result = update(self.class.expirable_field => time)
|
|
120
|
+
after_expire if result && hooks
|
|
121
|
+
end
|
|
122
|
+
result
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Set an absolute lifetime from now — `token.expire_in!(15.minutes)` —
|
|
126
|
+
# whatever the current expiry. Sugar for `expire!(now + duration)`, so a
|
|
127
|
+
# positive duration schedules expiry and fires no hooks.
|
|
128
|
+
def expire_in!(duration)
|
|
129
|
+
unless duration.respond_to?(:to_i) && !duration.is_a?(String)
|
|
130
|
+
raise ArgumentError,
|
|
131
|
+
"ConcernsOnRails::Models::Expirable: expire_in! takes a duration " \
|
|
132
|
+
"(e.g. 15.minutes), got #{duration.class}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
expire!(Time.zone.now + duration)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Make the record never expire (nil expiry). No hooks: nothing expired.
|
|
139
|
+
def clear_expiry!
|
|
140
|
+
update(self.class.expirable_field => nil)
|
|
101
141
|
end
|
|
102
142
|
|
|
103
143
|
# Push expiry forward by `by:`. If the record has no expiry yet, or has
|
|
@@ -127,7 +167,17 @@ module ConcernsOnRails
|
|
|
127
167
|
now = Time.zone.now
|
|
128
168
|
value.nil? || value <= now ? now : value
|
|
129
169
|
end
|
|
130
|
-
|
|
170
|
+
|
|
171
|
+
# A write dated in the future schedules expiry rather than performing it.
|
|
172
|
+
def expirable_scheduled?(time)
|
|
173
|
+
return false if time.blank?
|
|
174
|
+
|
|
175
|
+
time.to_time > Time.zone.now
|
|
176
|
+
rescue StandardError
|
|
177
|
+
false # unparseable input: treat it as an immediate expiry, as before
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private :expiry_extension_base, :expirable_scheduled?
|
|
131
181
|
end
|
|
132
182
|
end
|
|
133
183
|
end
|
|
@@ -28,9 +28,18 @@ module ConcernsOnRails
|
|
|
28
28
|
# Options: `as:` (explicit method name — required when the column does not
|
|
29
29
|
# end in `_cents`), `unit:` ("$"), `precision:` (2), `delimiter:` (","),
|
|
30
30
|
# `separator:` ("."), `subunit_to_unit:` (100).
|
|
31
|
+
#
|
|
32
|
+
# Class-level aggregates come for free and follow the current scope:
|
|
33
|
+
# Product.sum_price # => BigDecimal, SUM(price_cents) / 100
|
|
34
|
+
# Product.in_stock.average_price # average / minimum / maximum too; nil on empty sets
|
|
35
|
+
# Product.formatted_sum_price # => "$1,234.56" — every aggregate has a formatted_ twin
|
|
36
|
+
# product.formatted_price(unit: "€", delimiter: ".", separator: ",") # per-call display overrides
|
|
31
37
|
module Monetizable
|
|
32
38
|
extend ActiveSupport::Concern
|
|
33
39
|
|
|
40
|
+
LABEL = "ConcernsOnRails::Models::Monetizable".freeze
|
|
41
|
+
AGGREGATES = %i[sum average minimum maximum].freeze
|
|
42
|
+
|
|
34
43
|
included do
|
|
35
44
|
class_attribute :monetizable_rules, instance_accessor: false, default: {}
|
|
36
45
|
end
|
|
@@ -54,15 +63,18 @@ module ConcernsOnRails
|
|
|
54
63
|
|
|
55
64
|
ensure_columns!("ConcernsOnRails::Models::Monetizable", fields, types: :integer)
|
|
56
65
|
config = { unit: unit, precision: precision, delimiter: delimiter, separator: separator, subunit_to_unit: subunit_to_unit }
|
|
57
|
-
fields.each
|
|
66
|
+
fields.each do |cents_field|
|
|
67
|
+
name = money_name(cents_field.to_sym, as)
|
|
68
|
+
define_money_accessors(cents_field.to_sym, name, config)
|
|
69
|
+
define_money_aggregates(cents_field.to_sym, name, config)
|
|
70
|
+
end
|
|
58
71
|
end
|
|
59
72
|
end
|
|
60
73
|
|
|
61
74
|
class_methods do # rubocop:disable Metrics/BlockLength
|
|
62
75
|
private
|
|
63
76
|
|
|
64
|
-
def define_money_accessors(cents_field,
|
|
65
|
-
name = money_name(cents_field, as)
|
|
77
|
+
def define_money_accessors(cents_field, name, config)
|
|
66
78
|
subunit = config[:subunit_to_unit]
|
|
67
79
|
self.monetizable_rules = monetizable_rules.merge(cents_field => name)
|
|
68
80
|
|
|
@@ -87,9 +99,27 @@ module ConcernsOnRails
|
|
|
87
99
|
end
|
|
88
100
|
end
|
|
89
101
|
|
|
90
|
-
define_method("formatted_#{name}") do
|
|
91
|
-
|
|
92
|
-
|
|
102
|
+
define_method("formatted_#{name}") do |**overrides|
|
|
103
|
+
options = ConcernsOnRails::Support::Money.format_options(config, overrides, LABEL)
|
|
104
|
+
ConcernsOnRails::Support::Money.format_each(self[cents_field], options)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# `sum_price` / `average_price` / `minimum_price` / `maximum_price` and
|
|
109
|
+
# their `formatted_` twins. Defined on the singleton so a relation
|
|
110
|
+
# (`Product.in_stock.sum_price`) delegates here inside its scoping;
|
|
111
|
+
# `public_send(aggregate)` then runs against the current scope.
|
|
112
|
+
def define_money_aggregates(cents_field, name, config)
|
|
113
|
+
subunit = config[:subunit_to_unit]
|
|
114
|
+
AGGREGATES.each do |aggregate|
|
|
115
|
+
define_singleton_method("#{aggregate}_#{name}") do
|
|
116
|
+
ConcernsOnRails::Support::Money.decimal(public_send(aggregate, cents_field), subunit)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
define_singleton_method("formatted_#{aggregate}_#{name}") do |**overrides|
|
|
120
|
+
options = ConcernsOnRails::Support::Money.format_options(config, overrides, LABEL)
|
|
121
|
+
ConcernsOnRails::Support::Money.format_each(public_send(aggregate, cents_field), options)
|
|
122
|
+
end
|
|
93
123
|
end
|
|
94
124
|
end
|
|
95
125
|
|
|
@@ -9,7 +9,7 @@ module ConcernsOnRails
|
|
|
9
9
|
|
|
10
10
|
DEFAULT_STARTS_AT_FIELD = :starts_at
|
|
11
11
|
DEFAULT_ENDS_AT_FIELD = :ends_at
|
|
12
|
-
SCOPE_BASES = %i[active_at current upcoming expired].freeze
|
|
12
|
+
SCOPE_BASES = %i[active_at current upcoming expired overlapping].freeze
|
|
13
13
|
|
|
14
14
|
included do
|
|
15
15
|
class_attribute :schedulable_starts_at_field, instance_accessor: false, default: DEFAULT_STARTS_AT_FIELD
|
|
@@ -49,8 +49,50 @@ module ConcernsOnRails
|
|
|
49
49
|
label: "ConcernsOnRails::Models::Schedulable")
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
+
# The relation behind the `overlapping` scope. Public (like
|
|
53
|
+
# schedulable_window) because scope lambdas resolve methods through the
|
|
54
|
+
# relation, which cannot reach private class methods.
|
|
55
|
+
def schedulable_overlapping(from, to = nil)
|
|
56
|
+
from, to, inclusive_end = schedulable_window(from, to)
|
|
57
|
+
relation = all
|
|
58
|
+
relation = schedulable_started_before(relation, to, inclusive_end) if schedulable_starts_at_field
|
|
59
|
+
relation = schedulable_ending_after(relation, from) if schedulable_ends_at_field && from
|
|
60
|
+
relation
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Normalizes an overlapping/overlaps? window into [from, to,
|
|
64
|
+
# inclusive_end]: two Times, or one Range (`..` → inclusive end).
|
|
65
|
+
def schedulable_window(from, to)
|
|
66
|
+
inclusive_end = false
|
|
67
|
+
if from.is_a?(Range)
|
|
68
|
+
raise ArgumentError, "ConcernsOnRails::Models::Schedulable: pass a Range or from/to, not both" unless to.nil?
|
|
69
|
+
|
|
70
|
+
inclusive_end = !from.exclude_end?
|
|
71
|
+
to = from.end
|
|
72
|
+
from = from.begin
|
|
73
|
+
end
|
|
74
|
+
raise ArgumentError, "ConcernsOnRails::Models::Schedulable: from must not be after to" if from && to && from > to
|
|
75
|
+
|
|
76
|
+
[from, to, inclusive_end]
|
|
77
|
+
end
|
|
78
|
+
|
|
52
79
|
private
|
|
53
80
|
|
|
81
|
+
# Started (NULL never overlaps, matching active_at) and, with a `to`,
|
|
82
|
+
# started before it — or on it for an inclusive Range end.
|
|
83
|
+
def schedulable_started_before(relation, to, inclusive_end)
|
|
84
|
+
column = arel_table[schedulable_starts_at_field]
|
|
85
|
+
return relation.where.not(schedulable_starts_at_field => nil) if to.nil?
|
|
86
|
+
|
|
87
|
+
relation.where(inclusive_end ? column.lteq(to) : column.lt(to))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Open-ended, or ending strictly after `from`.
|
|
91
|
+
def schedulable_ending_after(relation, from)
|
|
92
|
+
column = arel_table[schedulable_ends_at_field]
|
|
93
|
+
relation.where(column.eq(nil).or(column.gt(from)))
|
|
94
|
+
end
|
|
95
|
+
|
|
54
96
|
# Built here rather than inline in `included do` so the names can be
|
|
55
97
|
# affixed. `current` resolves `active_at` through the names map — a
|
|
56
98
|
# literal call would break under an affix.
|
|
@@ -88,6 +130,14 @@ module ConcernsOnRails
|
|
|
88
130
|
|
|
89
131
|
where(arel_table[field].lteq(Time.zone.now))
|
|
90
132
|
}
|
|
133
|
+
|
|
134
|
+
# Records whose window intersects [from, to) — bookings that clash,
|
|
135
|
+
# events on a calendar page. Same boundary rules as active_at
|
|
136
|
+
# (inclusive start, exclusive end): a window that merely touches the
|
|
137
|
+
# query window does not overlap. Either side may be nil (unbounded);
|
|
138
|
+
# a Range works too, and `from..to` makes the end inclusive.
|
|
139
|
+
# Unstarted records (nil starts_at) never overlap, matching active_at.
|
|
140
|
+
scope schedulable_scope_names[:overlapping], ->(from, to = nil) { schedulable_overlapping(from, to) }
|
|
91
141
|
end
|
|
92
142
|
end # rubocop:enable Metrics/BlockLength
|
|
93
143
|
|
|
@@ -100,6 +150,13 @@ module ConcernsOnRails
|
|
|
100
150
|
active_at?(Time.zone.now)
|
|
101
151
|
end
|
|
102
152
|
|
|
153
|
+
# Does this record's window intersect [from, to)? Mirrors the
|
|
154
|
+
# `overlapping` scope — boundaries, nil sides and Ranges included.
|
|
155
|
+
def overlaps?(from, to = nil)
|
|
156
|
+
from, to, inclusive_end = self.class.schedulable_window(from, to)
|
|
157
|
+
schedulable_starts_before?(to, inclusive_end) && schedulable_ends_after?(from)
|
|
158
|
+
end
|
|
159
|
+
|
|
103
160
|
def upcoming?
|
|
104
161
|
field = self.class.schedulable_starts_at_field
|
|
105
162
|
value = field && self[field]
|
|
@@ -169,6 +226,30 @@ module ConcernsOnRails
|
|
|
169
226
|
value.nil? || value > time
|
|
170
227
|
end
|
|
171
228
|
private :schedulable_not_ended_at?
|
|
229
|
+
|
|
230
|
+
# Started (nil = never, matching active_at) and, when `to` is given,
|
|
231
|
+
# started before it (or on it, for an inclusive Range end).
|
|
232
|
+
def schedulable_starts_before?(to, inclusive_end)
|
|
233
|
+
field = self.class.schedulable_starts_at_field
|
|
234
|
+
return true unless field
|
|
235
|
+
|
|
236
|
+
value = self[field]
|
|
237
|
+
return false if value.nil?
|
|
238
|
+
return true if to.nil?
|
|
239
|
+
|
|
240
|
+
inclusive_end ? value <= to : value < to
|
|
241
|
+
end
|
|
242
|
+
private :schedulable_starts_before?
|
|
243
|
+
|
|
244
|
+
# Still running at `from` (open-ended or ending strictly after it).
|
|
245
|
+
def schedulable_ends_after?(from)
|
|
246
|
+
field = self.class.schedulable_ends_at_field
|
|
247
|
+
return true unless field && from
|
|
248
|
+
|
|
249
|
+
value = self[field]
|
|
250
|
+
value.nil? || value > from
|
|
251
|
+
end
|
|
252
|
+
private :schedulable_ends_after?
|
|
172
253
|
end
|
|
173
254
|
end
|
|
174
255
|
end
|
|
@@ -24,6 +24,7 @@ module ConcernsOnRails
|
|
|
24
24
|
# Article.tagged_with("ruby", "rails") # records carrying BOTH tags
|
|
25
25
|
# Article.tagged_with("ruby", "go", any: true) # records carrying ANY tag
|
|
26
26
|
# Article.all_tags # sorted unique tags in use
|
|
27
|
+
# Article.published.tag_counts(limit: 20) # { "ruby" => 12, "rails" => 7, ... } for a tag cloud
|
|
27
28
|
#
|
|
28
29
|
# Notes:
|
|
29
30
|
# * Matching is boundary-safe ("rail" does not match "rails").
|
|
@@ -32,7 +33,7 @@ module ConcernsOnRails
|
|
|
32
33
|
# "a" and "b"), everywhere, so what you read back always matches what
|
|
33
34
|
# a save would have produced.
|
|
34
35
|
# * Reach for acts-as-taggable-on when you need tag contexts, ownership,
|
|
35
|
-
#
|
|
36
|
+
# or polymorphic tags shared across models.
|
|
36
37
|
module Taggable
|
|
37
38
|
extend ActiveSupport::Concern
|
|
38
39
|
|
|
@@ -92,6 +93,38 @@ module ConcernsOnRails
|
|
|
92
93
|
.uniq.sort
|
|
93
94
|
end
|
|
94
95
|
|
|
96
|
+
# Tag => number of records carrying it, ordered by count desc then tag
|
|
97
|
+
# asc (a Hash keeps insertion order, so `.first(n)` / `.keys` are the
|
|
98
|
+
# cloud). Relation-aware: `Article.published.tag_counts`. One GROUP BY
|
|
99
|
+
# query on the raw column — identical tag strings ship once with their
|
|
100
|
+
# row count and are split in Ruby, so the cost scales with DISTINCT tag
|
|
101
|
+
# strings, not rows. `limit:` keeps the top N.
|
|
102
|
+
def tag_counts(limit: nil)
|
|
103
|
+
counts = Hash.new(0)
|
|
104
|
+
taggable_count_scope.where.not(taggable_field => nil)
|
|
105
|
+
.group(taggable_field).count.each do |raw, rows|
|
|
106
|
+
taggable_split(raw).each { |tag| counts[tag] += rows }
|
|
107
|
+
end
|
|
108
|
+
ordered = counts.sort_by { |tag, count| [-count, tag] }
|
|
109
|
+
ordered = ordered.first([limit.to_i, 0].max) if limit
|
|
110
|
+
ordered.to_h
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# The rows to tally. A caller's select/group/order cannot survive the
|
|
114
|
+
# GROUP BY (COUNT(a, b) is invalid SQL, an array group key is
|
|
115
|
+
# meaningless, and a trailing ORDER BY breaks Postgres), so they are
|
|
116
|
+
# stripped. limit/offset genuinely pick rows, so they are honoured by
|
|
117
|
+
# resolving the window to ids first -- MySQL rejects LIMIT inside an
|
|
118
|
+
# IN subquery, so the ids come back through Ruby. The window is
|
|
119
|
+
# bounded by definition, so that stays cheap.
|
|
120
|
+
def taggable_count_scope
|
|
121
|
+
relation = all
|
|
122
|
+
base = relation.except(:select, :group)
|
|
123
|
+
return base.except(:order) unless (relation.limit_value || relation.offset_value) && primary_key
|
|
124
|
+
|
|
125
|
+
unscoped.where(primary_key => base.pluck(primary_key))
|
|
126
|
+
end
|
|
127
|
+
|
|
95
128
|
# Split a raw stored column value into a normalized tag array.
|
|
96
129
|
def taggable_split(raw)
|
|
97
130
|
taggable_clean_all(raw.to_s.split(taggable_delimiter))
|
|
@@ -6,6 +6,8 @@ module ConcernsOnRails
|
|
|
6
6
|
# string. Pure and stateless; used by Models::Monetizable. Uses BigDecimal
|
|
7
7
|
# throughout so there is no binary-float rounding drift.
|
|
8
8
|
module Money
|
|
9
|
+
FORMAT_OPTIONS = %i[unit precision delimiter separator subunit_to_unit].freeze
|
|
10
|
+
|
|
9
11
|
module_function
|
|
10
12
|
|
|
11
13
|
# format(199999) => "$1,999.99"
|
|
@@ -32,6 +34,33 @@ module ConcernsOnRails
|
|
|
32
34
|
"#{sign}#{unit}#{number}"
|
|
33
35
|
end
|
|
34
36
|
|
|
37
|
+
# Merge per-call display overrides into a field's formatting config,
|
|
38
|
+
# rejecting typos (`units:`) instead of silently ignoring them.
|
|
39
|
+
def format_options(config, overrides, label)
|
|
40
|
+
return config if overrides.empty?
|
|
41
|
+
|
|
42
|
+
unknown = overrides.keys - FORMAT_OPTIONS
|
|
43
|
+
raise ArgumentError, "#{label}: unknown formatting option(s): #{unknown.join(', ')}" if unknown.any?
|
|
44
|
+
|
|
45
|
+
config.merge(overrides)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Subunits to a BigDecimal amount. A grouped relation's aggregate is a
|
|
49
|
+
# Hash keyed by the GROUP BY value, so map over it rather than feeding
|
|
50
|
+
# the whole Hash to BigDecimal().
|
|
51
|
+
def decimal(cents, subunit)
|
|
52
|
+
return cents.transform_values { |value| decimal(value, subunit) } if cents.is_a?(Hash)
|
|
53
|
+
|
|
54
|
+
cents.nil? ? nil : BigDecimal(cents.to_s) / subunit
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# format() that is likewise grouped-relation aware and nil-safe.
|
|
58
|
+
def format_each(cents, options)
|
|
59
|
+
return cents.transform_values { |value| format_each(value, options) } if cents.is_a?(Hash)
|
|
60
|
+
|
|
61
|
+
cents.nil? ? nil : format(cents, options)
|
|
62
|
+
end
|
|
63
|
+
|
|
35
64
|
# Insert the thousands delimiter into a non-negative integer string.
|
|
36
65
|
# Single lookahead pass — the old reverse/gsub/reverse allocated three
|
|
37
66
|
# strings per formatted amount.
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: concerns_on_rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.28.
|
|
4
|
+
version: 1.28.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ethan Nguyen
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-17 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: actionpack
|