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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 615b4ddcff3ebdffc7e32f618731510bf5e386fa015cb0a1ae958a4e6fb04d1c
4
- data.tar.gz: 723b3ae2077f43fc5771947d2e610f011ca83ee8215b78fb0894a04400362220
3
+ metadata.gz: 5c6fcd158fd0dce6d1744e9ad6be5d921bf283e921e717a8327f04f53e8668f4
4
+ data.tar.gz: fe3b93a06be3a0cbb4b0e1904a976393b77d24c4075f1d8dccb98e13d3ac6597
5
5
  SHA512:
6
- metadata.gz: 352ff281ce000213b296a1b8ccae97478d6a6726644fb6bcf69c5f4e05d94c57e674f5cff59f3ed0ff8d90891c90b6ebc53affdec5f47ea64102ca88a3c40a0e
7
- data.tar.gz: 595236a2464439aa26d10ed118fc5d307ca47022cac8e5c9cae6077419da39dc7a3fc2d085534bfb2d36bdf3625c99442054dc7fd5868be71b9034923aa66e11
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,460 RSpec examples** run against a real database on every CI build
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
- - Reach for [`acts-as-taggable-on`](https://github.com/mbleigh/acts-as-taggable-on) when you need tag contexts, ownership, counts/clouds, or polymorphic tags shared across models.
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
- **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 three methods.
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
- **Options**: `available:` (allow-list for matching; defaults to `I18n.available_locales`), `default:`, `param:` (default `:locale`), `header:` (default `true`).
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,460 examples)
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.4.gem # install locally
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; runs the action under the resolved locale.
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
- I18n.with_locale(resolved_locale, &)
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
- }.freeze
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 optional custom
71
- # "Header-Name" => "value" pairs. Later declarations win on collision.
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
- resolved = presets.to_h do |key|
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 { |name, value| response.set_header(name, value) }
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
- def duplicate(overrides = {})
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
- self.class.duplicable_config[:associations].each do |name|
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. Expirable defines no lifecycle hooks, so this is a
35
- # single UPDATE unless the model overrode `expire!` or declares
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 validations,
56
- # plus why) lives in Support::BatchOps.fast_path?. Expirable defines
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, :expire!)
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
- update(self.class.expirable_field => time)
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
- private :expiry_extension_base
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 { |cents_field| define_money_accessors(cents_field.to_sym, as, config) }
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, as, config)
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
- cents = self[cents_field]
92
- cents.nil? ? nil : ConcernsOnRails::Support::Money.format(cents, config)
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
- # tag counts/clouds, or polymorphic tags shared across models.
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.
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.28.4".freeze
2
+ VERSION = "1.28.5".freeze
3
3
  end
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
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-16 00:00:00.000000000 Z
11
+ date: 2026-09-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actionpack