concerns_on_rails 1.18.0 → 1.19.0
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 +8 -0
- data/README.md +71 -0
- data/lib/concerns_on_rails/controllers/deprecatable.rb +268 -0
- data/lib/concerns_on_rails/legacy_aliases.rb +1 -0
- data/lib/concerns_on_rails/models/storable.rb +436 -0
- data/lib/concerns_on_rails/version.rb +1 -1
- data/lib/concerns_on_rails.rb +2 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 191acd17664d56588e54832b2377ede09cf836fe481a0e4c27246adae9f2eb76
|
|
4
|
+
data.tar.gz: 95dc3faff52552da6688961fd51e926d4eb2a041f9974f05ea77a491b698d434
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f472e83548c6ccd7850c3d3c9994cbb1efac4637210f07f35f69e16ca700aa7f131bed7d24737e825896aa8301cd2786dfe70aa0f563002f1bc8d199056fae28
|
|
7
|
+
data.tar.gz: 6c231481577b2db38df1730cb16ad72c2f06ea7bba9d9dc0730e0c8068dce94597b26b514241e1ebd6be51b91caf4ae75dbb8ea32aa11f1aef9109cd45f3d38b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 1.19.0 (2026-06-13)
|
|
4
|
+
|
|
5
|
+
Two new concerns, selected by a unanimous three-judge design panel over six independently-proposed candidates (typed-JSON-settings and RFC-deprecation-headers each beat conditional counter caches, deep cloning, anonymization, params contracts, feature gates, and maintenance mode on value × shippability), then hardened in review (an `ActiveSupport::TimeWithZone` passed as `deprecated_at:`/`sunset_at:` — i.e. `Time.current` — was spuriously rejected as unparseable because `Module#===` ignores TimeWithZone's `is_a?(Time)` lie; a bare `Date` written to a `:datetime` key now anchors to midnight UTC instead of the host zone via `Date#to_time`; an unknown `header_format:` now raises at declaration time instead of silently emitting the RFC form). 894 examples, 0 failures.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Models::Storable**: typed, defaulted, optionally-validated accessors over a single JSON-or-text column ("store_attribute-lite") — native `store_accessor` is untyped on every supported Rails version (a form-submitted `"true"` stays a String), has no defaults and no per-key dirty methods. `storable_by :settings, theme: { type: :string, default: "light", in: %w[light dark] }, ...` (repeatable — same column merges keys, columns independent, subclasses add keys without mutating the parent; `prefix:`/`suffix:` affix the generated names) gives a casting reader/writer, a `?` predicate (boolean keys), `_changed?`/`_was` computed per key against the column's own previous value, and `reset_<key>` (key removal → default applies again, distinct from an explicitly-written nil, which reads back as nil). Casting via `ActiveModel::Type` with JSON-safe representations: `:decimal` as a precision-safe String, `:datetime` as UTC ISO8601 with microseconds, `:date` as ISO8601, `:json` passthrough (reader returns a dup — reassign, don't mutate). The codec never uses `serialize` (sidestepping the Rails 7.1 kwarg drift entirely): a plain text column is JSON-encoded/decoded manually and tolerantly (corrupt JSON → `{}`, garbage values cast to nil, readers never raise), while native `json`/`jsonb` columns and host-app-`serialize`d columns are detected lazily and handed the Hash. Undeclared keys are preserved; string keys throughout; `in:` adds an inclusion validation on the (affixed) accessor name, nil/absent passing. Every generated name is collision-checked against methods and columns at macro time; key specs are shape- and type-validated (`ArgumentError`, ColumnGuard for the column). Whole-column dirty / last-write-wins semantics documented. Zero new runtime dependencies.
|
|
9
|
+
- **Controllers::Deprecatable**: standards-based API endpoint deprecation — RFC 9745 `Deprecation` (final structured-fields form `@<unix>`, or the widely-deployed pre-RFC draft literal `true` via `header_format: :legacy`), RFC 8594 `Sunset` (IMF-fixdate via `Time#httpdate`, not the classic hand-rolled ISO8601 bug), and RFC 8288 `Link` rels (`rel="deprecation"` migration docs + `rel="successor-version"`, appended to any existing Link header, never clobbered). `deprecate_actions :index, :show, deprecated_at:, sunset_at:, link:, successor:, after_sunset:, header_format:, notify:` — repeatable, inherited; no positional actions = whole-controller catch-all; the LAST matching rule wins (deliberately the reverse of Idempotentable's first-match: deprecation rules are configuration overrides, so a base-controller catch-all is naturally overridden by a later action-specific declaration) and exactly one Deprecation header is ever emitted. Times parse eagerly at declaration (`ArgumentError` on garbage; bare dates = 00:00 UTC — sunset is an instant; `sunset_at >= deprecated_at` enforced; TimeWithZone accepted). `after_sunset: :gone` (requires `sunset_at`) halts with 410 `endpoint_sunset` at/after the boundary instant — headers still ride the 410 so the cut-off self-documents — via Respondable's `render_error` when present, inline envelope otherwise; the default `:headers` never blocks. Every matching hit instruments `deprecated_endpoint.concerns_on_rails` (`ActiveSupport::Notifications`) and `instance_exec`s `notify:` (raising notify propagates — broken metrics should be loud) through the `on_deprecated_access(rule)` override point, so teams can measure stragglers before flipping enforcement. `deprecation_active?`/`sunset_passed?` predicates for serializers; one UTC clock seam (`deprecation_now`). Zero new runtime dependencies.
|
|
10
|
+
|
|
3
11
|
## 1.18.0 (2026-06-12)
|
|
4
12
|
|
|
5
13
|
Two new concerns, designed and hardened through adversarial design- and code-review rounds (a shared-reflection registration strategy that produced invalid SQL was caught and replaced before implementation; a time-serialization path that silently lost sub-second precision likewise; a NULL page-boundary value that would have silently dropped rows now raises; a post-review pass fixed `has_many :through` aliasing, which crashed at macro time under lazy class loading and re-derived its `source:` from the alias name at query time). A follow-up enhancement round added bidirectional cursors, allow-listed order presets, and row-value predicates to CursorPaginatable, and `only:`/`except:`/`deprecated:`/`alias_foreign_key:` to Aliasable. 793 examples, 0 failures.
|
data/README.md
CHANGED
|
@@ -46,6 +46,7 @@ Article.published.without_deleted.find("hello-world")
|
|
|
46
46
|
- [Auditable](#-auditable) — single-column change history ("paper_trail-lite")
|
|
47
47
|
- [Lockable](#-lockable) — failed-attempt tracking + account lockout
|
|
48
48
|
- [Aliasable](#-aliasable) — full read/write/query aliases for associations
|
|
49
|
+
- [Storable](#-storable) — typed accessors over one JSON settings column ("store_attribute-lite")
|
|
49
50
|
- **Controller concerns**
|
|
50
51
|
- [Paginatable](#-paginatable) — offset pagination with headers
|
|
51
52
|
- [CursorPaginatable](#-cursorpaginatable) — cursor (keyset) pagination with headers
|
|
@@ -61,6 +62,7 @@ Article.published.without_deleted.find("hello-world")
|
|
|
61
62
|
- [Timezoneable](#-timezoneable) — per-request `Time.zone` from params / header / cookie
|
|
62
63
|
- [Idempotentable](#-idempotentable) — `Idempotency-Key` request replay (409 on concurrent duplicates)
|
|
63
64
|
- [WebhookVerifiable](#-webhookverifiable) — HMAC verification for inbound webhooks (Stripe/GitHub/Shopify)
|
|
65
|
+
- [Deprecatable](#-deprecatable) — RFC `Deprecation`/`Sunset` headers + 410 sunset enforcement
|
|
64
66
|
- [Module paths & namespacing](#-module-paths--namespacing)
|
|
65
67
|
- [Development](#-development)
|
|
66
68
|
- [Contributing](#-contributing)
|
|
@@ -1039,6 +1041,42 @@ Book.joins(:sections).where(sections: { title: "Intro" })
|
|
|
1039
1041
|
|
|
1040
1042
|
---
|
|
1041
1043
|
|
|
1044
|
+
## ⚙️ Storable
|
|
1045
|
+
|
|
1046
|
+
Typed, defaulted, optionally-validated accessors over a **single JSON (or text) column** ("store_attribute-lite"). Rails' native `store_accessor` is untyped on every supported version — a form-submitted `"true"` stays the String `"true"` — with no defaults and no per-key dirty tracking; that gap is why the `store_attribute` / `jsonb_accessor` gems exist.
|
|
1047
|
+
|
|
1048
|
+
```ruby
|
|
1049
|
+
class Account < ApplicationRecord
|
|
1050
|
+
include ConcernsOnRails::Storable
|
|
1051
|
+
|
|
1052
|
+
storable_by :settings,
|
|
1053
|
+
theme: { type: :string, default: "light", in: %w[light dark] },
|
|
1054
|
+
notifications: { type: :boolean, default: true },
|
|
1055
|
+
items_per_page: { type: :integer, default: 25 },
|
|
1056
|
+
trial_ends_at: { type: :datetime }
|
|
1057
|
+
storable_by :flags, { beta: { type: :boolean, default: false } }, prefix: :flag
|
|
1058
|
+
end
|
|
1059
|
+
|
|
1060
|
+
account.theme # => "light" (virtual default; nothing persisted)
|
|
1061
|
+
account.notifications = "0" # params arrive as strings…
|
|
1062
|
+
account.notifications # => false (…and read back cast)
|
|
1063
|
+
account.notifications? # boolean keys get a predicate
|
|
1064
|
+
account.items_per_page_changed? # per-key dirty (and items_per_page_was)
|
|
1065
|
+
account.reset_theme # drop the key → the default applies again
|
|
1066
|
+
account.flag_beta # affixed accessor
|
|
1067
|
+
```
|
|
1068
|
+
|
|
1069
|
+
**Options** (per key): `type:` (`:string` default, `:integer`, `:float`, `:decimal`, `:boolean`, `:date`, `:datetime`, `:json`), `default:` (a value, or a Proc `instance_exec`'d per read), `in:` (inclusion validation, errors on the accessor name). Macro options: `prefix:` / `suffix:` affix the generated method names (the collision escape hatch). The macro is repeatable — repeat calls for the same column merge keys, different columns are independent, and subclasses can add keys without affecting the parent.
|
|
1070
|
+
|
|
1071
|
+
**Notes**
|
|
1072
|
+
- Works on a plain `text` column (JSON encoded/decoded internally), a native `json`/`jsonb` column, or a column the host app already `serialize`d — detected automatically. `serialize` itself is never used, so the Rails 7.1 API drift is irrelevant.
|
|
1073
|
+
- nil vs unset: a written `nil` (explicit JSON null) reads back as `nil` and does **not** fall back to the default; `reset_<key>` removes the key so the default applies again. `:decimal` is stored as a precision-safe string, `:date`/`:datetime` as ISO8601 (datetime in UTC at microsecond precision).
|
|
1074
|
+
- Writing one key dirties (and saves) the **whole column** — concurrent writers to different keys are last-write-wins on the hash. Undeclared keys are preserved. `:json` readers return a dup: reassign, don't mutate in place.
|
|
1075
|
+
- Generated names are collision-checked against existing methods and columns at macro time (`ArgumentError`; affix to escape). Read-side casting never raises — corrupt column JSON decodes as `{}`, garbage values cast to `nil`.
|
|
1076
|
+
- Reach for [`store_attribute`](https://github.com/palkan/store_attribute) / [`jsonb_accessor`](https://github.com/madeintandem/jsonb_accessor) when you need to **query** into the store (jsonb operators, store-backed scopes).
|
|
1077
|
+
|
|
1078
|
+
---
|
|
1079
|
+
|
|
1042
1080
|
# 🎮 Controller Concerns
|
|
1043
1081
|
|
|
1044
1082
|
Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
|
|
@@ -1504,6 +1542,39 @@ end
|
|
|
1504
1542
|
|
|
1505
1543
|
---
|
|
1506
1544
|
|
|
1545
|
+
## 🌅 Deprecatable
|
|
1546
|
+
|
|
1547
|
+
Standards-based **API endpoint deprecation**: the RFC 9745 `Deprecation` and RFC 8594 `Sunset` headers, `Link` rels pointing at the migration docs and the successor endpoint, an instrumentation hook to measure who still calls the endpoint, and optional **410 Gone** enforcement once the sunset instant passes. This is how Stripe/GitHub/Zalando retire API versions — and nothing native exists on any Rails version.
|
|
1548
|
+
|
|
1549
|
+
```ruby
|
|
1550
|
+
class Api::V1::OrdersController < ApplicationController
|
|
1551
|
+
include ConcernsOnRails::Controllers::Deprecatable
|
|
1552
|
+
|
|
1553
|
+
deprecate_actions :index, :show,
|
|
1554
|
+
deprecated_at: "2026-06-01",
|
|
1555
|
+
sunset_at: "2026-12-31T00:00:00Z",
|
|
1556
|
+
link: "https://docs.example.com/v1-migration",
|
|
1557
|
+
successor: "https://api.example.com/v2/orders",
|
|
1558
|
+
after_sunset: :gone, # default :headers — announce, never block
|
|
1559
|
+
notify: -> { StatsD.increment("api.v1.orders.deprecated") }
|
|
1560
|
+
end
|
|
1561
|
+
|
|
1562
|
+
# Every matching response then carries:
|
|
1563
|
+
# Deprecation: @1780272000
|
|
1564
|
+
# Sunset: Thu, 31 Dec 2026 00:00:00 GMT
|
|
1565
|
+
# Link: <https://docs.example.com/v1-migration>; rel="deprecation", <https://api.example.com/v2/orders>; rel="successor-version"
|
|
1566
|
+
```
|
|
1567
|
+
|
|
1568
|
+
**Options**: `deprecated_at:` (required; Time/Date/String — parsed eagerly, normalized to UTC), `sunset_at:` (optional, must be ≥ `deprecated_at`; a bare date means **00:00 UTC that day** — sunset is an instant, not end-of-day), `link:` / `successor:` (URLs), `after_sunset:` (`:headers` default | `:gone` → 410 with code `endpoint_sunset` at/after the sunset instant), `header_format:` (`:rfc9745` default, `@<unix>` | `:legacy`, the widely-deployed draft literal `true`), `notify:` (callable, `instance_exec`'d per matching request — a raising notify propagates on purpose). No positional actions = catch-all for the whole controller. **The last matching rule wins**, so an action-specific declaration naturally overrides a base controller's catch-all.
|
|
1569
|
+
|
|
1570
|
+
**Notes**
|
|
1571
|
+
- Headers go out on every matching response — **including the 410 itself**, so the cut-off self-documents. `Link` values are appended to any existing `Link` header (pagination, CDN), never clobbered.
|
|
1572
|
+
- Each hit instruments `deprecated_endpoint.concerns_on_rails` (`ActiveSupport::Notifications`) with `{controller:, action:, deprecated_at:, sunset_at:}` — subscribe to count stragglers *before* flipping `after_sunset: :gone`. Override `on_deprecated_access(rule)` to replace the default instrumentation.
|
|
1573
|
+
- 410 bodies delegate to `Respondable`'s `render_error` when present (inline JSON envelope otherwise). `skip_before_action :apply_api_deprecations` opts an action out; `deprecation_active?` / `sunset_passed?` are available for serializers/response bodies.
|
|
1574
|
+
- Flipping `:gone` is a deliberate, customer-facing cut-off — coordinate it with `notify:`-driven outreach, and mind CDN-cached responses that may outlive the headers.
|
|
1575
|
+
|
|
1576
|
+
---
|
|
1577
|
+
|
|
1507
1578
|
## 🗂️ Module paths & namespacing
|
|
1508
1579
|
|
|
1509
1580
|
Every concern is available under two paths:
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
require "active_support/notifications"
|
|
3
|
+
require "date"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module ConcernsOnRails
|
|
7
|
+
module Controllers
|
|
8
|
+
# Standards-based API endpoint deprecation — the receiving end of an API
|
|
9
|
+
# retirement plan (how Stripe / GitHub / Zalando sunset versions). Declare
|
|
10
|
+
# which actions are deprecated and the standard signalling headers go out on
|
|
11
|
+
# every response so clients (and their SDKs) can discover the deprecation,
|
|
12
|
+
# the migration docs, the successor endpoint, and the hard cut-off — then
|
|
13
|
+
# optionally enforce a 410 Gone once that cut-off passes.
|
|
14
|
+
#
|
|
15
|
+
# class Api::V1::OrdersController < ApplicationController
|
|
16
|
+
# include ConcernsOnRails::Controllers::Deprecatable
|
|
17
|
+
#
|
|
18
|
+
# deprecate_actions :index, :show,
|
|
19
|
+
# deprecated_at: "2026-06-01",
|
|
20
|
+
# sunset_at: "2026-12-31T00:00:00Z",
|
|
21
|
+
# link: "https://docs.example.com/v1-migration",
|
|
22
|
+
# successor: "https://api.example.com/v2/orders",
|
|
23
|
+
# after_sunset: :gone,
|
|
24
|
+
# notify: -> { StatsD.increment("api.v1.orders.deprecated") }
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# Headers emitted (always — including on the 410, so the failure is
|
|
28
|
+
# self-documenting):
|
|
29
|
+
# * Deprecation — RFC 9745. The final form is a structured-fields Date
|
|
30
|
+
# item: "@<unix-seconds>" of `deprecated_at`. `header_format: :legacy`
|
|
31
|
+
# emits the still-widely-deployed pre-RFC draft form, the literal "true".
|
|
32
|
+
# * Sunset — RFC 8594. An IMF-fixdate (HTTP-date) via Time#httpdate,
|
|
33
|
+
# NOT ISO 8601 — hand-rolling that is the classic bug.
|
|
34
|
+
# * Link — RFC 8288. rel="deprecation" (the migration doc) and/or
|
|
35
|
+
# rel="successor-version" (the replacement endpoint), APPENDED to any
|
|
36
|
+
# Link header already on the response (pagination / CDN), never clobbered.
|
|
37
|
+
#
|
|
38
|
+
# `sunset_at` is an INSTANT, not a calendar day: a bare date "2026-12-31" is
|
|
39
|
+
# normalised to 00:00 UTC, so the endpoint dies at the START of that day, not
|
|
40
|
+
# end-of-day. Times are parsed eagerly at declaration time and normalised to
|
|
41
|
+
# UTC.
|
|
42
|
+
#
|
|
43
|
+
# THE LAST MATCHING RULE WINS (deliberately the reverse of Idempotentable's
|
|
44
|
+
# first-match): deprecation rules are configuration overrides, not guards, so
|
|
45
|
+
# a V1 base controller's catch-all `deprecate_actions` is naturally
|
|
46
|
+
# overridden by a later, action-specific declaration in a subclass. Exactly
|
|
47
|
+
# one rule applies per request and exactly one Deprecation header is emitted.
|
|
48
|
+
# With no positional actions a rule is a catch-all for the whole controller
|
|
49
|
+
# (the WebhookVerifiable convention).
|
|
50
|
+
#
|
|
51
|
+
# `after_sunset: :gone` (requires `sunset_at`) halts with 410 once the sunset
|
|
52
|
+
# instant is reached (the boundary instant counts as sunset — inclusive). The
|
|
53
|
+
# default `:headers` NEVER blocks, however long past sunset — flip to `:gone`
|
|
54
|
+
# only once metrics show callers have migrated. `on_deprecated_access` is that
|
|
55
|
+
# metrics seam: it instruments "deprecated_endpoint.concerns_on_rails" and
|
|
56
|
+
# runs `notify:`. A raising `notify` propagates on purpose — a broken metrics
|
|
57
|
+
# hook should be loud, not silently swallowed (WebhookVerifiable's stance).
|
|
58
|
+
module Deprecatable
|
|
59
|
+
extend ActiveSupport::Concern
|
|
60
|
+
|
|
61
|
+
LABEL = "ConcernsOnRails::Controllers::Deprecatable".freeze
|
|
62
|
+
VALID_AFTER_SUNSET = %i[headers gone].freeze
|
|
63
|
+
VALID_HEADER_FORMATS = %i[rfc9745 legacy].freeze
|
|
64
|
+
UNPARSEABLE = "could not be parsed (pass a Time, Date, DateTime, or parseable String)".freeze
|
|
65
|
+
|
|
66
|
+
included do
|
|
67
|
+
class_attribute :deprecatable_rules, instance_accessor: false, default: []
|
|
68
|
+
before_action :apply_api_deprecations
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
module ClassMethods
|
|
72
|
+
# Declare a deprecation rule. No positional actions = catch-all for the
|
|
73
|
+
# whole controller. Repeatable; rules accumulate (reassigned, never
|
|
74
|
+
# mutated, so subclasses inherit) and the LAST one matching the current
|
|
75
|
+
# action wins.
|
|
76
|
+
def deprecate_actions(*actions, deprecated_at: nil, sunset_at: nil, link: nil, successor: nil,
|
|
77
|
+
after_sunset: :headers, header_format: :rfc9745, notify: nil)
|
|
78
|
+
actions = actions.flatten.map(&:to_s)
|
|
79
|
+
after_sunset = after_sunset.to_sym
|
|
80
|
+
header_format = header_format.to_sym
|
|
81
|
+
|
|
82
|
+
deprecated_time = parse_deprecation_time(deprecated_at)
|
|
83
|
+
sunset_time = sunset_at.nil? ? nil : parse_deprecation_time(sunset_at)
|
|
84
|
+
validate_deprecate_actions!(deprecated_at: deprecated_at, deprecated_time: deprecated_time,
|
|
85
|
+
sunset_at: sunset_at, sunset_time: sunset_time, link: link,
|
|
86
|
+
successor: successor, after_sunset: after_sunset, notify: notify)
|
|
87
|
+
unless VALID_HEADER_FORMATS.include?(header_format)
|
|
88
|
+
raise ArgumentError, "#{LABEL}: :header_format must be one of #{VALID_HEADER_FORMATS.join(', ')}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
rule = { actions: actions, deprecated_at: deprecated_time, sunset_at: sunset_time,
|
|
92
|
+
link: link, successor: successor, after_sunset: after_sunset,
|
|
93
|
+
header_format: header_format, notify: notify }
|
|
94
|
+
self.deprecatable_rules = deprecatable_rules + [rule]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def validate_deprecate_actions!(deprecated_at:, deprecated_time:, sunset_at:, sunset_time:,
|
|
100
|
+
link:, successor:, after_sunset:, notify:)
|
|
101
|
+
raise ArgumentError, "#{LABEL}: :deprecated_at is required" if deprecated_at.nil?
|
|
102
|
+
raise ArgumentError, "#{LABEL}: :deprecated_at #{UNPARSEABLE}" if deprecated_time.nil?
|
|
103
|
+
|
|
104
|
+
validate_deprecation_sunset!(sunset_at, sunset_time, deprecated_time, after_sunset)
|
|
105
|
+
validate_deprecation_link!(:link, link)
|
|
106
|
+
validate_deprecation_link!(:successor, successor)
|
|
107
|
+
raise ArgumentError, "#{LABEL}: :notify must be callable" unless notify.nil? || notify.respond_to?(:call)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def validate_deprecation_sunset!(sunset_at, sunset_time, deprecated_time, after_sunset)
|
|
111
|
+
unless VALID_AFTER_SUNSET.include?(after_sunset)
|
|
112
|
+
raise ArgumentError, "#{LABEL}: :after_sunset must be one of #{VALID_AFTER_SUNSET.join(', ')}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
unless sunset_at.nil?
|
|
116
|
+
raise ArgumentError, "#{LABEL}: :sunset_at #{UNPARSEABLE}" if sunset_time.nil?
|
|
117
|
+
raise ArgumentError, "#{LABEL}: :sunset_at must be on or after :deprecated_at" if sunset_time < deprecated_time
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
raise ArgumentError, "#{LABEL}: after_sunset: :gone requires :sunset_at" if after_sunset == :gone && sunset_time.nil?
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def validate_deprecation_link!(name, value)
|
|
124
|
+
return if value.nil?
|
|
125
|
+
return if value.is_a?(String) && !value.strip.empty?
|
|
126
|
+
|
|
127
|
+
raise ArgumentError, "#{LABEL}: :#{name} must be a non-blank String"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Eager parse to a UTC Time. A bare Date (or date-only String) becomes
|
|
131
|
+
# midnight UTC — sunset is an instant at the START of the day.
|
|
132
|
+
def parse_deprecation_time(value)
|
|
133
|
+
case value
|
|
134
|
+
# TimeWithZone listed explicitly: it lies about is_a?(Time) but
|
|
135
|
+
# Module#=== checks the real ancestry, so `when Time` alone would
|
|
136
|
+
# miss it — and Time.current / 1.month.from_now are exactly the
|
|
137
|
+
# values Rails hosts pass.
|
|
138
|
+
when ActiveSupport::TimeWithZone, Time then value.utc
|
|
139
|
+
when DateTime then value.to_time.utc
|
|
140
|
+
when Date then Time.utc(value.year, value.month, value.day)
|
|
141
|
+
when String then parse_deprecation_string(value)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def parse_deprecation_string(value)
|
|
146
|
+
return nil if value.strip.empty?
|
|
147
|
+
|
|
148
|
+
# DateTime.parse reads a zoneless string as UTC (+00:00) regardless of
|
|
149
|
+
# the host's system timezone — deterministic — and honours an explicit
|
|
150
|
+
# offset when one is present.
|
|
151
|
+
DateTime.parse(value).to_time.utc
|
|
152
|
+
rescue ArgumentError, TypeError
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# before_action entry point. Public so host apps can
|
|
158
|
+
# `skip_before_action :apply_api_deprecations` or override it.
|
|
159
|
+
def apply_api_deprecations
|
|
160
|
+
rule = deprecation_rule_for_action
|
|
161
|
+
return nil unless rule
|
|
162
|
+
|
|
163
|
+
# Order matters: headers go out unconditionally (so even the 410 carries
|
|
164
|
+
# them), THEN we record the access, THEN enforce. Recording before
|
|
165
|
+
# enforcing means metrics still count callers who get the 410.
|
|
166
|
+
emit_deprecation_headers(rule)
|
|
167
|
+
on_deprecated_access(rule)
|
|
168
|
+
enforce_deprecation_sunset(rule)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Public override point + instrumentation seam. Default: emit an
|
|
172
|
+
# ActiveSupport::Notifications event and run the rule's `notify:` callable
|
|
173
|
+
# (instance_exec'd, so it can read controller state). A raising `notify`
|
|
174
|
+
# propagates by design.
|
|
175
|
+
def on_deprecated_access(rule)
|
|
176
|
+
ActiveSupport::Notifications.instrument(
|
|
177
|
+
"deprecated_endpoint.concerns_on_rails",
|
|
178
|
+
controller: deprecation_controller_name, action: deprecation_action_name,
|
|
179
|
+
deprecated_at: rule[:deprecated_at], sunset_at: rule[:sunset_at]
|
|
180
|
+
)
|
|
181
|
+
instance_exec(&rule[:notify]) if rule[:notify]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# True when some rule covers the current action.
|
|
185
|
+
def deprecation_active?
|
|
186
|
+
!deprecation_rule_for_action.nil?
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# True when the matching rule has a sunset_at that the clock has reached.
|
|
190
|
+
def sunset_passed?
|
|
191
|
+
deprecation_sunset_reached?(deprecation_rule_for_action)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
private
|
|
195
|
+
|
|
196
|
+
def deprecation_rule_for_action
|
|
197
|
+
action = deprecation_action_name
|
|
198
|
+
return nil unless action
|
|
199
|
+
|
|
200
|
+
# Last match wins — see the module comment. reverse_each.find returns the
|
|
201
|
+
# most recently declared rule covering this action (catch-all or not).
|
|
202
|
+
self.class.deprecatable_rules.reverse_each.find do |rule|
|
|
203
|
+
rule[:actions].empty? || rule[:actions].include?(action)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def emit_deprecation_headers(rule)
|
|
208
|
+
return unless respond_to?(:response) && response
|
|
209
|
+
|
|
210
|
+
response.set_header("Deprecation", deprecation_header_value(rule))
|
|
211
|
+
response.set_header("Sunset", rule[:sunset_at].httpdate) if rule[:sunset_at]
|
|
212
|
+
emit_deprecation_link_header(rule)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def deprecation_header_value(rule)
|
|
216
|
+
# :legacy is the pre-RFC draft form everyone already ships; :rfc9745 is
|
|
217
|
+
# the structured-fields Date item finalised in RFC 9745.
|
|
218
|
+
return "true" if rule[:header_format] == :legacy
|
|
219
|
+
|
|
220
|
+
"@#{rule[:deprecated_at].to_i}"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def emit_deprecation_link_header(rule)
|
|
224
|
+
parts = []
|
|
225
|
+
parts << "<#{rule[:link]}>; rel=\"deprecation\"" if rule[:link]
|
|
226
|
+
parts << "<#{rule[:successor]}>; rel=\"successor-version\"" if rule[:successor]
|
|
227
|
+
return if parts.empty?
|
|
228
|
+
|
|
229
|
+
value = parts.join(", ")
|
|
230
|
+
# Append, never clobber: pagination / CDN may already have set Link.
|
|
231
|
+
existing = response.headers["Link"]
|
|
232
|
+
value = "#{existing}, #{value}" unless existing.to_s.empty?
|
|
233
|
+
response.set_header("Link", value)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def enforce_deprecation_sunset(rule)
|
|
237
|
+
return unless rule[:after_sunset] == :gone
|
|
238
|
+
return unless deprecation_sunset_reached?(rule)
|
|
239
|
+
|
|
240
|
+
message = "This endpoint was sunset on #{rule[:sunset_at].httpdate}."
|
|
241
|
+
return render_error(message: message, status: :gone, code: "endpoint_sunset") if respond_to?(:render_error)
|
|
242
|
+
return unless respond_to?(:response) && response
|
|
243
|
+
|
|
244
|
+
render json: { success: false, error: { message: message, code: "endpoint_sunset" } }, status: :gone
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Inclusive: the boundary instant itself counts as sunset.
|
|
248
|
+
def deprecation_sunset_reached?(rule)
|
|
249
|
+
!!(rule && rule[:sunset_at] && deprecation_now >= rule[:sunset_at])
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def deprecation_action_name
|
|
253
|
+
respond_to?(:action_name) ? action_name.to_s : nil
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def deprecation_controller_name
|
|
257
|
+
return controller_path if respond_to?(:controller_path)
|
|
258
|
+
|
|
259
|
+
self.class.name
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Single clock seam so travel_to drives everything in specs.
|
|
263
|
+
def deprecation_now
|
|
264
|
+
Time.now.utc
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
require "active_support/core_ext/object/deep_dup"
|
|
3
|
+
require "active_model/type"
|
|
4
|
+
require "bigdecimal"
|
|
5
|
+
require "json"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
module ConcernsOnRails
|
|
9
|
+
module Models
|
|
10
|
+
# Typed, defaulted, optionally-validated accessors over a single JSON (or
|
|
11
|
+
# serialized-text) column ("store_attribute-lite"). Rails' native
|
|
12
|
+
# `store_accessor` is untyped on every supported version (a form-submitted
|
|
13
|
+
# "true" stays the String "true"), ships no defaults, and exposes no
|
|
14
|
+
# per-key dirty methods — the gap that the store_attribute / jsonb_accessor
|
|
15
|
+
# gems exist to fill. This concern closes it with no extra dependency.
|
|
16
|
+
#
|
|
17
|
+
# class Account < ApplicationRecord
|
|
18
|
+
# include ConcernsOnRails::Storable
|
|
19
|
+
#
|
|
20
|
+
# storable_by :settings,
|
|
21
|
+
# theme: { type: :string, default: "light", in: %w[light dark] },
|
|
22
|
+
# notifications: { type: :boolean, default: true },
|
|
23
|
+
# items_per_page: { type: :integer, default: 25 },
|
|
24
|
+
# trial_ends_at: { type: :datetime }
|
|
25
|
+
# storable_by :flags, { beta: { type: :boolean, default: false } }, prefix: :flag
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# account.theme # => "light" (virtual default; nothing stored yet)
|
|
29
|
+
# account.notifications = "0"
|
|
30
|
+
# account.notifications # => false (cast, not the String "0")
|
|
31
|
+
# account.notifications? # => false (boolean keys get a predicate)
|
|
32
|
+
# account.flag_beta # => false (prefixed accessor)
|
|
33
|
+
# account.items_per_page_changed? # per-key dirty, computed off the column's _was
|
|
34
|
+
# account.reset_theme # drop the key so the reader falls back to the default
|
|
35
|
+
#
|
|
36
|
+
# Per key: `type:` (:string default, :integer, :float, :decimal, :boolean,
|
|
37
|
+
# :date, :datetime, :json), `default:` (a value, or a Proc instance_exec'd
|
|
38
|
+
# per read), `in:` (an enumerable membership set). The macro is repeatable —
|
|
39
|
+
# repeat calls for the SAME column merge keys; different columns are
|
|
40
|
+
# independent. `prefix:`/`suffix:` rename the generated accessors as
|
|
41
|
+
# `<prefix>_<key>_<suffix>`.
|
|
42
|
+
#
|
|
43
|
+
# Notes:
|
|
44
|
+
# * Whole-column dirty: writing one key reassigns (and so dirties) the
|
|
45
|
+
# entire column. Two requests writing different keys of the same row are
|
|
46
|
+
# last-write-wins on the whole hash — there is no per-key merge on save.
|
|
47
|
+
# * nil vs unset: a writer-stored nil (explicit JSON null) reads back as
|
|
48
|
+
# nil and does NOT fall back to the default; `reset_<key>` removes the
|
|
49
|
+
# key entirely so the reader resolves the default again.
|
|
50
|
+
# * :json values are passed through uncast and the reader returns a dup —
|
|
51
|
+
# reassign (`record.config = record.config.merge("k" => 1)`), don't
|
|
52
|
+
# mutate in place, or the write is silently lost.
|
|
53
|
+
# * Read-side casting never raises: corrupt column JSON decodes to {} and
|
|
54
|
+
# ungarbageable values cast to nil (ActiveModel semantics). :decimal is
|
|
55
|
+
# stored precision-safe as a String (BigDecimal), :date/:datetime as
|
|
56
|
+
# ISO8601 strings (datetime in UTC, microsecond precision).
|
|
57
|
+
# * Reserved option names: passing key specs as keyword arguments means a
|
|
58
|
+
# key literally named `prefix` or `suffix` would be swallowed by the
|
|
59
|
+
# affix options — declare those via the positional Hash escape hatch
|
|
60
|
+
# (`storable_by :col, { prefix: { type: :string } }`).
|
|
61
|
+
# * Reach for the store_attribute or jsonb_accessor gems when you need
|
|
62
|
+
# querying into the store, jsonb operators, or store-backed scopes.
|
|
63
|
+
module Storable
|
|
64
|
+
extend ActiveSupport::Concern
|
|
65
|
+
|
|
66
|
+
LABEL = "ConcernsOnRails::Models::Storable".freeze
|
|
67
|
+
|
|
68
|
+
VALID_TYPES = %i[string integer float decimal boolean date datetime json].freeze
|
|
69
|
+
ALLOWED_SPEC_KEYS = %i[type default in].freeze
|
|
70
|
+
|
|
71
|
+
# Reusable ActiveModel casters for the JSON-native types. :decimal,
|
|
72
|
+
# :date and :datetime round-trip through Strings and are handled
|
|
73
|
+
# explicitly; :json is passed through uncast.
|
|
74
|
+
CASTERS = {
|
|
75
|
+
string: ActiveModel::Type::String.new,
|
|
76
|
+
integer: ActiveModel::Type::Integer.new,
|
|
77
|
+
float: ActiveModel::Type::Float.new,
|
|
78
|
+
boolean: ActiveModel::Type::Boolean.new,
|
|
79
|
+
date: ActiveModel::Type::Date.new,
|
|
80
|
+
datetime: ActiveModel::Type::DateTime.new
|
|
81
|
+
}.freeze
|
|
82
|
+
|
|
83
|
+
included do
|
|
84
|
+
# { column => { key => normalized_spec } }. Subclasses inherit and may
|
|
85
|
+
# add keys; every write reassigns deep copies so a parent is never
|
|
86
|
+
# mutated by a child.
|
|
87
|
+
class_attribute :storable_keys, instance_accessor: false, default: {}
|
|
88
|
+
# { generated_method_name => [column, key] } — lets a re-declaration of
|
|
89
|
+
# the same key skip the collision guard while a different key claiming
|
|
90
|
+
# an already-taken accessor still raises.
|
|
91
|
+
class_attribute :storable_owned_methods, instance_accessor: false, default: {}
|
|
92
|
+
|
|
93
|
+
validate :storable_validate_inclusions
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
module ClassMethods
|
|
97
|
+
include ConcernsOnRails::Support::ColumnGuard
|
|
98
|
+
|
|
99
|
+
# Declare typed accessors over `column`. Key specs may arrive as the
|
|
100
|
+
# positional `keys` Hash or as trailing keyword arguments (they are
|
|
101
|
+
# merged); the positional form is the escape hatch for keys literally
|
|
102
|
+
# named `prefix`/`suffix`. See the module docs.
|
|
103
|
+
def storable_by(column, keys = {}, prefix: nil, suffix: nil, **kw_keys)
|
|
104
|
+
column = column.to_sym
|
|
105
|
+
ensure_columns!(LABEL, column)
|
|
106
|
+
|
|
107
|
+
prepared = storable_merge_key_specs(keys, kw_keys).map do |key, raw_spec|
|
|
108
|
+
key = key.to_sym
|
|
109
|
+
[key, storable_normalize_spec(key, raw_spec, prefix, suffix)]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
storable_install_keys(column, prepared)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Lazily decide — once per class/column, at first write when a DB
|
|
116
|
+
# connection exists — whether the column's attribute type stores a Hash
|
|
117
|
+
# natively (a :json column, or a host-app `serialize`d column) so we can
|
|
118
|
+
# hand it the Hash; everything else gets a generated JSON String.
|
|
119
|
+
def storable_native_hash_column?(column)
|
|
120
|
+
cache = (@storable_native_hash_cache ||= {})
|
|
121
|
+
name = column.to_sym
|
|
122
|
+
return cache[name] if cache.key?(name)
|
|
123
|
+
|
|
124
|
+
cache[name] = storable_detect_native_hash(column)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def storable_merge_key_specs(keys, kw_keys)
|
|
130
|
+
raise ArgumentError, "#{LABEL}: keys must be a Hash of name => spec (got #{keys.class})" unless keys.is_a?(Hash)
|
|
131
|
+
|
|
132
|
+
keys.merge(kw_keys)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def storable_assert_spec_shape!(key, raw_spec)
|
|
136
|
+
raise ArgumentError, "#{LABEL}: spec for ':#{key}' must be a Hash, got #{raw_spec.class}" unless raw_spec.is_a?(Hash)
|
|
137
|
+
|
|
138
|
+
unknown = raw_spec.keys.map(&:to_sym) - ALLOWED_SPEC_KEYS
|
|
139
|
+
return if unknown.empty?
|
|
140
|
+
|
|
141
|
+
raise ArgumentError,
|
|
142
|
+
"#{LABEL}: unknown option(s) #{unknown.join(', ')} in spec for ':#{key}' (allowed: #{ALLOWED_SPEC_KEYS.join(', ')})"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def storable_normalize_spec(key, raw_spec, prefix, suffix)
|
|
146
|
+
storable_assert_spec_shape!(key, raw_spec)
|
|
147
|
+
|
|
148
|
+
type = (raw_spec[:type] || :string).to_sym
|
|
149
|
+
unless VALID_TYPES.include?(type)
|
|
150
|
+
raise ArgumentError, "#{LABEL}: unknown type ':#{type}' for ':#{key}' (valid: #{VALID_TYPES.join(', ')})"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
inclusion = raw_spec[:in]
|
|
154
|
+
if !inclusion.nil? && !inclusion.respond_to?(:include?)
|
|
155
|
+
raise ArgumentError, "#{LABEL}: in: for ':#{key}' must be enumerable (respond to #include?)"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
{ type: type, default: raw_spec[:default], in: inclusion,
|
|
159
|
+
accessor: [prefix, key, suffix].compact.join("_").to_sym }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Guard collisions against a working copy of the owners map (so two keys
|
|
163
|
+
# in one call claiming the same accessor are caught too), then commit
|
|
164
|
+
# the merged config and define the accessors.
|
|
165
|
+
def storable_install_keys(column, prepared)
|
|
166
|
+
owners = storable_owned_methods.dup
|
|
167
|
+
prepared.each { |key, spec| storable_guard_collisions!(column, key, spec, owners) }
|
|
168
|
+
|
|
169
|
+
merged = storable_keys.dup
|
|
170
|
+
column_keys = (merged[column] || {}).dup
|
|
171
|
+
prepared.each { |key, spec| column_keys[key] = spec }
|
|
172
|
+
merged[column] = column_keys
|
|
173
|
+
self.storable_keys = merged
|
|
174
|
+
self.storable_owned_methods = owners
|
|
175
|
+
|
|
176
|
+
prepared.each { |key, spec| storable_define_key_methods(column, key, spec) }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def storable_guard_collisions!(column, key, spec, owners)
|
|
180
|
+
storable_method_names(spec[:accessor], spec[:type]).each do |method_name|
|
|
181
|
+
owner = owners[method_name]
|
|
182
|
+
if owner
|
|
183
|
+
next if owner == [column, key] # our own re-declaration — merge, don't collide
|
|
184
|
+
|
|
185
|
+
raise storable_collision_error(method_name)
|
|
186
|
+
end
|
|
187
|
+
raise storable_collision_error(method_name) if storable_method_taken?(method_name, spec[:accessor])
|
|
188
|
+
|
|
189
|
+
owners[method_name] = [column, key]
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# The reader is intentionally listed first so a column-attribute clash
|
|
194
|
+
# reports the bare accessor name.
|
|
195
|
+
def storable_method_names(accessor, type)
|
|
196
|
+
base = accessor.to_s
|
|
197
|
+
names = [base, "#{base}=", "#{base}_changed?", "#{base}_was", "reset_#{base}"]
|
|
198
|
+
names << "#{base}?" if type == :boolean
|
|
199
|
+
names.map(&:to_sym)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# A name is taken when it shadows a column's (lazily defined) attribute
|
|
203
|
+
# accessors or any already-defined instance method.
|
|
204
|
+
def storable_method_taken?(method_name, accessor)
|
|
205
|
+
return true if column_names.include?(accessor.to_s)
|
|
206
|
+
|
|
207
|
+
method_defined?(method_name) || private_method_defined?(method_name)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def storable_collision_error(method_name)
|
|
211
|
+
ArgumentError.new(
|
|
212
|
+
"#{LABEL}: generated method '#{method_name}' collides with an existing method or column; " \
|
|
213
|
+
"pass prefix: or suffix: to rename the accessors"
|
|
214
|
+
)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Methods look the spec up at call time (not via closure) so a later
|
|
218
|
+
# merge that changes a key's type/default takes effect without redefining.
|
|
219
|
+
def storable_define_key_methods(column, key, spec)
|
|
220
|
+
base = spec[:accessor].to_s
|
|
221
|
+
|
|
222
|
+
define_method(base) { storable_get(column, key) }
|
|
223
|
+
define_method("#{base}=") { |value| storable_set(column, key, value) }
|
|
224
|
+
define_method("#{base}_changed?") { storable_key_changed?(column, key) }
|
|
225
|
+
define_method("#{base}_was") { storable_key_was(column, key) }
|
|
226
|
+
define_method("reset_#{base}") { storable_reset(column, key) }
|
|
227
|
+
define_method("#{base}?") { storable_get(column, key) == true } if spec[:type] == :boolean
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def storable_detect_native_hash(column)
|
|
231
|
+
name = column.to_s
|
|
232
|
+
type = type_for_attribute(name)
|
|
233
|
+
return true if defined?(ActiveRecord::Type::Serialized) && type.is_a?(ActiveRecord::Type::Serialized)
|
|
234
|
+
|
|
235
|
+
col = columns_hash[name]
|
|
236
|
+
!col.nil? && col.type == :json
|
|
237
|
+
rescue StandardError
|
|
238
|
+
false
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# ---- readers / writers (the generated accessors delegate here) ----
|
|
243
|
+
|
|
244
|
+
private
|
|
245
|
+
|
|
246
|
+
def storable_get(column, key)
|
|
247
|
+
spec = storable_spec(column, key)
|
|
248
|
+
storable_resolve(spec, storable_decode(self[column]), key)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def storable_set(column, key, value)
|
|
252
|
+
spec = storable_spec(column, key)
|
|
253
|
+
hash = storable_decode(self[column]).dup
|
|
254
|
+
hash[key.to_s] = storable_cast_write(spec[:type], value)
|
|
255
|
+
storable_assign(column, hash)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Per-key dirty: decode the column's own _was value and compare the cast
|
|
259
|
+
# per-key values. After a save (dirty reset) _was equals the current value,
|
|
260
|
+
# so the key reads as unchanged.
|
|
261
|
+
def storable_key_changed?(column, key)
|
|
262
|
+
storable_key_was(column, key) != storable_get(column, key)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def storable_key_was(column, key)
|
|
266
|
+
spec = storable_spec(column, key)
|
|
267
|
+
storable_resolve(spec, storable_decode(attribute_was(column.to_s)), key)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# In-memory only (no save) — hence no bang. Removing the key lets the
|
|
271
|
+
# reader fall back to the default again.
|
|
272
|
+
def storable_reset(column, key)
|
|
273
|
+
hash = storable_decode(self[column])
|
|
274
|
+
return unless hash.key?(key.to_s)
|
|
275
|
+
|
|
276
|
+
new_hash = hash.dup
|
|
277
|
+
new_hash.delete(key.to_s)
|
|
278
|
+
storable_assign(column, new_hash)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def storable_spec(column, key)
|
|
282
|
+
self.class.storable_keys.fetch(column).fetch(key)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# absent -> default; present-but-nil -> nil (never the default); present ->
|
|
286
|
+
# cast through the declared type.
|
|
287
|
+
def storable_resolve(spec, hash, key)
|
|
288
|
+
skey = key.to_s
|
|
289
|
+
return storable_default(spec) unless hash.key?(skey)
|
|
290
|
+
|
|
291
|
+
raw = hash[skey]
|
|
292
|
+
return nil if raw.nil?
|
|
293
|
+
|
|
294
|
+
storable_cast_read(spec[:type], raw)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# A Proc default is instance_exec'd per call; a mutable Hash/Array default
|
|
298
|
+
# is deep-duped per call so one instance's mutation never leaks into another.
|
|
299
|
+
def storable_default(spec)
|
|
300
|
+
default = spec[:default]
|
|
301
|
+
return instance_exec(&default) if default.is_a?(Proc)
|
|
302
|
+
|
|
303
|
+
case default
|
|
304
|
+
when Hash, Array then default.deep_dup
|
|
305
|
+
else default
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# ---- storage codec ----
|
|
310
|
+
|
|
311
|
+
# A native-json Hash is used as-is; a String is parsed tolerantly; anything
|
|
312
|
+
# else (nil, an array, a stray scalar) decodes to {}.
|
|
313
|
+
def storable_decode(raw)
|
|
314
|
+
case raw
|
|
315
|
+
when Hash then raw
|
|
316
|
+
when String then storable_parse(raw)
|
|
317
|
+
else {}
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def storable_parse(string)
|
|
322
|
+
return {} if string.strip.empty?
|
|
323
|
+
|
|
324
|
+
parsed = JSON.parse(string)
|
|
325
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
326
|
+
rescue JSON::ParserError
|
|
327
|
+
{}
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Reassigning the whole attribute is what marks the column dirty. Hand a
|
|
331
|
+
# Hash to native/serialized columns; otherwise generate the JSON String.
|
|
332
|
+
def storable_assign(column, hash)
|
|
333
|
+
self[column] = if self.class.storable_native_hash_column?(column)
|
|
334
|
+
hash
|
|
335
|
+
else
|
|
336
|
+
JSON.generate(hash)
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# ---- casting ----
|
|
341
|
+
|
|
342
|
+
def storable_cast_read(type, raw)
|
|
343
|
+
case type
|
|
344
|
+
when :json then raw.deep_dup
|
|
345
|
+
when :decimal then storable_read_decimal(raw)
|
|
346
|
+
when :date then CASTERS[:date].cast(raw)
|
|
347
|
+
when :datetime then storable_read_time(raw)
|
|
348
|
+
else CASTERS[type].cast(raw)
|
|
349
|
+
end
|
|
350
|
+
rescue StandardError
|
|
351
|
+
# ActiveModel casting tolerates garbage already; BigDecimal()/Time.iso8601
|
|
352
|
+
# do not, so swallow and follow the "cast to nil" convention.
|
|
353
|
+
nil
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def storable_read_decimal(raw)
|
|
357
|
+
return raw if raw.is_a?(BigDecimal)
|
|
358
|
+
|
|
359
|
+
BigDecimal(raw.to_s)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def storable_read_time(raw)
|
|
363
|
+
return raw if raw.is_a?(Time)
|
|
364
|
+
|
|
365
|
+
Time.iso8601(raw.to_s)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def storable_cast_write(type, value)
|
|
369
|
+
case type
|
|
370
|
+
when :json then value
|
|
371
|
+
when :decimal then storable_write_decimal(value)
|
|
372
|
+
when :date then storable_write_date(value)
|
|
373
|
+
when :datetime then storable_write_datetime(value)
|
|
374
|
+
else CASTERS[type].cast(value)
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Precision-safe String (the Auditable precedent): BigDecimal#to_s("F").
|
|
379
|
+
def storable_write_decimal(value)
|
|
380
|
+
return nil if value.nil?
|
|
381
|
+
|
|
382
|
+
big = value.is_a?(BigDecimal) ? value : BigDecimal(value.to_s)
|
|
383
|
+
big.to_s("F")
|
|
384
|
+
rescue ArgumentError, TypeError
|
|
385
|
+
nil
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def storable_write_date(value)
|
|
389
|
+
CASTERS[:date].cast(value)&.iso8601
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# UTC iso8601(6): microsecond precision, the lesson CursorPaginatable learned.
|
|
393
|
+
def storable_write_datetime(value)
|
|
394
|
+
storable_coerce_time(value)&.utc&.iso8601(6)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# A bare Date becomes midnight UTC (deterministic — Date#to_time would
|
|
398
|
+
# anchor to the host's zone).
|
|
399
|
+
def storable_coerce_time(value)
|
|
400
|
+
case value
|
|
401
|
+
when nil then nil
|
|
402
|
+
when ActiveSupport::TimeWithZone, Time then value
|
|
403
|
+
when DateTime then value.to_time
|
|
404
|
+
when Date then Time.utc(value.year, value.month, value.day)
|
|
405
|
+
else CASTERS[:datetime].cast(value)
|
|
406
|
+
end
|
|
407
|
+
rescue ArgumentError, TypeError
|
|
408
|
+
nil
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# ---- validation ----
|
|
412
|
+
|
|
413
|
+
# One pass adds an inclusion error per `in:`-constrained key whose stored
|
|
414
|
+
# value is present and non-nil but casts outside the allowed set. Absent
|
|
415
|
+
# and nil values pass (compose with a presence validator if you need them).
|
|
416
|
+
def storable_validate_inclusions
|
|
417
|
+
self.class.storable_keys.each do |column, keys|
|
|
418
|
+
decoded = storable_decode(self[column])
|
|
419
|
+
keys.each do |key, spec|
|
|
420
|
+
allowed = spec[:in]
|
|
421
|
+
next unless allowed
|
|
422
|
+
|
|
423
|
+
skey = key.to_s
|
|
424
|
+
next unless decoded.key?(skey)
|
|
425
|
+
|
|
426
|
+
raw = decoded[skey]
|
|
427
|
+
next if raw.nil?
|
|
428
|
+
|
|
429
|
+
value = storable_cast_read(spec[:type], raw)
|
|
430
|
+
errors.add(spec[:accessor], "is not included in the list") unless allowed.include?(value)
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
end
|
data/lib/concerns_on_rails.rb
CHANGED
|
@@ -50,6 +50,7 @@ require "concerns_on_rails/models/monetizable"
|
|
|
50
50
|
require "concerns_on_rails/models/auditable"
|
|
51
51
|
require "concerns_on_rails/models/lockable"
|
|
52
52
|
require "concerns_on_rails/models/aliasable"
|
|
53
|
+
require "concerns_on_rails/models/storable"
|
|
53
54
|
|
|
54
55
|
# Controller concerns
|
|
55
56
|
require "concerns_on_rails/controllers/paginatable"
|
|
@@ -66,6 +67,7 @@ require "concerns_on_rails/controllers/timezoneable"
|
|
|
66
67
|
require "concerns_on_rails/controllers/idempotentable"
|
|
67
68
|
require "concerns_on_rails/controllers/webhook_verifiable"
|
|
68
69
|
require "concerns_on_rails/controllers/cursor_paginatable"
|
|
70
|
+
require "concerns_on_rails/controllers/deprecatable"
|
|
69
71
|
|
|
70
72
|
# Backwards compatibility (top-level aliases for pre-1.6 module paths)
|
|
71
73
|
require "concerns_on_rails/legacy_aliases"
|
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.
|
|
4
|
+
version: 1.19.0
|
|
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-06-
|
|
11
|
+
date: 2026-06-13 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -72,6 +72,7 @@ files:
|
|
|
72
72
|
- lib/concerns_on_rails.rb
|
|
73
73
|
- lib/concerns_on_rails/controllers/authorizable.rb
|
|
74
74
|
- lib/concerns_on_rails/controllers/cursor_paginatable.rb
|
|
75
|
+
- lib/concerns_on_rails/controllers/deprecatable.rb
|
|
75
76
|
- lib/concerns_on_rails/controllers/error_handleable.rb
|
|
76
77
|
- lib/concerns_on_rails/controllers/filterable.rb
|
|
77
78
|
- lib/concerns_on_rails/controllers/idempotentable.rb
|
|
@@ -104,6 +105,7 @@ files:
|
|
|
104
105
|
- lib/concerns_on_rails/models/soft_deletable.rb
|
|
105
106
|
- lib/concerns_on_rails/models/sortable.rb
|
|
106
107
|
- lib/concerns_on_rails/models/stateable.rb
|
|
108
|
+
- lib/concerns_on_rails/models/storable.rb
|
|
107
109
|
- lib/concerns_on_rails/models/taggable.rb
|
|
108
110
|
- lib/concerns_on_rails/models/tokenizable.rb
|
|
109
111
|
- lib/concerns_on_rails/support/address_data.rb
|