concerns_on_rails 1.34.0 → 1.35.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 +56 -0
- data/README.md +4 -2
- data/lib/concerns_on_rails/controllers/cursor_paginatable.rb +14 -0
- data/lib/concerns_on_rails/controllers/paginatable.rb +24 -1
- data/lib/concerns_on_rails/models/normalizable.rb +4 -2
- data/lib/concerns_on_rails/models/searchable.rb +2 -4
- data/lib/concerns_on_rails/models/taggable.rb +3 -5
- data/lib/concerns_on_rails/support/caller_window.rb +39 -0
- data/lib/concerns_on_rails/support/edge_space.rb +31 -0
- data/lib/concerns_on_rails/support/generated_values.rb +16 -0
- data/lib/concerns_on_rails/support/money.rb +2 -1
- data/lib/concerns_on_rails/support/time_value.rb +6 -0
- data/lib/concerns_on_rails/version.rb +1 -1
- data/lib/concerns_on_rails.rb +2 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0542404e12b0b684a042ebcb69ca240f9581cfd75a2399782b93b4a14433832c
|
|
4
|
+
data.tar.gz: b0a7d63e3c1ed59c36d29bccb238e6bf9531d4a40a0841d5544217a0c5cbc04f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a678f1400483f94da5e10f0c24b5d9f2ce8f1c476183629fb8bf3ce96187c54a68a131650de902efd6913acf363168be22cfc9cde922de3f1c2607567f8f91d3
|
|
7
|
+
data.tar.gz: 1695643e945d899a7a279a803ddf0abadab8e54c9ac152af4d93a2a6002914448a2fc03e572f4bab4d3ecc38f3d98e54e990106359cafca8878d746b45b1e3d9
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,61 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 1.35.0 (2026-11-09)
|
|
4
|
+
|
|
5
|
+
A small correctness release following the 2026-10-29 audit: it fixes the 3 findings the 1.34.0 release left open
|
|
6
|
+
(DATA-S2, PARAMS-S2, TRANS-S4). Two of them change behaviour. Read **Upgrading** first.
|
|
7
|
+
|
|
8
|
+
### Upgrading
|
|
9
|
+
|
|
10
|
+
- [ ] **Paginatable, a relation you already limited or offset:** `paginated(Player.order(score: :desc).limit(10))`
|
|
11
|
+
now pages INSIDE that window: `X-Total-Count`, `X-Total-Pages` and `pagination_meta` describe the rows the relation
|
|
12
|
+
returns (`max(0, min(limit, rows - offset))`) and the pages slice them. A "top 10, 3 per page" relation has a total
|
|
13
|
+
of 10, pages 1-3 hold 3 rows and page 4 holds 1; later pages are empty. Before, your LIMIT/OFFSET was replaced by the
|
|
14
|
+
page's own and stripped from the COUNT, so the total was the whole table and page 4+ served rows beyond your window.
|
|
15
|
+
A relation without `limit`/`offset` is unchanged. If you relied on the old reading (a LIMIT used as "a big batch"
|
|
16
|
+
while paging the whole table), drop the `.limit(...)`/`.offset(...)` before `paginated`. (PARAMS-S2)
|
|
17
|
+
- [ ] **CursorPaginatable, a relation you already limited or offset:** `cursor_paginated` / `cursor_pagination_meta`
|
|
18
|
+
raise `ArgumentError` for a relation carrying `limit`/`offset`. Before, the per_page + 1 probe replaced your LIMIT
|
|
19
|
+
and the walk ran to the end of the table (OFFSET has no meaning under a keyset WHERE). Drop them, or page the window
|
|
20
|
+
with Paginatable. (PARAMS-S2)
|
|
21
|
+
- [ ] **Internal constants removed:** `Searchable::EDGE_SPACE` and `Taggable::EDGE_SPACE` no longer exist (they were
|
|
22
|
+
never public API); `Normalizable::EDGE_SPACE` stays. Use `ConcernsOnRails::Support::EdgeSpace` if you referenced
|
|
23
|
+
them. (TRANS-S4)
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **Tokenizable / Hashable / Sequenceable / Sortable: a create halted by a later `before_create` (`throw :abort`)
|
|
28
|
+
inside a caller's transaction left the drawn value on the record.** `Support::GeneratedValues::Revertible` restored
|
|
29
|
+
the token/hash/sequence number/position only from `rolledback!`, which never runs when `save` returns false inside a
|
|
30
|
+
joined transaction (Rails swallows the Rollback). A retried `save` re-used a value that was never inserted; for
|
|
31
|
+
Sequenceable that is a number another row may have taken meanwhile, so a duplicate or `RecordNotUnique`. `Revertible`
|
|
32
|
+
now also restores the recorded values the moment the create callbacks halt, inside or outside a transaction.
|
|
33
|
+
(DATA-S2)
|
|
34
|
+
- **Paginatable / CursorPaginatable: a caller's LIMIT/OFFSET.** See Upgrading: Paginatable pages inside the window by
|
|
35
|
+
combining the windows arithmetically on the caller's own relation (no derived table, so `eager_load`/`includes`,
|
|
36
|
+
STI, `readonly` and `strict_loading` behave as in Rails); CursorPaginatable refuses it. (PARAMS-S2)
|
|
37
|
+
- **Normalizable / Searchable / Taggable / Monetizable: edge-whitespace stripping was ~1 microsecond per character.**
|
|
38
|
+
A long interior run of spaces made `gsub(/\A[[:space:]\0]+|[[:space:]\0]+\z/, "")` cost over 1 s of CPU per MB of
|
|
39
|
+
untrusted input (a 1 MB `"a" + " " * N + "b"` query parameter). Each edge is now stripped with one anchored match
|
|
40
|
+
through `Support::EdgeSpace`, linear and byte-identical in result. `TimeValue.names_year?` (verb time arguments such
|
|
41
|
+
as `expire!`/`publish_at!`) answers false for a String over 128 bytes before running its trailing-comment regex,
|
|
42
|
+
which `Date._parse` refuses anyway (the result was already false). (TRANS-S4)
|
|
43
|
+
|
|
44
|
+
### Known issues (not fixed in this release)
|
|
45
|
+
- CounterCacheable: a parent destroyed inside the same rolled-back transaction keeps the
|
|
46
|
+
mirrored counter/lock_version in memory.
|
|
47
|
+
- Aliasable: eager-loading an alias together with its source in one query loads each child twice.
|
|
48
|
+
- Encryptable: on the pre-7.1 Marshal format, a record whose field has a per-field Proc `key:`
|
|
49
|
+
cannot be `Marshal.dump`ed (`Rails.cache`).
|
|
50
|
+
- CursorPaginatable: on PostgreSQL, a page ending on a timestamp stored as `'infinity'` now mints
|
|
51
|
+
its cursor (it used to be a 500), but the next page answers 400 — the walk cannot step past it.
|
|
52
|
+
- Normalizable / Sanitizable: on an unvalidated (`update_attribute`) save that is halted and retried, with TWO
|
|
53
|
+
transforms on one column where the SECOND (not Normalizable) rewrites the first's output, Sanitizable's own record
|
|
54
|
+
holds only the first's output, so a validated retry re-sanitizes.
|
|
55
|
+
- Sluggable `history: true` + `scope:`: friendly_id's history finder ignores scope, so after a record moves scope its
|
|
56
|
+
stale history slug can still match `Model.where(scope).friendly.find(old_slug)`. This is stock friendly_id
|
|
57
|
+
behaviour; the gem does not change it. (GRAPH-5)
|
|
58
|
+
|
|
3
59
|
## 1.34.0 (2026-10-29)
|
|
4
60
|
|
|
5
61
|
A correctness release: it fixes the 43 bugs proven by the 2026-10-29 audit of 1.33.0. Some fixes change behaviour,
|
data/README.md
CHANGED
|
@@ -2041,7 +2041,7 @@ Render it straight into a `1 … 44 45 46 [47] 48 49 50 … 100` bar:
|
|
|
2041
2041
|
`?page=` past the last page windows around the last page (as `rel="prev"` already does), and
|
|
2042
2042
|
`window:` must be a non-negative Integer or `nil`/`false` — validated at declaration.
|
|
2043
2043
|
|
|
2044
|
-
A relation's total is a `COUNT` with `ORDER`/`LIMIT`/`OFFSET` stripped; a `DISTINCT` relation with its own `SELECT` list (`Membership.select(:group_id).distinct`, or DISTINCT written into the list: `select("DISTINCT category")`) is counted as a raw `SELECT COUNT(*) FROM (…)` subquery (STI-safe), so the total is the number of distinct rows it returns, not the underlying row count. MySQL alone rejects repeated output names in that subquery (`select("a.id, b.id")`); there the count is retried with the list re-aliased positionally, and a list that cannot be re-aliased (`table.*`) falls back to the plain over-count. A model class (`paginated(Article)`) is counted through `.all`.
|
|
2044
|
+
A relation's total is a `COUNT` with `ORDER`/`LIMIT`/`OFFSET` stripped, then bounded by the window the relation itself carries: `paginated(Player.order(score: :desc).limit(10))` pages a top 10 (total 10, so 3 per page gives 4 pages and page 5 is empty, never rows past the 10) — the pages offset and limit the caller's own relation, so `eager_load`/`includes`, STI and `readonly` keep working; a `DISTINCT` relation with its own `SELECT` list (`Membership.select(:group_id).distinct`, or DISTINCT written into the list: `select("DISTINCT category")`) is counted as a raw `SELECT COUNT(*) FROM (…)` subquery (STI-safe), so the total is the number of distinct rows it returns, not the underlying row count. MySQL alone rejects repeated output names in that subquery (`select("a.id, b.id")`); there the count is retried with the list re-aliased positionally, and a list that cannot be re-aliased (`table.*`) falls back to the plain over-count. A model class (`paginated(Article)`) is counted through `.all`.
|
|
2045
2045
|
|
|
2046
2046
|
**Response headers**: `X-Total-Count`, `X-Page`, `X-Per-Page`, `X-Total-Pages`, and an RFC 8288 `Link`
|
|
2047
2047
|
header with `first` / `prev` / `next` / `last` URLs rebuilt from the current request (other query params
|
|
@@ -2067,6 +2067,8 @@ class ArticlesController < ApplicationController
|
|
|
2067
2067
|
end
|
|
2068
2068
|
```
|
|
2069
2069
|
|
|
2070
|
+
A relation already carrying `limit`/`offset` is refused with an `ArgumentError`; page such a window with Paginatable.
|
|
2071
|
+
|
|
2070
2072
|
**URL params**
|
|
2071
2073
|
|
|
2072
2074
|
| Param | Default | Notes |
|
|
@@ -2871,7 +2873,7 @@ Point your agent at `llms.txt` for an overview, or paste a single concern's `.md
|
|
|
2871
2873
|
bundle install # install dev dependencies
|
|
2872
2874
|
bundle exec rspec # run the test suite (3,420 examples)
|
|
2873
2875
|
gem build concerns_on_rails.gemspec # build the gem
|
|
2874
|
-
gem install ./concerns_on_rails-1.
|
|
2876
|
+
gem install ./concerns_on_rails-1.35.0.gem # install locally
|
|
2875
2877
|
|
|
2876
2878
|
# Preview the docs site locally (GitHub Pages serves docs/ as-is):
|
|
2877
2879
|
cd docs && python3 -m http.server 8000 # → http://localhost:8000
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require "active_support/concern"
|
|
2
2
|
require "concerns_on_rails/support/error_envelope"
|
|
3
3
|
require "concerns_on_rails/support/scalar_param"
|
|
4
|
+
require "concerns_on_rails/support/caller_window"
|
|
4
5
|
require "concerns_on_rails/support/numeric_operand"
|
|
5
6
|
require "concerns_on_rails/support/time_value"
|
|
6
7
|
require "json"
|
|
@@ -320,6 +321,7 @@ module ConcernsOnRails
|
|
|
320
321
|
|
|
321
322
|
def cursor_paginate_result(relation, order:, per_page:, bidirectional: nil)
|
|
322
323
|
relation = relation.all if relation.is_a?(Class)
|
|
324
|
+
cursor_refuse_caller_window!(relation)
|
|
323
325
|
pairs = cursor_order_pairs(relation, order)
|
|
324
326
|
limit = cursor_per_page(per_page)
|
|
325
327
|
bidi = bidirectional.nil? ? self.class.cursor_paginatable_bidirectional : bidirectional
|
|
@@ -329,6 +331,18 @@ module ConcernsOnRails
|
|
|
329
331
|
meta: cursor_build_meta(relation.model, pairs, limit, fetch, bidi) }
|
|
330
332
|
end
|
|
331
333
|
|
|
334
|
+
# The keyset query's own `limit(per_page + 1)` would replace the
|
|
335
|
+
# caller's LIMIT and the walk would run past it to the end of the table
|
|
336
|
+
# (PARAMS-S2), and OFFSET has no meaning under a keyset WHERE. Refuse
|
|
337
|
+
# rather than walk rows the caller meant to exclude.
|
|
338
|
+
def cursor_refuse_caller_window!(relation)
|
|
339
|
+
return unless ConcernsOnRails::Support::CallerWindow.window?(relation)
|
|
340
|
+
|
|
341
|
+
raise ArgumentError,
|
|
342
|
+
"#{CursorPaginatable.name}: cursor pagination replaces the relation's LIMIT/OFFSET; " \
|
|
343
|
+
"drop them or use Paginatable"
|
|
344
|
+
end
|
|
345
|
+
|
|
332
346
|
# A backward ("prev") cursor walks the INVERTED ordering so LIMIT grabs
|
|
333
347
|
# the rows nearest the boundary, then flips the page back to canonical
|
|
334
348
|
# order. reorder (not order) so the keyset columns REPLACE any prior
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "active_support/concern"
|
|
2
2
|
require "concerns_on_rails/support/scalar_param"
|
|
3
|
+
require "concerns_on_rails/support/caller_window"
|
|
3
4
|
require "concerns_on_rails/support/link_header"
|
|
4
5
|
|
|
5
6
|
module ConcernsOnRails
|
|
@@ -196,7 +197,7 @@ module ConcernsOnRails
|
|
|
196
197
|
elsif source.is_a?(Array)
|
|
197
198
|
source[offset, per_page] || []
|
|
198
199
|
else
|
|
199
|
-
source
|
|
200
|
+
paginatable_page_relation(source, offset, per_page)
|
|
200
201
|
end
|
|
201
202
|
|
|
202
203
|
@paginatable_meta =
|
|
@@ -269,6 +270,13 @@ module ConcernsOnRails
|
|
|
269
270
|
return source.size if source.is_a?(Array)
|
|
270
271
|
|
|
271
272
|
relation = paginatable_count_relation(source)
|
|
273
|
+
total = paginatable_unwindowed_total(relation)
|
|
274
|
+
ConcernsOnRails::Support::CallerWindow.window?(relation) ? ConcernsOnRails::Support::CallerWindow.rows_in(total, relation) : total
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# The count with the caller's own LIMIT/OFFSET stripped (every path below
|
|
278
|
+
# does); paginatable_total then bounds it to the caller's window.
|
|
279
|
+
def paginatable_unwindowed_total(relation)
|
|
272
280
|
if paginatable_distinct_select?(relation) || paginatable_grouped_having_select?(relation)
|
|
273
281
|
return paginatable_distinct_select_total(relation)
|
|
274
282
|
end
|
|
@@ -284,6 +292,21 @@ module ConcernsOnRails
|
|
|
284
292
|
source.is_a?(Class) && source.respond_to?(:all) ? source.all : source
|
|
285
293
|
end
|
|
286
294
|
|
|
295
|
+
# A relation the caller already limited/offset is paged INSIDE that
|
|
296
|
+
# window (PARAMS-S2): its offset plus the page's, its limit cut to what
|
|
297
|
+
# is left of the window. Still the caller's own relation, so ORDER,
|
|
298
|
+
# eager loading, STI, readonly and quoting stay Rails'. A page past the
|
|
299
|
+
# window's end is empty (no query).
|
|
300
|
+
def paginatable_page_relation(source, offset, per_page)
|
|
301
|
+
relation = paginatable_count_relation(source)
|
|
302
|
+
return source.limit(per_page).offset(offset) unless ConcernsOnRails::Support::CallerWindow.window?(relation)
|
|
303
|
+
|
|
304
|
+
window_offset, limit = ConcernsOnRails::Support::CallerWindow.page_window(relation, offset, per_page)
|
|
305
|
+
return relation.none if limit.zero?
|
|
306
|
+
|
|
307
|
+
relation.limit(limit).offset(window_offset)
|
|
308
|
+
end
|
|
309
|
+
|
|
287
310
|
def paginatable_plain_count(relation)
|
|
288
311
|
counted = relation.except(:order, :limit, :offset, :select).count(:all)
|
|
289
312
|
counted.is_a?(Hash) ? counted.length : counted
|
|
@@ -6,6 +6,7 @@ require "active_support/core_ext/string/filters"
|
|
|
6
6
|
require "active_support/core_ext/string/inflections"
|
|
7
7
|
require "uri"
|
|
8
8
|
require "concerns_on_rails/support/column_guard"
|
|
9
|
+
require "concerns_on_rails/support/edge_space"
|
|
9
10
|
require "concerns_on_rails/support/transform_backstop"
|
|
10
11
|
|
|
11
12
|
module ConcernsOnRails
|
|
@@ -43,7 +44,8 @@ module ConcernsOnRails
|
|
|
43
44
|
# drops the suffix of "customer_id" outright.
|
|
44
45
|
TITLEIZE_WORD = /(?<![[:alnum:]'])[[:alpha:]]+/
|
|
45
46
|
# Leading/trailing Unicode whitespace (plus NUL, which String#strip also
|
|
46
|
-
# removed), for the stripping presets.
|
|
47
|
+
# removed), for the stripping presets. Kept for callers that match it
|
|
48
|
+
# themselves; stripping goes through Support::EdgeSpace (linear time).
|
|
47
49
|
EDGE_SPACE = /\A[[:space:]\0]+|[[:space:]\0]+\z/
|
|
48
50
|
|
|
49
51
|
# `:url` — strip, default the scheme to https://, lowercase the scheme and
|
|
@@ -85,7 +87,7 @@ module ConcernsOnRails
|
|
|
85
87
|
# [[:space:]]); :nullify_blank uses it too, so a value of nothing but
|
|
86
88
|
# no-break spaces is blank.
|
|
87
89
|
def self.strip(value)
|
|
88
|
-
|
|
90
|
+
Support::EdgeSpace.strip(value)
|
|
89
91
|
end
|
|
90
92
|
|
|
91
93
|
# `host=` also clears the userinfo on uri >= 1.1 (Ruby 3.2's bundled
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "active_support/concern"
|
|
2
2
|
require "concerns_on_rails/support/column_guard"
|
|
3
|
+
require "concerns_on_rails/support/edge_space"
|
|
3
4
|
|
|
4
5
|
module ConcernsOnRails
|
|
5
6
|
module Models
|
|
@@ -45,9 +46,6 @@ module ConcernsOnRails
|
|
|
45
46
|
|
|
46
47
|
LIKE_ESCAPE = "\\".freeze
|
|
47
48
|
LIKE_SPECIAL = /[\\%_]/
|
|
48
|
-
# Leading/trailing Unicode whitespace (plus NUL, which String#strip also
|
|
49
|
-
# removed) — Normalizable::EDGE_SPACE.
|
|
50
|
-
EDGE_SPACE = /\A[[:space:]\0]+|[[:space:]\0]+\z/
|
|
51
49
|
VALID_MODES = %i[any all].freeze
|
|
52
50
|
VALID_MATCHES = %i[contains prefix exact].freeze
|
|
53
51
|
# Which match tiers can be told apart under each match mode (best first).
|
|
@@ -120,7 +118,7 @@ module ConcernsOnRails
|
|
|
120
118
|
# Unicode-aware (String#strip / #split are ASCII-only): a no-break or
|
|
121
119
|
# an IME's ideographic space is whitespace too.
|
|
122
120
|
def search_terms(query)
|
|
123
|
-
text = query.to_s
|
|
121
|
+
text = Support::EdgeSpace.strip(query.to_s)
|
|
124
122
|
return [] if text.empty?
|
|
125
123
|
|
|
126
124
|
searchable_mode == :all ? text.split(/[[:space:]]+/) : [text]
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require "active_support/concern"
|
|
2
2
|
require "concerns_on_rails/support/batch_ops"
|
|
3
3
|
require "concerns_on_rails/support/column_guard"
|
|
4
|
+
require "concerns_on_rails/support/edge_space"
|
|
4
5
|
require "concerns_on_rails/support/transform_backstop"
|
|
5
6
|
|
|
6
7
|
module ConcernsOnRails
|
|
@@ -61,9 +62,6 @@ module ConcernsOnRails
|
|
|
61
62
|
# the escape character for us, so a backslash is portable here.
|
|
62
63
|
LIKE_ESCAPE = "\\".freeze
|
|
63
64
|
LIKE_SPECIAL = /[\\%_]/
|
|
64
|
-
# Leading/trailing Unicode whitespace (plus NUL, which String#strip also
|
|
65
|
-
# removed) — Normalizable::EDGE_SPACE.
|
|
66
|
-
EDGE_SPACE = /\A[[:space:]\0]+|[[:space:]\0]+\z/
|
|
67
65
|
|
|
68
66
|
included do
|
|
69
67
|
class_attribute :taggable_field, instance_accessor: false, default: DEFAULT_FIELD
|
|
@@ -207,7 +205,7 @@ module ConcernsOnRails
|
|
|
207
205
|
# kept as a distinct tag that tagged_with("ruby") never found, though
|
|
208
206
|
# it looked identical in all_tags.
|
|
209
207
|
def taggable_clean(tag)
|
|
210
|
-
tag = taggable_trim_straddle(tag.to_s
|
|
208
|
+
tag = taggable_trim_straddle(Support::EdgeSpace.strip(tag.to_s))
|
|
211
209
|
taggable_downcase ? tag.downcase : tag
|
|
212
210
|
end
|
|
213
211
|
|
|
@@ -236,7 +234,7 @@ module ConcernsOnRails
|
|
|
236
234
|
tag = tag[0, tag.length - trailing.length] if trailing
|
|
237
235
|
leading = suffixes.find { |suffix| tag.start_with?(suffix) }
|
|
238
236
|
tag = tag[leading.length..] if leading
|
|
239
|
-
|
|
237
|
+
Support::EdgeSpace.strip(tag)
|
|
240
238
|
end
|
|
241
239
|
|
|
242
240
|
# For each period k of the delimiter d (d == d[0, k] + d[k..], i.e.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module ConcernsOnRails
|
|
2
|
+
module Support
|
|
3
|
+
# A relation the caller already LIMITed and/or OFFSET (`Player.order(:score).limit(10)`)
|
|
4
|
+
# is a window: Paginatable pages INSIDE it (PARAMS-S2) instead of replacing
|
|
5
|
+
# its LIMIT with the page's own. The two windows are combined arithmetically
|
|
6
|
+
# on the caller's own relation, so ORDER, eager loading, STI, readonly and
|
|
7
|
+
# identifier quoting stay exactly as Rails builds them.
|
|
8
|
+
module CallerWindow
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# True for a relation carrying a limit or a non-zero offset. A model
|
|
12
|
+
# class, an Array and a duck-typed source have no window.
|
|
13
|
+
def window?(relation)
|
|
14
|
+
return false unless %i[limit_value offset_value].all? { |reader| relation.respond_to?(reader) }
|
|
15
|
+
|
|
16
|
+
!relation.limit_value.nil? || offset_of(relation).positive?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def offset_of(relation)
|
|
20
|
+
relation.offset_value.to_i
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Rows inside the window, given the unwindowed row count: max(0, min(L, N - O)).
|
|
24
|
+
def rows_in(total, relation)
|
|
25
|
+
rows = total - offset_of(relation)
|
|
26
|
+
rows = [rows, relation.limit_value.to_i].min unless relation.limit_value.nil?
|
|
27
|
+
[rows, 0].max
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# [offset, limit] for the page starting `page_offset` rows into the
|
|
31
|
+
# window. A limit of 0 is a page past the window's end.
|
|
32
|
+
def page_window(relation, page_offset, per_page)
|
|
33
|
+
limit = per_page
|
|
34
|
+
limit = (relation.limit_value.to_i - page_offset).clamp(0, per_page) unless relation.limit_value.nil?
|
|
35
|
+
[offset_of(relation) + page_offset, limit]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module ConcernsOnRails
|
|
2
|
+
module Support
|
|
3
|
+
# Strips Unicode whitespace (and NUL, which String#strip also removed) from
|
|
4
|
+
# both ends of a String — String#strip is ASCII-only, so a pasted no-break
|
|
5
|
+
# or ideographic space survived it. Shared by Normalizable, Searchable,
|
|
6
|
+
# Taggable and Money.
|
|
7
|
+
#
|
|
8
|
+
# The one-regex form (`gsub(/\A[[:space:]\0]+|[[:space:]\0]+\z/, "")`) had
|
|
9
|
+
# to start a `[[:space:]]+\z` attempt at every position of an interior run
|
|
10
|
+
# of spaces. Ruby 3.2's match cache keeps that from going quadratic, but
|
|
11
|
+
# at about a microsecond per character: a 1 MB `"a" + " " * N + "b"` query
|
|
12
|
+
# parameter cost ~1 s of CPU, and 3 MB of `"a b" * N` over 2 s. Two
|
|
13
|
+
# anchored `\A` runs (the end is reached by reversing the String, whose
|
|
14
|
+
# whitespace characters are all single code points) match each edge in one
|
|
15
|
+
# pass and stop at the first non-space character.
|
|
16
|
+
module EdgeSpace
|
|
17
|
+
LEADING = /\A[[:space:]\0]+/
|
|
18
|
+
LEADING_NO_NUL = /\A[[:space:]]+/
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# A new String, like gsub returned. Raises ArgumentError on invalid byte
|
|
23
|
+
# sequences, as the regex form did. `nul: false` leaves NUL alone
|
|
24
|
+
# (Money's reader never stripped it).
|
|
25
|
+
def strip(string, nul: true)
|
|
26
|
+
edge = nul ? LEADING : LEADING_NO_NUL
|
|
27
|
+
string.sub(edge, "").reverse.sub(edge, "").reverse
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -36,6 +36,10 @@ module ConcernsOnRails
|
|
|
36
36
|
# each column's pre-create value on the record, and `Revertible` — included
|
|
37
37
|
# by every producer — puts them back when the create rolls back.
|
|
38
38
|
#
|
|
39
|
+
# A create HALTED by a later before_create (`throw :abort`) puts them back
|
|
40
|
+
# at the halt itself: inside a caller's transaction Rails swallows the
|
|
41
|
+
# Rollback it raises for the false `save`, so `rolledback!` never runs.
|
|
42
|
+
#
|
|
39
43
|
# Every hook is idempotent — it re-derives, it never appends — so two
|
|
40
44
|
# producers reporting in one create (a token and a number) are safe. The
|
|
41
45
|
# hooks are NOT free outside a save (friendly_id's slug-availability
|
|
@@ -81,6 +85,18 @@ module ConcernsOnRails
|
|
|
81
85
|
|
|
82
86
|
def generated_values_enrolled; end
|
|
83
87
|
|
|
88
|
+
# A before_create that halts (`throw :abort`) after a producer drew its
|
|
89
|
+
# value makes the create callbacks answer false. `save` then raises
|
|
90
|
+
# Rollback, but inside a caller's transaction that Rollback is swallowed
|
|
91
|
+
# by the JOINED transaction and `rolledback!` never runs — so the
|
|
92
|
+
# restore happens here, at the halt, inside or outside a transaction.
|
|
93
|
+
# A real rollback still reverts through `rolledback!`.
|
|
94
|
+
def _create_record(*, **, &)
|
|
95
|
+
result = super
|
|
96
|
+
generated_values_revert! if result == false
|
|
97
|
+
result
|
|
98
|
+
end
|
|
99
|
+
|
|
84
100
|
# Puts back the columns a create path generated, while the record is a
|
|
85
101
|
# new record again. A column the caller has changed since (no longer
|
|
86
102
|
# the generated value) is left alone; a persisted record keeps what it
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "bigdecimal"
|
|
2
|
+
require "concerns_on_rails/support/edge_space"
|
|
2
3
|
|
|
3
4
|
module ConcernsOnRails
|
|
4
5
|
module Support
|
|
@@ -238,7 +239,7 @@ module ConcernsOnRails
|
|
|
238
239
|
|
|
239
240
|
# String#strip misses Unicode whitespace such as a no-break space.
|
|
240
241
|
def strip_space(string)
|
|
241
|
-
|
|
242
|
+
EdgeSpace.strip(string, nul: false)
|
|
242
243
|
end
|
|
243
244
|
|
|
244
245
|
# Subunits to a BigDecimal amount. A grouped relation's aggregate is a
|
|
@@ -55,6 +55,11 @@ module ConcernsOnRails
|
|
|
55
55
|
# JavaScript Date#toString's "(Eastern Daylight Time)", RFC 9557's
|
|
56
56
|
# "[America/New_York]".
|
|
57
57
|
TRAILING_COMMENTS = /(?:\s*(?:\([^()]*\)|\[[^\[\]]*\]))+\s*\z/
|
|
58
|
+
# Date._parse refuses a String longer than this many BYTES (its `limit:`
|
|
59
|
+
# default). names_year? answers false for such a String before it runs
|
|
60
|
+
# TRAILING_COMMENTS, whose match cost grows with the input (about a
|
|
61
|
+
# microsecond per character, seconds for a few MB).
|
|
62
|
+
DATE_PARSE_LIMIT = 128
|
|
58
63
|
|
|
59
64
|
module_function
|
|
60
65
|
|
|
@@ -147,6 +152,7 @@ module ConcernsOnRails
|
|
|
147
152
|
# ActiveRecord does.
|
|
148
153
|
def names_year?(value)
|
|
149
154
|
return true unless value.is_a?(::String)
|
|
155
|
+
return false if value.bytesize > DATE_PARSE_LIMIT
|
|
150
156
|
|
|
151
157
|
bare = value.sub(TRAILING_COMMENTS, "")
|
|
152
158
|
return false if bare.match?(/[()\[\]]/)
|
data/lib/concerns_on_rails.rb
CHANGED
|
@@ -66,6 +66,7 @@ module ConcernsOnRails
|
|
|
66
66
|
module Support
|
|
67
67
|
autoload :ColumnGuard, "concerns_on_rails/support/column_guard"
|
|
68
68
|
autoload :IncludeTree, "concerns_on_rails/support/include_tree"
|
|
69
|
+
autoload :CallerWindow, "concerns_on_rails/support/caller_window"
|
|
69
70
|
autoload :ScalarParam, "concerns_on_rails/support/scalar_param"
|
|
70
71
|
autoload :UniqueRetry, "concerns_on_rails/support/unique_retry"
|
|
71
72
|
autoload :ErrorEnvelope, "concerns_on_rails/support/error_envelope"
|
|
@@ -87,6 +88,7 @@ module ConcernsOnRails
|
|
|
87
88
|
autoload :VaryHeader, "concerns_on_rails/support/vary_header"
|
|
88
89
|
autoload :NumericOperand, "concerns_on_rails/support/numeric_operand"
|
|
89
90
|
autoload :TimeValue, "concerns_on_rails/support/time_value"
|
|
91
|
+
autoload :EdgeSpace, "concerns_on_rails/support/edge_space"
|
|
90
92
|
autoload :NativeJson, "concerns_on_rails/support/native_json"
|
|
91
93
|
autoload :Callable, "concerns_on_rails/support/callable"
|
|
92
94
|
autoload :SlugSources, "concerns_on_rails/support/slug_sources"
|
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.35.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-11-
|
|
11
|
+
date: 2026-11-10 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: actionpack
|
|
@@ -190,8 +190,10 @@ files:
|
|
|
190
190
|
- lib/concerns_on_rails/support/association_scope.rb
|
|
191
191
|
- lib/concerns_on_rails/support/batch_ops.rb
|
|
192
192
|
- lib/concerns_on_rails/support/callable.rb
|
|
193
|
+
- lib/concerns_on_rails/support/caller_window.rb
|
|
193
194
|
- lib/concerns_on_rails/support/column_guard.rb
|
|
194
195
|
- lib/concerns_on_rails/support/contextual_destroy.rb
|
|
196
|
+
- lib/concerns_on_rails/support/edge_space.rb
|
|
195
197
|
- lib/concerns_on_rails/support/encrypted_lookup.rb
|
|
196
198
|
- lib/concerns_on_rails/support/encryptor.rb
|
|
197
199
|
- lib/concerns_on_rails/support/error_envelope.rb
|