concerns_on_rails 1.17.0 → 1.18.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 -1
- data/lib/concerns_on_rails/controllers/cursor_paginatable.rb +501 -0
- data/lib/concerns_on_rails/legacy_aliases.rb +1 -0
- data/lib/concerns_on_rails/models/aliasable.rb +405 -0
- data/lib/concerns_on_rails/version.rb +1 -1
- data/lib/concerns_on_rails.rb +14 -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: 30c62bfbc26afd05fe7ed2e991add6fa180f3e71316ec638a045932e89bf2ce1
|
|
4
|
+
data.tar.gz: af936258d441cf0206e120853f0a227967f663e577983628087defe2d60d7202
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 359c8240a7d15da31c2a3236a69cb75b1cce57dc5fb80869214c64c05b3b7051d2a17ee17e73d5e9aec3321a53ca8a5de7ada2e73fa80f1f4da50e41fbfc6398
|
|
7
|
+
data.tar.gz: 6b7388a71ceed19ee2517786f5bffdde6acb2e13506ce4ee00b983ff46293a9479aa91d01e2f6912e50a7eb1944f644534369215274fa2234fd889315e32e6f1
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 1.18.0 (2026-06-12)
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Models::Aliasable**: full association aliasing — `alias_association :writer, :author` (argument order mirrors `alias_method new, old`) gives read, write/assign, `build_`/`create_`/`create_!`/`reload_`/`reset_`, the `_ids` pair, and the query side (`joins`/`includes`/`preload`/`eager_load`/`where`-hash/`reflect_on_association`). One loaded cache under two names (`record.association(:alias)` IS the source's proxy) and only the source macro installs callbacks, so `dependent:`, counter caches, autosave and validations run exactly once. The query side registers a *renamed reflection copy* (registering the same object emits mismatched JOIN/WHERE table names — invalid SQL), with `class_name`/`foreign_key`/`foreign_type` (direct associations) or `source:` (`has_many`/`has_one :through` — resolved exactly when the through class is already loaded, anchored to the source association's own name when it is not) pinned so nothing re-derives from the alias name; the `_reflections` key form (String on <= 7.x, Symbol on newer) is probed at runtime. Aliases are inherited; re-declaring one with the same source in a subclass after redefining that source refreshes the reflection (descendant caches cleared), while repointing an existing alias at a different source raises. Collision validation sweeps every generated method name against associations, methods, columns, and virtual attributes — skipped gracefully when no database is reachable at load time. Aliases of aliases collapse to the terminal source; `has_and_belongs_to_many` is rejected (use `has_many :through`); the `belongs_to` FK attribute is not aliased by default (`alias_foreign_key: true` aliases the `<alias>_id`/`<alias>_type` pair via `alias_attribute`, collision-checked). Options: `only:`/`except:` narrow the generated method map by group (`:reader`/`:writer`/`:build`/`:reload`/`:ids`; narrowing re-declares prune their stale delegators); `deprecated:` (true or a String hint) warns through the new `ConcernsOnRails.deprecator` on every delegator call — the gradual-rename story. Zero new runtime dependencies.
|
|
9
|
+
- **Controllers::CursorPaginatable**: cursor/keyset pagination — the no-COUNT, concurrent-insert-stable complement to Paginatable. `cursor_paginate_by order: { created_at: :desc }, per_page: 25, max_per_page: 200` (+ per-call `order:`/`per_page:`); `cursor_paginated(scope)` returns the page (loaded Array, limit+1 has-more detection) and sets `X-Per-Page`, `X-Count` (this page — totals deliberately never computed), `X-Has-More`, `X-Next-Cursor`; `cursor_pagination_meta` memoizes for Respondable `meta:` composition. The primary key is always appended as a strict tiebreaker (duplicate values never skip/repeat rows; proven by a ties-walk spec) and the keyset WHERE is Arel OR-expansion, portable across adapters and mixed asc/desc. Cursors are opaque URL-safe Base64 tokens pinned to the table + column:direction list — malformed, tampered (non-scalar values, wrong arity), cross-model, or stale-config cursors raise `InvalidCursor`, auto-rescued to a 400 (`invalid_cursor`, `render_invalid_cursor` override point, delegates to Respondable) on any Rescuable controller. Boundary timestamps serialize at microsecond precision (`iso8601(6)`) and cast back through the model's attribute types so each adapter quotes natively. Ordering columns are chosen in code only, validated against the schema; `reorder` defeats `default_scope` ordering; composite/PK-less tables raise `ArgumentError`, as does a NULL ordering value on a page-boundary row (which would otherwise silently drop rows — SQL three-valued logic). Opt-in extras: `bidirectional: true` mints `X-Prev-Cursor`/`X-Has-Prev` (backward fetches walk the inverted ordering and flip back to canonical order; direction is pinned in the token, so prev tokens replayed at forward-only endpoints 400 and pre-bidirectional tokens stay valid forward cursors); `order_presets:`/`default_preset:`/`order_param:` give clients allow-listed named orderings (unknown names → 400 `invalid_order_preset`; switching presets mid-walk invalidates the cursor); `predicate: :auto` upgrades the keyset WHERE to a composite-index-friendly row-value tuple `(a, b, id) > (x, y, z)` on PostgreSQL/MySQL/SQLite under uniform directions, falling back to the portable OR-expansion (`:row` forces tuples and raises on mixed directions; `:or` forces the expansion). Zero new runtime dependencies (URL-safe Base64 via `pack("m0")`, as in WebhookVerifiable).
|
|
10
|
+
|
|
3
11
|
## 1.17.0 (2026-06-10)
|
|
4
12
|
|
|
5
13
|
Two new concerns, hardened by two adversarial review rounds (in-memory state is restored when a raising lock/unlock hook rolls the write back, so retries can't silently no-op; a hook aborting via `ActiveRecord::Rollback` makes `lock_access!`/`unlock_access!` return false instead of a fake success; invalid-UTF-8 signature headers fail closed instead of raising). 679 examples, 0 failures.
|
data/README.md
CHANGED
|
@@ -45,8 +45,10 @@ Article.published.without_deleted.find("hello-world")
|
|
|
45
45
|
- [Monetizable](#-monetizable) — integer-cents money columns (BigDecimal)
|
|
46
46
|
- [Auditable](#-auditable) — single-column change history ("paper_trail-lite")
|
|
47
47
|
- [Lockable](#-lockable) — failed-attempt tracking + account lockout
|
|
48
|
+
- [Aliasable](#-aliasable) — full read/write/query aliases for associations
|
|
48
49
|
- **Controller concerns**
|
|
49
50
|
- [Paginatable](#-paginatable) — offset pagination with headers
|
|
51
|
+
- [CursorPaginatable](#-cursorpaginatable) — cursor (keyset) pagination with headers
|
|
50
52
|
- [Filterable](#-filterable) — declarative URL-param filters
|
|
51
53
|
- [Sortable (controller)](#-sortable-controller) — URL-param ordering with allow-list
|
|
52
54
|
- [Respondable](#-respondable) — standardized JSON envelopes
|
|
@@ -68,7 +70,7 @@ Article.published.without_deleted.find("hello-world")
|
|
|
68
70
|
|
|
69
71
|
## ✨ Why this gem?
|
|
70
72
|
|
|
71
|
-
- **Twenty model concerns +
|
|
73
|
+
- **Twenty-one model concerns + fourteen controller concerns**, all production-ready
|
|
72
74
|
- **One include, one macro** — no boilerplate, no glue code
|
|
73
75
|
- **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
|
|
74
76
|
- **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
|
|
@@ -1004,6 +1006,39 @@ User.locked / User.unlocked # expiry-aware scopes
|
|
|
1004
1006
|
|
|
1005
1007
|
---
|
|
1006
1008
|
|
|
1009
|
+
## 🪞 Aliasable
|
|
1010
|
+
|
|
1011
|
+
Alias an existing association under a second name with **full** semantics — read, write/assign, build/create, and the query side (`joins` / `includes` / `where`-hash) — not just a delegated reader. (`alias_attribute` covers columns only; Rails has no built-in association aliasing.)
|
|
1012
|
+
|
|
1013
|
+
```ruby
|
|
1014
|
+
class Book < ApplicationRecord
|
|
1015
|
+
include ConcernsOnRails::Aliasable
|
|
1016
|
+
|
|
1017
|
+
belongs_to :author
|
|
1018
|
+
has_many :chapters
|
|
1019
|
+
|
|
1020
|
+
alias_association :writer, :author # alias_method order: new, old
|
|
1021
|
+
alias_association :sections, :chapters
|
|
1022
|
+
end
|
|
1023
|
+
|
|
1024
|
+
book.writer # same cached object as book.author
|
|
1025
|
+
book.writer = user # assigns through the original association
|
|
1026
|
+
book.build_writer(...) # build_ / create_ / create_! / reload_ (singular)
|
|
1027
|
+
book.sections << chapter # the same CollectionProxy as book.chapters
|
|
1028
|
+
book.section_ids # ids reader/writer (collection)
|
|
1029
|
+
Book.joins(:sections).where(sections: { title: "Intro" })
|
|
1030
|
+
```
|
|
1031
|
+
|
|
1032
|
+
**Options**: `alias_association new_name, source_name` — repeatable; declare it **after** the source association; re-declaring an existing alias with the **same** source (e.g. in a subclass that redefined the source) is allowed and refreshes it, while repointing an alias at a *different* source raises. Keyword options: `only:`/`except:` narrow the generated methods by group (`:reader`, `:writer`, `:build`, `:reload`, `:ids`); `deprecated: true` (or a String hint) makes every delegator warn through `ConcernsOnRails.deprecator` — the gradual-rename story; `alias_foreign_key: true` (`belongs_to` only) also aliases `<alias>_id` (and `<alias>_type` when polymorphic) via `alias_attribute`.
|
|
1033
|
+
|
|
1034
|
+
**Notes**
|
|
1035
|
+
- One loaded cache under two names: `record.association(:alias)` IS `record.association(:source)`, and only the source macro installs callbacks — `dependent:`, counter caches, autosave and validations run exactly once.
|
|
1036
|
+
- The where-hash key must match the name you joined under (stock-Rails rule): `joins(:sections).where(sections: {...})` works; `joins(:chapters).where(sections: {...})` does not.
|
|
1037
|
+
- The `belongs_to` foreign-key **attribute** is not aliased — pair with `alias_attribute :writer_id, :author_id` if you need it.
|
|
1038
|
+
- `has_and_belongs_to_many` cannot be aliased (use `has_many :through`). `has_many`/`has_one :through` **can** — the copy pins `source:` so it is not re-derived from the alias name; if your classes load lazily and the through model names the source differently (e.g. `belongs_to :author` behind `has_many :authors`), declare `source:` explicitly on the original association. Aliases are inherited by subclasses.
|
|
1039
|
+
|
|
1040
|
+
---
|
|
1041
|
+
|
|
1007
1042
|
# 🎮 Controller Concerns
|
|
1008
1043
|
|
|
1009
1044
|
Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
|
|
@@ -1035,6 +1070,41 @@ end
|
|
|
1035
1070
|
|
|
1036
1071
|
---
|
|
1037
1072
|
|
|
1073
|
+
## 🧭 CursorPaginatable
|
|
1074
|
+
|
|
1075
|
+
Cursor (keyset) pagination — the constant-time complement to Paginatable: **no COUNT query**, stable under concurrent inserts, ideal for infinite scroll and sync feeds.
|
|
1076
|
+
|
|
1077
|
+
```ruby
|
|
1078
|
+
class ArticlesController < ApplicationController
|
|
1079
|
+
include ConcernsOnRails::Controllers::CursorPaginatable
|
|
1080
|
+
|
|
1081
|
+
cursor_paginate_by order: { created_at: :desc }, per_page: 25, max_per_page: 200
|
|
1082
|
+
|
|
1083
|
+
def index
|
|
1084
|
+
render json: cursor_paginated(Article.all) # bad cursors are rescued to a 400 automatically
|
|
1085
|
+
end
|
|
1086
|
+
end
|
|
1087
|
+
```
|
|
1088
|
+
|
|
1089
|
+
**URL params**
|
|
1090
|
+
|
|
1091
|
+
| Param | Default | Notes |
|
|
1092
|
+
|--------------|---------|----------------------------------------------------------|
|
|
1093
|
+
| `?cursor=` | — | The opaque token from `X-Next-Cursor` (omit for page 1) |
|
|
1094
|
+
| `?per_page=` | `25` | Capped at `max_per_page` (default 200; `0` disables the cap) |
|
|
1095
|
+
| `?order=` | first preset | With `order_presets:` only — selects a named ordering from the allow-list (unknown names → 400 `invalid_order_preset`) |
|
|
1096
|
+
|
|
1097
|
+
**Response headers**: `X-Per-Page`, `X-Count` (rows on **this** page — totals are deliberately not computed), `X-Has-More`, `X-Next-Cursor` (only while more pages exist). With `bidirectional: true`: also `X-Has-Prev`, `X-Prev-Cursor`.
|
|
1098
|
+
|
|
1099
|
+
**Notes**
|
|
1100
|
+
- The primary key is always appended as a tiebreaker, so duplicate values never skip or repeat rows; ordering columns are chosen **in code** (never from params) and should be `NOT NULL` (a NULL boundary value raises rather than silently dropping rows).
|
|
1101
|
+
- Cursors are opaque, table/order-pinned tokens — a malformed, cross-endpoint, or stale-config cursor renders a 400 (`invalid_cursor`; override `render_invalid_cursor` to customize, delegates to Respondable's `render_error` when present). They are **not signed**: a client can mint different boundary values, but values are cast through the model's attribute types and bound by Arel (no injection) and the relation's own scoping still applies — treat a cursor as a page position, never an authorization boundary.
|
|
1102
|
+
- `cursor_paginated` uses `reorder` (replaces any `default_scope` ORDER BY) and returns a loaded Array. Don't wrap it with the controller Sortable's `sorted` — pass `order:` per call instead.
|
|
1103
|
+
- Forward-only by default — `bidirectional: true` (macro or per call) adds prev cursors and `X-Has-Prev`/`X-Prev-Cursor`; direction is pinned in the token, so prev tokens replayed on forward-only endpoints 400 and old direction-less tokens stay valid. `order_presets: { newest: {...}, top: {...} }` (+ `default_preset:`, `order_param:`) lets clients pick a **named** ordering from an allow-list. `predicate: :auto` upgrades the keyset WHERE to a row-value tuple `(a, b, id) > (x, y, z)` on PostgreSQL/MySQL/SQLite when directions are uniform — composite-index friendly — falling back to the portable OR-expansion (`:row`/`:or` force a strategy).
|
|
1104
|
+
- Use Paginatable when you need page numbers and totals.
|
|
1105
|
+
|
|
1106
|
+
---
|
|
1107
|
+
|
|
1038
1108
|
## 🔎 Filterable
|
|
1039
1109
|
|
|
1040
1110
|
Declarative URL-param filtering with three modes per filter.
|
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
require "json"
|
|
3
|
+
require "time" # Time#iso8601(fraction_digits) lives in the stdlib time library
|
|
4
|
+
|
|
5
|
+
module ConcernsOnRails
|
|
6
|
+
module Controllers
|
|
7
|
+
# Cursor (keyset) pagination for API controllers — the constant-time
|
|
8
|
+
# complement to Paginatable's offset pagination. No COUNT query, stable
|
|
9
|
+
# under concurrent inserts, suitable for infinite scroll and sync feeds.
|
|
10
|
+
#
|
|
11
|
+
# class ArticlesController < ApplicationController
|
|
12
|
+
# include ConcernsOnRails::Controllers::CursorPaginatable
|
|
13
|
+
# cursor_paginate_by order: { created_at: :desc }, per_page: 25, max_per_page: 200
|
|
14
|
+
#
|
|
15
|
+
# def index
|
|
16
|
+
# render json: cursor_paginated(Article.all)
|
|
17
|
+
# end
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# Reads params[:cursor] (the opaque token from X-Next-Cursor) and
|
|
21
|
+
# params[:per_page]. The primary key is always appended as a tiebreaker.
|
|
22
|
+
# Ordering columns are chosen in code (never from params), must live on the
|
|
23
|
+
# base model's table, be selected by the relation, and should be NOT NULL.
|
|
24
|
+
#
|
|
25
|
+
# Optional capabilities (all opt-in, defaults unchanged):
|
|
26
|
+
# * bidirectional: true — mints X-Prev-Cursor / X-Has-Prev alongside the
|
|
27
|
+
# next cursor, so clients can page back without keeping old tokens.
|
|
28
|
+
# * order_presets: { newest: { created_at: :desc }, top: { score: :desc } }
|
|
29
|
+
# — allow-listed, client-selectable named orderings (?order= picks a
|
|
30
|
+
# preset NAME; columns stay code-chosen). Unknown names raise
|
|
31
|
+
# InvalidOrderPreset (auto-rescued to a 400).
|
|
32
|
+
# * predicate: :auto (default) — on adapters with row-value support
|
|
33
|
+
# (PostgreSQL/MySQL/SQLite) and uniform directions, the keyset WHERE is
|
|
34
|
+
# a tuple comparison `(a, b, id) > (x, y, z)` that walks a composite
|
|
35
|
+
# index directly; everything else falls back to the OR-expansion.
|
|
36
|
+
#
|
|
37
|
+
# Malformed or mismatched cursors raise CursorPaginatable::InvalidCursor;
|
|
38
|
+
# on real controllers a rescue_from is registered automatically and renders
|
|
39
|
+
# a 400 (via Respondable's render_error when included). Override
|
|
40
|
+
# #render_invalid_cursor to customize the body. Cursors are opaque but NOT
|
|
41
|
+
# signed — boundary values are cast through the model's attribute types
|
|
42
|
+
# and bound by Arel (no injection) and the relation's scoping still
|
|
43
|
+
# applies, so treat a cursor as a page position, never an authorization
|
|
44
|
+
# boundary.
|
|
45
|
+
#
|
|
46
|
+
# Do not combine with Controllers::Sortable#sorted — cursor_paginated uses
|
|
47
|
+
# reorder, which replaces any prior ORDER BY (including Models::Sortable's
|
|
48
|
+
# default_scope). Pass `order:` per call instead.
|
|
49
|
+
module CursorPaginatable
|
|
50
|
+
extend ActiveSupport::Concern
|
|
51
|
+
|
|
52
|
+
DEFAULT_PER_PAGE = 25
|
|
53
|
+
DEFAULT_MAX_PER_PAGE = 200
|
|
54
|
+
VALID_DIRECTIONS = %i[asc desc].freeze
|
|
55
|
+
VALID_PREDICATES = %i[auto row or].freeze
|
|
56
|
+
CURSOR_DIRECTIONS = %w[next prev].freeze
|
|
57
|
+
# Adapters whose SQL supports row-value (tuple) comparison: (a, b) > (x, y).
|
|
58
|
+
ROW_PREDICATE_ADAPTERS = /postgres|mysql|trilogy|sqlite/i
|
|
59
|
+
|
|
60
|
+
# Raised when params[:cursor] is malformed, tampered with, or was minted
|
|
61
|
+
# under a different table/order configuration. Auto-rescued to a 400 when
|
|
62
|
+
# the including class supports rescue_from (real controllers do).
|
|
63
|
+
class InvalidCursor < StandardError; end
|
|
64
|
+
|
|
65
|
+
# Raised when params[?order=] (or the configured order_param) names no
|
|
66
|
+
# configured preset. Auto-rescued to a 400 like InvalidCursor.
|
|
67
|
+
class InvalidOrderPreset < StandardError; end
|
|
68
|
+
|
|
69
|
+
# Normalizes Symbol / Array-of-Symbols / Hash order declarations to
|
|
70
|
+
# [[column, direction], ...]. Raises ArgumentError on anything else.
|
|
71
|
+
def self.normalize_order!(order)
|
|
72
|
+
pairs =
|
|
73
|
+
case order
|
|
74
|
+
when Hash then order.map { |col, dir| [col.to_sym, dir.to_sym] }
|
|
75
|
+
when Array then order.map { |col| [normalize_order_column!(col), :asc] }
|
|
76
|
+
else [[normalize_order_column!(order), :asc]]
|
|
77
|
+
end
|
|
78
|
+
raise ArgumentError, "#{name}: at least one order column is required" if pairs.empty?
|
|
79
|
+
|
|
80
|
+
validate_order_directions!(pairs)
|
|
81
|
+
pairs
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.validate_order_directions!(pairs)
|
|
85
|
+
pairs.each do |col, dir|
|
|
86
|
+
next if VALID_DIRECTIONS.include?(dir)
|
|
87
|
+
|
|
88
|
+
raise ArgumentError, "#{name}: direction for '#{col}' must be :asc or :desc"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.normalize_order_column!(col)
|
|
93
|
+
return col.to_sym if col.is_a?(Symbol) || col.is_a?(String)
|
|
94
|
+
|
|
95
|
+
raise ArgumentError, "#{name}: order entries must be column names (Symbol/String); " \
|
|
96
|
+
"use a Hash like { column: :desc } to set directions"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Macro-time validation/normalization for order_presets / default_preset
|
|
100
|
+
# / predicate (module functions so class_methods stays thin).
|
|
101
|
+
def self.normalize_presets!(presets)
|
|
102
|
+
raise ArgumentError, "#{name}: order_presets must be a non-empty Hash" unless presets.is_a?(Hash) && presets.any?
|
|
103
|
+
|
|
104
|
+
presets.to_h { |key, order| [key.to_sym, normalize_order!(order)] }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.resolve_default_preset!(presets, default_preset, order)
|
|
108
|
+
validate_order_sources!(presets, default_preset, order)
|
|
109
|
+
return nil unless presets
|
|
110
|
+
|
|
111
|
+
key = (default_preset || presets.keys.first).to_sym
|
|
112
|
+
raise ArgumentError, "#{name}: default_preset '#{key}' is not one of the order_presets" unless presets.key?(key)
|
|
113
|
+
|
|
114
|
+
key
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def self.validate_order_sources!(presets, default_preset, order)
|
|
118
|
+
raise ArgumentError, "#{name}: pass order: or order_presets:, not both" if order && presets
|
|
119
|
+
raise ArgumentError, "#{name}: order: or order_presets: is required" if order.nil? && presets.nil?
|
|
120
|
+
raise ArgumentError, "#{name}: default_preset: requires order_presets:" if default_preset && presets.nil?
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def self.validate_predicate!(predicate)
|
|
124
|
+
predicate = predicate.to_sym
|
|
125
|
+
return predicate if VALID_PREDICATES.include?(predicate)
|
|
126
|
+
|
|
127
|
+
raise ArgumentError, "#{name}: predicate: must be one of #{VALID_PREDICATES.join(', ')}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
included do
|
|
131
|
+
class_attribute :cursor_paginatable_order, default: nil
|
|
132
|
+
class_attribute :cursor_paginatable_order_presets, default: nil
|
|
133
|
+
class_attribute :cursor_paginatable_default_preset, default: nil
|
|
134
|
+
class_attribute :cursor_paginatable_order_param, default: :order
|
|
135
|
+
class_attribute :cursor_paginatable_per_page, default: DEFAULT_PER_PAGE
|
|
136
|
+
class_attribute :cursor_paginatable_max_per_page, default: DEFAULT_MAX_PER_PAGE
|
|
137
|
+
class_attribute :cursor_paginatable_bidirectional, default: false
|
|
138
|
+
class_attribute :cursor_paginatable_predicate, default: :auto
|
|
139
|
+
|
|
140
|
+
# Real controllers (anything with ActiveSupport::Rescuable) get the 400
|
|
141
|
+
# handlers automatically; bare objects let the errors propagate.
|
|
142
|
+
if respond_to?(:rescue_from)
|
|
143
|
+
rescue_from InvalidCursor, with: :render_invalid_cursor
|
|
144
|
+
rescue_from InvalidOrderPreset, with: :render_invalid_order_preset
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
class_methods do
|
|
149
|
+
# Configure the keyset ordering and page-size defaults. Exactly one of
|
|
150
|
+
# order: (a fixed ordering) or order_presets: (named orderings the
|
|
151
|
+
# client picks via ?<order_param>=) is required.
|
|
152
|
+
# Examples:
|
|
153
|
+
# cursor_paginate_by order: { created_at: :desc }, per_page: 50, max_per_page: 500
|
|
154
|
+
# cursor_paginate_by order_presets: { newest: { created_at: :desc }, top: { score: :desc } },
|
|
155
|
+
# default_preset: :newest, bidirectional: true
|
|
156
|
+
# max_per_page: 0 (or negative) disables the per_page cap.
|
|
157
|
+
def cursor_paginate_by(order: nil, order_presets: nil, default_preset: nil, order_param: :order,
|
|
158
|
+
per_page: DEFAULT_PER_PAGE, max_per_page: DEFAULT_MAX_PER_PAGE,
|
|
159
|
+
bidirectional: false, predicate: :auto)
|
|
160
|
+
self.cursor_paginatable_order = order && CursorPaginatable.normalize_order!(order)
|
|
161
|
+
self.cursor_paginatable_order_presets = order_presets && CursorPaginatable.normalize_presets!(order_presets)
|
|
162
|
+
self.cursor_paginatable_default_preset =
|
|
163
|
+
CursorPaginatable.resolve_default_preset!(cursor_paginatable_order_presets, default_preset, order)
|
|
164
|
+
self.cursor_paginatable_order_param = order_param.to_sym
|
|
165
|
+
self.cursor_paginatable_per_page = per_page.to_i
|
|
166
|
+
self.cursor_paginatable_max_per_page = max_per_page.to_i
|
|
167
|
+
self.cursor_paginatable_bidirectional = bidirectional ? true : false
|
|
168
|
+
self.cursor_paginatable_predicate = CursorPaginatable.validate_predicate!(predicate)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Run the keyset query (limit + 1 to detect has_more), set the standard
|
|
173
|
+
# response headers, and return the page as a loaded Array (laziness is
|
|
174
|
+
# impossible here: has_more detection materializes limit + 1 rows).
|
|
175
|
+
# Raises InvalidCursor (rescued to a 400 on real controllers) on bad
|
|
176
|
+
# cursors.
|
|
177
|
+
def cursor_paginated(relation, order: nil, per_page: nil, bidirectional: nil)
|
|
178
|
+
@cursor_pagination_meta = nil # never expose a previous call's meta after a failure
|
|
179
|
+
result = cursor_paginate_result(relation, order: order, per_page: per_page, bidirectional: bidirectional)
|
|
180
|
+
@cursor_pagination_meta = result[:meta]
|
|
181
|
+
apply_cursor_pagination_headers(result[:meta])
|
|
182
|
+
result[:records]
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# With no arguments: the meta Hash memoized by the last cursor_paginated
|
|
186
|
+
# call (no extra query; nil if that call failed or never ran). With a
|
|
187
|
+
# relation: runs the query and returns meta WITHOUT setting headers or
|
|
188
|
+
# touching the memo — for body-based pagination (Respondable's meta:).
|
|
189
|
+
def cursor_pagination_meta(relation = nil, order: nil, per_page: nil, bidirectional: nil)
|
|
190
|
+
return @cursor_pagination_meta if relation.nil?
|
|
191
|
+
|
|
192
|
+
cursor_paginate_result(relation, order: order, per_page: per_page, bidirectional: bidirectional)[:meta]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Public override point (mirrors ErrorHandleable's public handlers):
|
|
196
|
+
# delegates to Respondable#render_error when available.
|
|
197
|
+
def render_invalid_cursor(error)
|
|
198
|
+
return render_error(message: error.message, status: :bad_request, code: "invalid_cursor") if respond_to?(:render_error)
|
|
199
|
+
|
|
200
|
+
render json: { success: false, error: { message: error.message, code: "invalid_cursor" } }, status: :bad_request
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Same override contract for unknown ?order= preset names.
|
|
204
|
+
def render_invalid_order_preset(error)
|
|
205
|
+
return render_error(message: error.message, status: :bad_request, code: "invalid_order_preset") if respond_to?(:render_error)
|
|
206
|
+
|
|
207
|
+
render json: { success: false, error: { message: error.message, code: "invalid_order_preset" } }, status: :bad_request
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
private
|
|
211
|
+
|
|
212
|
+
def cursor_paginate_result(relation, order:, per_page:, bidirectional: nil)
|
|
213
|
+
relation = relation.all if relation.is_a?(Class)
|
|
214
|
+
pairs = cursor_order_pairs(relation, order)
|
|
215
|
+
limit = cursor_per_page(per_page)
|
|
216
|
+
bidi = bidirectional.nil? ? self.class.cursor_paginatable_bidirectional : bidirectional
|
|
217
|
+
cursor = decode_cursor(params[:cursor], pairs, relation.model, bidirectional: bidi)
|
|
218
|
+
fetch = cursor_fetch_page(relation, pairs, limit, cursor)
|
|
219
|
+
{ records: fetch[:page],
|
|
220
|
+
meta: cursor_build_meta(relation.model, pairs, limit, fetch, bidi) }
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# A backward ("prev") cursor walks the INVERTED ordering so LIMIT grabs
|
|
224
|
+
# the rows nearest the boundary, then flips the page back to canonical
|
|
225
|
+
# order. reorder (not order) so the keyset columns REPLACE any prior
|
|
226
|
+
# ORDER BY — including a Models::Sortable default_scope order.
|
|
227
|
+
def cursor_fetch_page(relation, pairs, limit, cursor)
|
|
228
|
+
backward = cursor ? cursor[:backward] : false
|
|
229
|
+
effective = backward ? cursor_invert_pairs(pairs) : pairs
|
|
230
|
+
scoped = relation.reorder(effective.to_h)
|
|
231
|
+
scoped = scoped.where(cursor_predicate(relation.model, effective, cursor[:values])) if cursor
|
|
232
|
+
rows = scoped.limit(limit + 1).to_a
|
|
233
|
+
|
|
234
|
+
page = rows.first(limit)
|
|
235
|
+
page.reverse! if backward
|
|
236
|
+
{ page: page, extra: rows.size > limit, backward: backward, arrived: !cursor.nil? }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def cursor_invert_pairs(pairs)
|
|
240
|
+
pairs.map { |col, dir| [col, dir == :asc ? :desc : :asc] }
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# The limit+1 probe detects "more" in the direction of travel; the
|
|
244
|
+
# other side is implied by the cursor we arrived on. Cursors are only
|
|
245
|
+
# minted from a non-empty page — an over-walked empty page returns no
|
|
246
|
+
# cursors and both flags false (clients keep their previous tokens).
|
|
247
|
+
# prev_cursor/has_prev appear in the meta only in bidirectional mode,
|
|
248
|
+
# so the forward-only meta shape is unchanged.
|
|
249
|
+
def cursor_build_meta(model, pairs, limit, fetch, bidi)
|
|
250
|
+
page = fetch[:page]
|
|
251
|
+
has_next = page.any? && (fetch[:backward] || fetch[:extra])
|
|
252
|
+
meta = { per_page: limit, count: page.size, has_more: has_next,
|
|
253
|
+
next_cursor: has_next ? encode_cursor(model, pairs, page.last, "next") : nil }
|
|
254
|
+
meta.merge!(cursor_prev_meta(model, pairs, fetch)) if bidi
|
|
255
|
+
meta
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def cursor_prev_meta(model, pairs, fetch)
|
|
259
|
+
page = fetch[:page]
|
|
260
|
+
has_prev = page.any? && (fetch[:backward] ? fetch[:extra] : fetch[:arrived])
|
|
261
|
+
{ has_prev: has_prev,
|
|
262
|
+
prev_cursor: has_prev ? encode_cursor(model, pairs, page.first, "prev") : nil }
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Resolved [[column, direction], ...] with the primary key appended as a
|
|
266
|
+
# tiebreaker (inheriting the last column's direction) when not declared.
|
|
267
|
+
def cursor_order_pairs(relation, override)
|
|
268
|
+
pairs = override ? CursorPaginatable.normalize_order!(override) : cursor_configured_pairs
|
|
269
|
+
pairs = (pairs || []).dup
|
|
270
|
+
pk = relation.model.primary_key
|
|
271
|
+
unless pk.is_a?(String) # Array under composite PKs, nil for PK-less tables
|
|
272
|
+
raise ArgumentError,
|
|
273
|
+
"#{CursorPaginatable.name}: #{relation.model.table_name} needs a single-column primary key"
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
pk = pk.to_sym
|
|
277
|
+
pairs << [pk, pairs.empty? ? :asc : pairs.last.last] unless pairs.any? { |col, _| col == pk }
|
|
278
|
+
validate_cursor_columns!(relation.model, pairs)
|
|
279
|
+
pairs
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def validate_cursor_columns!(model, pairs)
|
|
283
|
+
pairs.map(&:first).each do |col|
|
|
284
|
+
next if model.column_names.include?(col.to_s)
|
|
285
|
+
|
|
286
|
+
raise ArgumentError,
|
|
287
|
+
"#{CursorPaginatable.name}: '#{col}' does not exist in the database (table: #{model.table_name})"
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def cursor_configured_pairs
|
|
292
|
+
self.class.cursor_paginatable_order || cursor_preset_order_pairs
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Resolves params[<order_param>] against the allow-listed presets. The
|
|
296
|
+
# param only ever picks a preset NAME — columns stay code-chosen. An
|
|
297
|
+
# unknown name raises (→ 400) rather than silently falling back: a
|
|
298
|
+
# typo'd ?order= must not quietly reorder the walk. Switching presets
|
|
299
|
+
# mid-walk invalidates the cursor via the pinned column:direction list.
|
|
300
|
+
def cursor_preset_order_pairs
|
|
301
|
+
presets = self.class.cursor_paginatable_order_presets
|
|
302
|
+
return nil unless presets
|
|
303
|
+
|
|
304
|
+
raw = params[self.class.cursor_paginatable_order_param]
|
|
305
|
+
return presets[self.class.cursor_paginatable_default_preset] if raw.nil? || raw.to_s.strip.empty?
|
|
306
|
+
|
|
307
|
+
key = presets.keys.find { |k| k.to_s == raw.to_s }
|
|
308
|
+
return presets[key] if key
|
|
309
|
+
|
|
310
|
+
raise InvalidOrderPreset, "Unknown order preset '#{raw}'. Available: #{presets.keys.join(', ')}."
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def cursor_per_page(override)
|
|
314
|
+
requested = (override || params[:per_page]).to_i
|
|
315
|
+
requested = self.class.cursor_paginatable_per_page if requested < 1
|
|
316
|
+
cap = self.class.cursor_paginatable_max_per_page
|
|
317
|
+
cap.positive? ? [requested, cap].min : requested
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# ----- cursor encode/decode -----
|
|
321
|
+
|
|
322
|
+
# "o" always pins the CANONICAL column:direction list (scope checks
|
|
323
|
+
# compare against it regardless of travel direction); "d" carries the
|
|
324
|
+
# direction this cursor travels.
|
|
325
|
+
def encode_cursor(model, pairs, record, direction)
|
|
326
|
+
payload = {
|
|
327
|
+
"t" => model.table_name, # pin the table so cross-model replay is rejected
|
|
328
|
+
"o" => pairs.map { |col, dir| "#{col}:#{dir}" },
|
|
329
|
+
"d" => direction,
|
|
330
|
+
"v" => pairs.map { |col, _dir| serialize_cursor_value(cursor_boundary_value!(record, col)) }
|
|
331
|
+
}
|
|
332
|
+
cursor_base64_encode(JSON.generate(payload))
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# A NULL boundary value would emit `col > NULL` — never TRUE in SQL
|
|
336
|
+
# three-valued logic — and silently drop rows from every later page.
|
|
337
|
+
# Fail loudly instead: this is a data/configuration problem.
|
|
338
|
+
def cursor_boundary_value!(record, col)
|
|
339
|
+
value = record.public_send(col)
|
|
340
|
+
return value unless value.nil?
|
|
341
|
+
|
|
342
|
+
raise ArgumentError,
|
|
343
|
+
"#{CursorPaginatable.name}: ordering column '#{col}' is NULL on the page-boundary row " \
|
|
344
|
+
"(id: #{record.id.inspect}) — cursor pagination needs non-NULL ordering values; " \
|
|
345
|
+
"use NOT NULL columns or COALESCE"
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Explicit is_a? checks (NOT acts_like?, which needs an un-required
|
|
349
|
+
# core_ext; NOT case/when, whose Module#=== misses TimeWithZone — its
|
|
350
|
+
# redefined #is_a? returns true for Time). iso8601(6) keeps microsecond
|
|
351
|
+
# precision so boundary equality survives the round trip.
|
|
352
|
+
def serialize_cursor_value(value)
|
|
353
|
+
return value.to_time.utc.iso8601(6) if value.is_a?(Time) || value.is_a?(DateTime)
|
|
354
|
+
return value.iso8601 if value.is_a?(Date)
|
|
355
|
+
return value.to_s if value.is_a?(BigDecimal)
|
|
356
|
+
|
|
357
|
+
value
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# nil → no cursor param (first page). Otherwise {values:, backward:}:
|
|
361
|
+
# the boundary values cast back to native types via the model's
|
|
362
|
+
# attribute types, so Arel quotes them correctly per database adapter
|
|
363
|
+
# (SQLite stores datetimes with a space, not ISO "T" — raw string
|
|
364
|
+
# comparison would silently break).
|
|
365
|
+
def decode_cursor(raw, pairs, model, bidirectional:)
|
|
366
|
+
return nil if raw.nil? || raw.to_s.strip.empty?
|
|
367
|
+
|
|
368
|
+
payload = parse_cursor_payload(raw.to_s)
|
|
369
|
+
raise InvalidCursor, "Invalid pagination cursor." unless payload
|
|
370
|
+
|
|
371
|
+
verify_cursor_scope!(payload, pairs, model)
|
|
372
|
+
direction = cursor_direction!(payload, bidirectional)
|
|
373
|
+
values = payload["v"]
|
|
374
|
+
raise InvalidCursor, "Invalid pagination cursor." unless valid_cursor_values?(values, pairs.size)
|
|
375
|
+
|
|
376
|
+
{ values: pairs.zip(values).map { |(col, _dir), value| model.type_for_attribute(col.to_s).cast(value) },
|
|
377
|
+
backward: direction == "prev" }
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Pre-bidirectional cursors carry no "d" — they are forward cursors and
|
|
381
|
+
# stay valid. "prev" requires bidirectional mode: a backward token
|
|
382
|
+
# replayed against a forward-only endpoint is a configuration mismatch,
|
|
383
|
+
# not a first page.
|
|
384
|
+
def cursor_direction!(payload, bidirectional)
|
|
385
|
+
direction = payload["d"] || "next"
|
|
386
|
+
raise InvalidCursor, "Invalid pagination cursor." unless CURSOR_DIRECTIONS.include?(direction)
|
|
387
|
+
raise InvalidCursor, "Cursor does not match the current pagination configuration." if direction == "prev" && !bidirectional
|
|
388
|
+
|
|
389
|
+
direction
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def parse_cursor_payload(raw)
|
|
393
|
+
parsed = JSON.parse(cursor_base64_decode(raw))
|
|
394
|
+
parsed.is_a?(Hash) ? parsed : nil
|
|
395
|
+
# ArgumentError: unpack1("m0") on invalid Base64; JSON::ParserError:
|
|
396
|
+
# malformed JSON. Nothing else in the body raises either.
|
|
397
|
+
rescue ArgumentError, JSON::ParserError
|
|
398
|
+
nil
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# Hand-rolled URL-safe Base64 (pack("m0") is strict RFC 4648, raising
|
|
402
|
+
# ArgumentError on garbage) — same trick as WebhookVerifiable, keeping
|
|
403
|
+
# the gem off the base64 gem, which left Ruby's default gems in 3.4.
|
|
404
|
+
def cursor_base64_encode(json)
|
|
405
|
+
[json].pack("m0").tr("+/", "-_").delete("=")
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def cursor_base64_decode(raw)
|
|
409
|
+
standard = raw.tr("-_", "+/")
|
|
410
|
+
standard += "=" * ((4 - (standard.length % 4)) % 4)
|
|
411
|
+
standard.unpack1("m0")
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Exact match on table + column:direction list rejects cursors minted on
|
|
415
|
+
# another model, under another order config, or after a config change.
|
|
416
|
+
def verify_cursor_scope!(payload, pairs, model)
|
|
417
|
+
expected = pairs.map { |col, dir| "#{col}:#{dir}" }
|
|
418
|
+
return if payload["t"] == model.table_name && payload["o"] == expected
|
|
419
|
+
|
|
420
|
+
raise InvalidCursor, "Cursor does not match the current pagination configuration."
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Only non-null JSON scalars may be cast — a tampered Hash/Array value
|
|
424
|
+
# would cast to nil (silently empty page) or raise adapter-dependent
|
|
425
|
+
# errors, and we never mint null boundary values (see
|
|
426
|
+
# cursor_boundary_value!), so a null here is tampering too.
|
|
427
|
+
def valid_cursor_values?(values, expected_size)
|
|
428
|
+
values.is_a?(Array) && values.size == expected_size && values.all? { |v| cursor_scalar?(v) }
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def cursor_scalar?(value)
|
|
432
|
+
value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# ----- keyset WHERE -----
|
|
436
|
+
# Two strategies, same strict lexicographic semantics (the boundary row
|
|
437
|
+
# itself is excluded):
|
|
438
|
+
# :or — (c1 > v1) OR (c1 = v1 AND c2 > v2) OR ... with gt/lt per
|
|
439
|
+
# column direction; portable, supports mixed asc/desc.
|
|
440
|
+
# :row — (c1, c2, id) > (v1, v2, v3) tuple comparison; lets the
|
|
441
|
+
# planner walk a composite index directly, but only expresses
|
|
442
|
+
# uniform directions and needs adapter support.
|
|
443
|
+
def cursor_predicate(model, pairs, values)
|
|
444
|
+
if cursor_row_predicate?(model, pairs)
|
|
445
|
+
cursor_row_predicate(model, pairs, values)
|
|
446
|
+
else
|
|
447
|
+
cursor_or_predicate(model, pairs, values)
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# :auto picks :row when it is expressible (>= 2 uniform-direction
|
|
452
|
+
# columns) and the adapter supports tuples, silently falling back
|
|
453
|
+
# otherwise; an explicit :row raises on mixed directions instead of
|
|
454
|
+
# silently changing strategy.
|
|
455
|
+
def cursor_row_predicate?(model, pairs)
|
|
456
|
+
mode = self.class.cursor_paginatable_predicate
|
|
457
|
+
return false if mode == :or || pairs.size < 2
|
|
458
|
+
|
|
459
|
+
uniform = pairs.map(&:last).uniq.size == 1
|
|
460
|
+
if mode == :row
|
|
461
|
+
raise ArgumentError, "#{CursorPaginatable.name}: predicate: :row requires uniform order directions" unless uniform
|
|
462
|
+
|
|
463
|
+
return true
|
|
464
|
+
end
|
|
465
|
+
uniform && model.connection.adapter_name.match?(ROW_PREDICATE_ADAPTERS)
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def cursor_row_predicate(model, pairs, values)
|
|
469
|
+
table = model.arel_table
|
|
470
|
+
lhs = Arel::Nodes::Grouping.new(pairs.map { |col, _dir| table[col] })
|
|
471
|
+
rhs = Arel::Nodes::Grouping.new(
|
|
472
|
+
pairs.each_with_index.map { |(col, _dir), i| Arel::Nodes.build_quoted(values[i], table[col]) }
|
|
473
|
+
)
|
|
474
|
+
pairs.first.last == :asc ? Arel::Nodes::GreaterThan.new(lhs, rhs) : Arel::Nodes::LessThan.new(lhs, rhs)
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def cursor_or_predicate(model, pairs, values)
|
|
478
|
+
table = model.arel_table
|
|
479
|
+
branches = pairs.each_index.map do |i|
|
|
480
|
+
eqs = (0...i).map { |j| table[pairs[j][0]].eq(values[j]) }
|
|
481
|
+
cmp = pairs[i][1] == :asc ? table[pairs[i][0]].gt(values[i]) : table[pairs[i][0]].lt(values[i])
|
|
482
|
+
(eqs + [cmp]).reduce(:and)
|
|
483
|
+
end
|
|
484
|
+
branches.reduce(:or)
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def apply_cursor_pagination_headers(meta)
|
|
488
|
+
return unless respond_to?(:response) && response
|
|
489
|
+
|
|
490
|
+
response.set_header("X-Per-Page", meta[:per_page].to_s)
|
|
491
|
+
response.set_header("X-Count", meta[:count].to_s)
|
|
492
|
+
response.set_header("X-Has-More", meta[:has_more].to_s)
|
|
493
|
+
response.set_header("X-Next-Cursor", meta[:next_cursor]) if meta[:next_cursor]
|
|
494
|
+
return unless meta.key?(:has_prev)
|
|
495
|
+
|
|
496
|
+
response.set_header("X-Has-Prev", meta[:has_prev].to_s)
|
|
497
|
+
response.set_header("X-Prev-Cursor", meta[:prev_cursor]) if meta[:prev_cursor]
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
end
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
|
|
3
|
+
module ConcernsOnRails
|
|
4
|
+
module Models
|
|
5
|
+
# Alias an existing ActiveRecord association under a second name with
|
|
6
|
+
# FULL support — read, write/assign, build/create, and the query side
|
|
7
|
+
# (joins / includes / preload / eager_load / where hash conditions) —
|
|
8
|
+
# not just a delegated reader. Rails' alias_attribute covers columns
|
|
9
|
+
# only; there is no built-in way to alias an association.
|
|
10
|
+
#
|
|
11
|
+
# class Book < ApplicationRecord
|
|
12
|
+
# include ConcernsOnRails::Aliasable
|
|
13
|
+
#
|
|
14
|
+
# belongs_to :author
|
|
15
|
+
# has_many :chapters
|
|
16
|
+
#
|
|
17
|
+
# alias_association :writer, :author # alias_method order: new, old
|
|
18
|
+
# alias_association :sections, :chapters
|
|
19
|
+
#
|
|
20
|
+
# # Options:
|
|
21
|
+
# alias_association :penman, :author, only: :reader # no writer/build_/create_
|
|
22
|
+
# alias_association :parts, :chapters, except: :ids # skip part_ids/part_ids=
|
|
23
|
+
# alias_association :owner, :author, deprecated: "use #author" # warns on use
|
|
24
|
+
# alias_association :maker, :author, alias_foreign_key: true # maker_id -> author_id
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# book.writer # same cached object as book.author
|
|
28
|
+
# book.writer = user # assigns through the original association
|
|
29
|
+
# book.build_writer(...) # build_/create_/create_!/reload_ (singular)
|
|
30
|
+
# book.sections << chapter # the same CollectionProxy as book.chapters
|
|
31
|
+
# book.section_ids # ids reader/writer (collection)
|
|
32
|
+
# Book.joins(:writer) # INNER JOIN "authors"
|
|
33
|
+
# Book.joins(:sections).where(sections: { title: "Intro" })
|
|
34
|
+
#
|
|
35
|
+
# Notes:
|
|
36
|
+
# * Declare alias_association AFTER the source association — it raises
|
|
37
|
+
# "does not exist" when the source has not been defined yet.
|
|
38
|
+
# * One loaded cache under two names: record.association(:alias) IS
|
|
39
|
+
# record.association(:source), and only the source macro installs
|
|
40
|
+
# callbacks — dependent:, counter_cache, autosave and validations
|
|
41
|
+
# run exactly once.
|
|
42
|
+
# * Query SQL: a bare joins(:sections) joins "chapters" directly; when
|
|
43
|
+
# paired with where(sections: {...}) Rails aliases the join as
|
|
44
|
+
# "sections" (INNER JOIN "chapters" "sections"). A where-hash key
|
|
45
|
+
# must match the name you joined under (same rule as stock Rails):
|
|
46
|
+
# joins(:sections).where(sections: {...}) works,
|
|
47
|
+
# joins(:chapters).where(sections: {...}) does not.
|
|
48
|
+
# * The belongs_to foreign-key attribute is NOT aliased — pair with
|
|
49
|
+
# Rails' alias_attribute (e.g. :writer_id, :author_id) if needed.
|
|
50
|
+
# * has_and_belongs_to_many cannot be aliased — use has_many :through.
|
|
51
|
+
# * has_many/has_one :through CAN be aliased (the copy pins `source:`
|
|
52
|
+
# so it is not re-derived from the alias name). One caveat: when the
|
|
53
|
+
# alias is declared before the through model's class has loaded AND
|
|
54
|
+
# the through model defines the source under a different name (e.g.
|
|
55
|
+
# belongs_to :author behind has_many :authors), declare `source:`
|
|
56
|
+
# explicitly on the original association.
|
|
57
|
+
# * Subclasses inherit aliases. If a subclass redefines the source
|
|
58
|
+
# association, re-declare the alias there (re-declaring with the SAME
|
|
59
|
+
# source is allowed and idempotent) so the query side picks up the
|
|
60
|
+
# new reflection. Repointing an existing alias at a DIFFERENT source
|
|
61
|
+
# raises.
|
|
62
|
+
# * only:/except: narrow the generated methods by group — :reader,
|
|
63
|
+
# :writer, :build, :reload (singular), :ids (collection). Groups that
|
|
64
|
+
# do not apply to the reflection type are ignored; the query side
|
|
65
|
+
# (joins/includes/where-hash) is always registered.
|
|
66
|
+
# * deprecated: true (or a String hint) makes every generated delegator
|
|
67
|
+
# warn through ConcernsOnRails.deprecator before delegating — the
|
|
68
|
+
# gradual-rename story: point the OLD name at the new association and
|
|
69
|
+
# deprecate it. The query side and alias_foreign_key attribute
|
|
70
|
+
# aliases do not warn.
|
|
71
|
+
# * alias_foreign_key: true (belongs_to only) also aliases the FK
|
|
72
|
+
# attribute via Rails' alias_attribute (<alias>_id, plus <alias>_type
|
|
73
|
+
# when polymorphic).
|
|
74
|
+
module Aliasable
|
|
75
|
+
extend ActiveSupport::Concern
|
|
76
|
+
|
|
77
|
+
LABEL = "ConcernsOnRails::Models::Aliasable".freeze
|
|
78
|
+
|
|
79
|
+
# Method stems per reflection type, keyed by the only:/except: group
|
|
80
|
+
# names; %s is the association name. The reload_/reset_ stems vary
|
|
81
|
+
# across the supported Rails range and build_/create_ are skipped by
|
|
82
|
+
# Rails for polymorphic belongs_to, so each SOURCE method is
|
|
83
|
+
# existence-checked before its delegator is made. The :ids pair is
|
|
84
|
+
# handled separately (it singularizes the association name).
|
|
85
|
+
SINGULAR_STEM_GROUPS = {
|
|
86
|
+
reader: ["%s"].freeze,
|
|
87
|
+
writer: ["%s="].freeze,
|
|
88
|
+
build: ["build_%s", "create_%s", "create_%s!"].freeze,
|
|
89
|
+
reload: ["reload_%s", "reset_%s"].freeze
|
|
90
|
+
}.freeze
|
|
91
|
+
COLLECTION_STEM_GROUPS = {
|
|
92
|
+
reader: ["%s"].freeze,
|
|
93
|
+
writer: ["%s="].freeze
|
|
94
|
+
}.freeze
|
|
95
|
+
METHOD_GROUPS = %i[reader writer build reload ids].freeze
|
|
96
|
+
|
|
97
|
+
included do
|
|
98
|
+
# Guarded so re-including the concern (e.g. ApplicationRecord and a
|
|
99
|
+
# model both include it) cannot reset an inherited, populated hash —
|
|
100
|
+
# that would desync the #association override from the inherited
|
|
101
|
+
# delegators and split the loaded caches. aliasable_alias_methods
|
|
102
|
+
# records which delegators each alias defined, so a re-declare with
|
|
103
|
+
# narrower only:/except: prunes the ones no longer wanted.
|
|
104
|
+
unless respond_to?(:aliasable_aliases)
|
|
105
|
+
class_attribute :aliasable_aliases, instance_accessor: false, default: {}
|
|
106
|
+
class_attribute :aliasable_alias_methods, instance_accessor: false, default: {}
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
module ClassMethods
|
|
111
|
+
# Register `new_name` as a full alias of the existing association
|
|
112
|
+
# `source_name`. Argument order mirrors `alias_method new, old`.
|
|
113
|
+
# Callable many times; aliases of aliases collapse to the terminal
|
|
114
|
+
# source; re-declaring an existing alias WITH THE SAME SOURCE (the
|
|
115
|
+
# STI subclass path) refreshes its reflection in place instead of
|
|
116
|
+
# raising, while repointing it at a different source raises — that is
|
|
117
|
+
# almost always an accident, and the generated methods/reflection
|
|
118
|
+
# would silently change meaning.
|
|
119
|
+
# Options:
|
|
120
|
+
# only:/except: — narrow the generated method map by group
|
|
121
|
+
# (:reader, :writer, :build, :reload, :ids); inapplicable groups
|
|
122
|
+
# are ignored, unknown ones raise.
|
|
123
|
+
# deprecated: — true or a String hint; delegators warn through
|
|
124
|
+
# ConcernsOnRails.deprecator before delegating.
|
|
125
|
+
# alias_foreign_key: — belongs_to only; alias_attribute the FK
|
|
126
|
+
# (<alias>_id, plus <alias>_type when polymorphic).
|
|
127
|
+
def alias_association(new_name, source_name, only: nil, except: nil, deprecated: nil, alias_foreign_key: false)
|
|
128
|
+
new_name = new_name.to_sym
|
|
129
|
+
source = aliasable_aliases[source_name.to_sym] || source_name.to_sym # collapse alias-of-alias
|
|
130
|
+
aliasable_guard_repoint!(new_name, source)
|
|
131
|
+
reflection = aliasable_validate!(new_name, source)
|
|
132
|
+
aliasable_validate_foreign_key!(new_name, reflection) if alias_foreign_key
|
|
133
|
+
aliasable_install(new_name, source, reflection,
|
|
134
|
+
groups: aliasable_method_groups(only, except),
|
|
135
|
+
deprecated: deprecated, alias_foreign_key: alias_foreign_key)
|
|
136
|
+
new_name
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def aliasable_guard_repoint!(new_name, source)
|
|
142
|
+
existing = aliasable_aliases[new_name]
|
|
143
|
+
return unless existing && existing != source
|
|
144
|
+
|
|
145
|
+
raise ArgumentError,
|
|
146
|
+
"#{LABEL}: '#{new_name}' is already aliased to '#{existing}' (model: #{name}) — " \
|
|
147
|
+
"repointing an alias is not allowed; remove the original declaration first"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def aliasable_install(new_name, source, reflection, groups:, deprecated:, alias_foreign_key:)
|
|
151
|
+
method_map = aliasable_method_map(new_name, source, reflection, groups)
|
|
152
|
+
unless aliasable_aliases.key?(new_name)
|
|
153
|
+
sweep = method_map.keys
|
|
154
|
+
sweep += aliasable_foreign_key_names(new_name, reflection) if alias_foreign_key
|
|
155
|
+
aliasable_check_collisions!(new_name, sweep)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
self.aliasable_aliases = aliasable_aliases.merge(new_name => source)
|
|
159
|
+
aliasable_register_reflection(new_name, source, reflection)
|
|
160
|
+
aliasable_define_methods(new_name, method_map, aliasable_deprecation_message(new_name, source, deprecated))
|
|
161
|
+
aliasable_define_foreign_key_aliases(new_name, reflection) if alias_foreign_key
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def aliasable_validate!(new_name, source)
|
|
165
|
+
raise ArgumentError, "#{LABEL}: alias '#{new_name}' must differ from the source association" if new_name == source
|
|
166
|
+
|
|
167
|
+
reflection = reflect_on_association(source)
|
|
168
|
+
unless reflection
|
|
169
|
+
raise ArgumentError,
|
|
170
|
+
"#{LABEL}: association '#{source}' does not exist (model: #{name}) — " \
|
|
171
|
+
"declare alias_association after the association"
|
|
172
|
+
end
|
|
173
|
+
# HABTM: Reflection.create cannot build habtm copies and the public
|
|
174
|
+
# reflections rebuild drops parent_reflection children — reject.
|
|
175
|
+
if reflection.macro == :has_and_belongs_to_many ||
|
|
176
|
+
(reflection.respond_to?(:parent_reflection) && reflection.parent_reflection)
|
|
177
|
+
raise ArgumentError, "#{LABEL}: has_and_belongs_to_many associations cannot be aliased — use has_many :through"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
reflection
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# only:/except: narrow generation by named group. Unknown names
|
|
184
|
+
# raise; names that don't apply to the reflection type (e.g. :build
|
|
185
|
+
# on a collection) are ignored so one declaration can serve both
|
|
186
|
+
# shapes.
|
|
187
|
+
def aliasable_method_groups(only, except)
|
|
188
|
+
raise ArgumentError, "#{LABEL}: pass only: or except:, not both" if only && except
|
|
189
|
+
|
|
190
|
+
requested = (Array(only) + Array(except)).map(&:to_sym)
|
|
191
|
+
unknown = requested - METHOD_GROUPS
|
|
192
|
+
if unknown.any?
|
|
193
|
+
raise ArgumentError,
|
|
194
|
+
"#{LABEL}: unknown method group(s): #{unknown.join(', ')} (valid: #{METHOD_GROUPS.join(', ')})"
|
|
195
|
+
end
|
|
196
|
+
return METHOD_GROUPS - requested if except
|
|
197
|
+
|
|
198
|
+
only ? requested : METHOD_GROUPS
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def aliasable_validate_foreign_key!(new_name, reflection)
|
|
202
|
+
return if reflection.belongs_to?
|
|
203
|
+
|
|
204
|
+
raise ArgumentError,
|
|
205
|
+
"#{LABEL}: alias_foreign_key: is only supported for belongs_to associations " \
|
|
206
|
+
"('#{new_name}' aliases a #{reflection.macro})"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Sweeps every name the declaration would generate (the configured
|
|
210
|
+
# method map plus the alias_foreign_key attribute pair) against
|
|
211
|
+
# existing associations, methods, columns, and declared attributes
|
|
212
|
+
# (virtual attributes).
|
|
213
|
+
def aliasable_check_collisions!(new_name, method_names)
|
|
214
|
+
raise ArgumentError, "#{LABEL}: '#{new_name}' is already an association on #{name}" if reflect_on_association(new_name)
|
|
215
|
+
|
|
216
|
+
schema = aliasable_schema_reachable?
|
|
217
|
+
method_names.each { |meth| aliasable_check_method_collision!(meth, schema) }
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def aliasable_check_method_collision!(meth, schema)
|
|
221
|
+
attr_name = meth.to_s.delete_suffix("!").delete_suffix("=")
|
|
222
|
+
if schema && (column_names.include?(attr_name) || attribute_types.key?(attr_name))
|
|
223
|
+
raise ArgumentError, "#{LABEL}: '#{attr_name}' is already a column or attribute on #{name} (table: #{table_name})"
|
|
224
|
+
end
|
|
225
|
+
return unless method_defined?(meth) || private_method_defined?(meth)
|
|
226
|
+
|
|
227
|
+
raise ArgumentError, "#{LABEL}: '#{meth}' is already defined as a method on #{name}"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Column collisions can only be checked against a live schema. Class
|
|
231
|
+
# loading without a database (rake db:create, assets:precompile) must
|
|
232
|
+
# not crash, so the column/attribute sweep is best-effort. The rescue
|
|
233
|
+
# is scoped to AR's own error hierarchy (no connection, no database,
|
|
234
|
+
# statement errors) — a NameError/NoMethodError from a real bug must
|
|
235
|
+
# still surface.
|
|
236
|
+
def aliasable_schema_reachable?
|
|
237
|
+
table_exists?
|
|
238
|
+
rescue ActiveRecord::ActiveRecordError
|
|
239
|
+
false
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Register a RENAMED COPY of the source reflection under the alias.
|
|
243
|
+
# Registering the same object does NOT work: PredicateBuilder aliases
|
|
244
|
+
# the arel table to the where-hash key while JoinDependency names the
|
|
245
|
+
# JOIN after reflection.name, so a shared object yields `JOIN "books"`
|
|
246
|
+
# + `WHERE "works"...` — invalid SQL. With the renamed copy the two
|
|
247
|
+
# agree (`INNER JOIN "books" "works"` when the where-hash references
|
|
248
|
+
# the alias), and the #association override below keeps the copy on
|
|
249
|
+
# the source's loaded cache. Reflection.create builds metadata only —
|
|
250
|
+
# it installs no callbacks, autosave, or validations, so side effects
|
|
251
|
+
# never run twice.
|
|
252
|
+
def aliasable_register_reflection(new_name, source, src)
|
|
253
|
+
renamed = ActiveRecord::Reflection.create(src.macro, new_name, src.scope, aliasable_copy_options(src), self)
|
|
254
|
+
# _reflections is String-keyed on Rails <= 7.x and Symbol-keyed on
|
|
255
|
+
# newer releases — probe the source's own entry, never hardcode.
|
|
256
|
+
key = _reflections.key?(source.to_s) ? new_name.to_s : new_name
|
|
257
|
+
# class_attribute writer: a subclass call never mutates the parent.
|
|
258
|
+
self._reflections = _reflections.merge(key => renamed)
|
|
259
|
+
aliasable_clear_reflection_caches
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Every option a reflection copy would re-derive from the ALIAS name
|
|
263
|
+
# must be pinned to what the source derived.
|
|
264
|
+
# * through: pin :source (Rails derives it from the association
|
|
265
|
+
# name — the copy would look for the alias on the through model).
|
|
266
|
+
# class_name is NOT touched: ThroughReflection#class_name resolves
|
|
267
|
+
# the source-reflection chain eagerly, raising NameError at macro
|
|
268
|
+
# time while the through/target classes are still unloaded, and
|
|
269
|
+
# with :source pinned the copy derives its klass correctly anyway.
|
|
270
|
+
# * direct: pin class_name via the lazy string (NOT src.klass.name —
|
|
271
|
+
# calling klass at macro time raises NameError while the target
|
|
272
|
+
# class is unloaded). belongs_to also derives its FK from the
|
|
273
|
+
# association name, so pin foreign_key (and foreign_type when
|
|
274
|
+
# polymorphic).
|
|
275
|
+
def aliasable_copy_options(src)
|
|
276
|
+
opts = src.options.dup
|
|
277
|
+
if opts[:through]
|
|
278
|
+
opts[:source] ||= aliasable_through_source_name(src)
|
|
279
|
+
else
|
|
280
|
+
aliasable_pin_direct_options!(opts, src)
|
|
281
|
+
end
|
|
282
|
+
opts
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def aliasable_pin_direct_options!(opts, src)
|
|
286
|
+
opts[:class_name] ||= src.class_name.to_s unless src.polymorphic?
|
|
287
|
+
return unless src.belongs_to?
|
|
288
|
+
|
|
289
|
+
opts[:foreign_key] ||= src.foreign_key
|
|
290
|
+
opts[:foreign_type] ||= src.foreign_type if src.polymorphic?
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Exact resolution (src.source_reflection.name) handles sources the
|
|
294
|
+
# through model defines under a different form (e.g. belongs_to
|
|
295
|
+
# :author behind has_many :authors), but it loads the through class —
|
|
296
|
+
# impossible while classes are still loading. Fall back to the source
|
|
297
|
+
# association's own name, Rails' derivation anchor; a lazily-loading
|
|
298
|
+
# app whose source lives under a different name must declare `source:`
|
|
299
|
+
# on the original association (standard Rails practice).
|
|
300
|
+
def aliasable_through_source_name(src)
|
|
301
|
+
src.source_reflection&.name || src.name
|
|
302
|
+
rescue NameError, ActiveRecord::ActiveRecordError
|
|
303
|
+
src.name
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# The memoized reflections cache is per-class; descendants that have
|
|
307
|
+
# already memoized would otherwise stay stale (this matters on the
|
|
308
|
+
# re-declare-in-a-subclass path). respond_to? guard: private method,
|
|
309
|
+
# presence varies across the supported range.
|
|
310
|
+
def aliasable_clear_reflection_caches
|
|
311
|
+
([self] + descendants).each do |klass|
|
|
312
|
+
klass.send(:clear_reflections_cache) if klass.respond_to?(:clear_reflections_cache, true)
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def aliasable_method_map(new_name, source, reflection, groups)
|
|
317
|
+
stem_groups = reflection.collection? ? COLLECTION_STEM_GROUPS : SINGULAR_STEM_GROUPS
|
|
318
|
+
map = {}
|
|
319
|
+
stem_groups.each do |group, stems|
|
|
320
|
+
next unless groups.include?(group)
|
|
321
|
+
|
|
322
|
+
stems.each { |stem| map[format(stem, new_name)] = format(stem, source) }
|
|
323
|
+
end
|
|
324
|
+
aliasable_add_ids_pair(map, new_name, source) if reflection.collection? && groups.include?(:ids)
|
|
325
|
+
map
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def aliasable_add_ids_pair(map, new_name, source)
|
|
329
|
+
alias_ids = "#{new_name.to_s.singularize}_ids"
|
|
330
|
+
source_ids = "#{source.to_s.singularize}_ids"
|
|
331
|
+
map[alias_ids] = source_ids
|
|
332
|
+
map["#{alias_ids}="] = "#{source_ids}="
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Defines the configured delegators and prunes ones a previous,
|
|
336
|
+
# broader declaration of this alias created (a re-declare narrowing
|
|
337
|
+
# only:/except: must not leave stale methods behind). Only methods
|
|
338
|
+
# this class's OWN generated_association_methods defined are pruned —
|
|
339
|
+
# a parent's declaration is never touched from a subclass.
|
|
340
|
+
def aliasable_define_methods(new_name, method_map, deprecation)
|
|
341
|
+
aliasable_prune_stale_delegators(new_name, method_map)
|
|
342
|
+
defined = method_map.filter_map do |alias_method_name, source_method_name|
|
|
343
|
+
next unless method_defined?(source_method_name)
|
|
344
|
+
|
|
345
|
+
aliasable_delegate(alias_method_name, source_method_name, deprecation)
|
|
346
|
+
alias_method_name
|
|
347
|
+
end
|
|
348
|
+
self.aliasable_alias_methods = aliasable_alias_methods.merge(new_name => defined)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def aliasable_prune_stale_delegators(new_name, method_map)
|
|
352
|
+
stale = (aliasable_alias_methods[new_name] || []) - method_map.keys
|
|
353
|
+
mod = generated_association_methods
|
|
354
|
+
stale.each { |meth| mod.send(:remove_method, meth) if mod.method_defined?(meth, false) }
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Delegators (not alias_method) so the alias honors model overrides
|
|
358
|
+
# of the source method, finds sources declared on a superclass, and
|
|
359
|
+
# tracks later redefinitions. They live in
|
|
360
|
+
# generated_association_methods — the same module Rails puts its own
|
|
361
|
+
# association methods in — so a model can override an alias and call
|
|
362
|
+
# super. A deprecated alias warns once per call, BEFORE delegating,
|
|
363
|
+
# so the warning fires even when the source raises.
|
|
364
|
+
def aliasable_delegate(alias_method_name, source_method_name, deprecation)
|
|
365
|
+
generated_association_methods.define_method(alias_method_name) do |*args, **kwargs, &block|
|
|
366
|
+
ConcernsOnRails.deprecator.warn(deprecation) if deprecation
|
|
367
|
+
__send__(source_method_name, *args, **kwargs, &block)
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Computed once at macro time; true gives the generic message and a
|
|
372
|
+
# String is appended as the migration hint. Query-side use and the
|
|
373
|
+
# alias_foreign_key attribute aliases do not warn — only delegators.
|
|
374
|
+
def aliasable_deprecation_message(new_name, source, deprecated)
|
|
375
|
+
return nil if deprecated.nil? || deprecated == false
|
|
376
|
+
|
|
377
|
+
base = "#{name}##{new_name} is a deprecated alias of ##{source}"
|
|
378
|
+
deprecated.is_a?(String) ? "#{base} — #{deprecated}" : base
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def aliasable_foreign_key_names(new_name, reflection)
|
|
382
|
+
names = ["#{new_name}_id", "#{new_name}_id="]
|
|
383
|
+
names.push("#{new_name}_type", "#{new_name}_type=") if reflection.polymorphic?
|
|
384
|
+
names
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# alias_attribute, not delegators: the FK is a real column, and Rails
|
|
388
|
+
# resolves attribute aliases in attribute APIs and where-hashes.
|
|
389
|
+
def aliasable_define_foreign_key_aliases(new_name, reflection)
|
|
390
|
+
alias_attribute :"#{new_name}_id", reflection.foreign_key.to_sym
|
|
391
|
+
alias_attribute :"#{new_name}_type", reflection.foreign_type.to_sym if reflection.polymorphic?
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Route the alias to the source association proxy so
|
|
396
|
+
# record.association(:alias) IS record.association(:source) — one
|
|
397
|
+
# loaded cache. Load-bearing for the preloader, which assigns loaded
|
|
398
|
+
# records via record.association(reflection.name) using the alias's
|
|
399
|
+
# renamed reflection.
|
|
400
|
+
def association(name)
|
|
401
|
+
super(self.class.aliasable_aliases[name.to_sym] || name)
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
end
|
data/lib/concerns_on_rails.rb
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
require "active_support/concern"
|
|
2
|
+
require "active_support/deprecation"
|
|
2
3
|
require "concerns_on_rails/version"
|
|
3
4
|
|
|
4
5
|
module ConcernsOnRails
|
|
5
6
|
module Models; end
|
|
6
7
|
module Controllers; end
|
|
7
8
|
module Support; end
|
|
9
|
+
|
|
10
|
+
# Gem-wide deprecator backing `alias_association ..., deprecated:` (and any
|
|
11
|
+
# future deprecation surface). A dedicated instance — not the global
|
|
12
|
+
# ActiveSupport::Deprecation singleton, whose direct use is itself
|
|
13
|
+
# deprecated on Rails 7.1+. Default behavior prints to $stderr; Rails apps
|
|
14
|
+
# can re-route it (e.g. `config.active_support.deprecation` style):
|
|
15
|
+
#
|
|
16
|
+
# ConcernsOnRails.deprecator.behavior = :log
|
|
17
|
+
def self.deprecator
|
|
18
|
+
@deprecator ||= ActiveSupport::Deprecation.new("2.0", "concerns_on_rails")
|
|
19
|
+
end
|
|
8
20
|
end
|
|
9
21
|
|
|
10
22
|
# Shared internal helpers (must load before the concerns that use them)
|
|
@@ -37,6 +49,7 @@ require "concerns_on_rails/models/maskable"
|
|
|
37
49
|
require "concerns_on_rails/models/monetizable"
|
|
38
50
|
require "concerns_on_rails/models/auditable"
|
|
39
51
|
require "concerns_on_rails/models/lockable"
|
|
52
|
+
require "concerns_on_rails/models/aliasable"
|
|
40
53
|
|
|
41
54
|
# Controller concerns
|
|
42
55
|
require "concerns_on_rails/controllers/paginatable"
|
|
@@ -52,6 +65,7 @@ require "concerns_on_rails/controllers/throttleable"
|
|
|
52
65
|
require "concerns_on_rails/controllers/timezoneable"
|
|
53
66
|
require "concerns_on_rails/controllers/idempotentable"
|
|
54
67
|
require "concerns_on_rails/controllers/webhook_verifiable"
|
|
68
|
+
require "concerns_on_rails/controllers/cursor_paginatable"
|
|
55
69
|
|
|
56
70
|
# Backwards compatibility (top-level aliases for pre-1.6 module paths)
|
|
57
71
|
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.18.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-12 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -71,6 +71,7 @@ files:
|
|
|
71
71
|
- README.md
|
|
72
72
|
- lib/concerns_on_rails.rb
|
|
73
73
|
- lib/concerns_on_rails/controllers/authorizable.rb
|
|
74
|
+
- lib/concerns_on_rails/controllers/cursor_paginatable.rb
|
|
74
75
|
- lib/concerns_on_rails/controllers/error_handleable.rb
|
|
75
76
|
- lib/concerns_on_rails/controllers/filterable.rb
|
|
76
77
|
- lib/concerns_on_rails/controllers/idempotentable.rb
|
|
@@ -86,6 +87,7 @@ files:
|
|
|
86
87
|
- lib/concerns_on_rails/legacy_aliases.rb
|
|
87
88
|
- lib/concerns_on_rails/models/activatable.rb
|
|
88
89
|
- lib/concerns_on_rails/models/addressable.rb
|
|
90
|
+
- lib/concerns_on_rails/models/aliasable.rb
|
|
89
91
|
- lib/concerns_on_rails/models/auditable.rb
|
|
90
92
|
- lib/concerns_on_rails/models/expirable.rb
|
|
91
93
|
- lib/concerns_on_rails/models/hashable.rb
|