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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5759f8cf9f86e11087369181f53f807875909047c17fe8abb19a5b6bafaae697
4
- data.tar.gz: d6e34235431b2c54a36de7126b4829b9892b05f66f0fc6b476ff561731452100
3
+ metadata.gz: 30c62bfbc26afd05fe7ed2e991add6fa180f3e71316ec638a045932e89bf2ce1
4
+ data.tar.gz: af936258d441cf0206e120853f0a227967f663e577983628087defe2d60d7202
5
5
  SHA512:
6
- metadata.gz: 2b101b659f6d47f0adcface39ef7069a978d8342e6499661d358af12037c12fb1b7d317cdd075ca9b5593989df2941e121ecf46e02d51c11e4f445cdf91801dd
7
- data.tar.gz: 984342a4be9bae7692c880bb2436de85cd86a3a0e8cd4ba1c167168db297bf4a0ce42a3a7025de910b7e33495d67e8352667ea302b8d47e12c1824cd7bd4ae31
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 + thirteen controller concerns**, all production-ready
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
@@ -22,4 +22,5 @@ module ConcernsOnRails
22
22
  Monetizable = Models::Monetizable
23
23
  Auditable = Models::Auditable
24
24
  Lockable = Models::Lockable
25
+ Aliasable = Models::Aliasable
25
26
  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
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.17.0".freeze
2
+ VERSION = "1.18.0".freeze
3
3
  end
@@ -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.17.0
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-10 00:00:00.000000000 Z
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