concerns_on_rails 1.14.1 → 1.15.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 +36 -0
- data/lib/concerns_on_rails/controllers/authorizable.rb +7 -3
- data/lib/concerns_on_rails/controllers/error_handleable.rb +5 -2
- data/lib/concerns_on_rails/controllers/filterable.rb +15 -1
- data/lib/concerns_on_rails/controllers/localizable.rb +24 -2
- data/lib/concerns_on_rails/controllers/paginatable.rb +19 -1
- data/lib/concerns_on_rails/controllers/secure_headable.rb +7 -5
- data/lib/concerns_on_rails/controllers/sortable.rb +16 -10
- data/lib/concerns_on_rails/models/activatable.rb +14 -4
- data/lib/concerns_on_rails/models/expirable.rb +26 -18
- data/lib/concerns_on_rails/models/hashable.rb +30 -3
- data/lib/concerns_on_rails/models/monetizable.rb +4 -0
- data/lib/concerns_on_rails/models/normalizable.rb +4 -0
- data/lib/concerns_on_rails/models/publishable.rb +52 -10
- data/lib/concerns_on_rails/models/searchable.rb +3 -0
- data/lib/concerns_on_rails/models/sluggable.rb +15 -1
- data/lib/concerns_on_rails/models/soft_deletable.rb +10 -2
- data/lib/concerns_on_rails/models/stateable.rb +11 -4
- data/lib/concerns_on_rails/models/taggable.rb +7 -3
- data/lib/concerns_on_rails/models/tokenizable.rb +8 -1
- data/lib/concerns_on_rails/support/money.rb +4 -1
- data/lib/concerns_on_rails/support/sequence_calculator.rb +8 -1
- data/lib/concerns_on_rails/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9d0ca1b10759cf285948bef864e3cba343b3d1caa5e461b000af0486b4d51781
|
|
4
|
+
data.tar.gz: 1006768d58216c32a190c551ede050eb2e12ac24d13daf847600e83d1f219f52
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7b8c99595c6fbdd34ba4eb2ed011dee168c6178cffbee5bd1803195e83d5e2301bb87db99bc2dbf1143ba6d5e584f708d44aefbbea13248af2bc0d6eba0772d2
|
|
7
|
+
data.tar.gz: b43057a24fd64599ee62ef31dcbddd0daf3b985193a36dd02811c17721ebebf0e73f5793c73ffb41c98524ca9b5bec1a100c9c3619ccf8ceb896ce5ce11051cc
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 1.15.0 (2026-06-10)
|
|
4
|
+
|
|
5
|
+
A review-driven release: 23 correctness/safety fixes (each with a regression spec) and 7 backward-compatible enhancements. 510 examples, 0 failures.
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **Controllers::SecureHeadable**: `content_security_policy_for(report_only: true)` no longer silently drops the policy block — the policy is defined via `content_security_policy` and report-only mode is toggled separately (a report-only rollout previously registered no policy at all).
|
|
9
|
+
- **Controllers::Authorizable**: `require_role` / actor resolution now find a private or `helper_method` `current_user` (`respond_to?(.., true)`), so Devise-style private `current_user` is no longer denied.
|
|
10
|
+
- **Controllers::Sortable**: uses `reorder` so the requested sort replaces a model `default_scope` ORDER BY instead of becoming a silent secondary key.
|
|
11
|
+
- **Controllers::Paginatable**: the total count no longer breaks on grouped relations (it returned a Hash); it collapses to the group count.
|
|
12
|
+
- **Controllers::Filterable**: a nested-hash param in direct-where mode no longer raises a user-triggerable 500 — non-scalar values are ignored.
|
|
13
|
+
- **Controllers::Localizable**: the `Accept-Language` parser honors q-values (rejects `q=0`, orders by preference) per RFC 7231.
|
|
14
|
+
- **Controllers::ErrorHandleable**: the 404 handler renders a generic message instead of the raw `RecordNotFound` message, which leaked the model class name and queried attribute/value.
|
|
15
|
+
- **Models::SoftDeletable**: `deleted_within` uses an explicit `>=` predicate (the previous endless range was unsupported on Rails 5.x); `soft_delete_all` / `restore_all` roll the whole batch back when a record fails.
|
|
16
|
+
- **Models::Sluggable**: backfills a blank slug on save even when the source is unchanged, and no longer overwrites an explicitly-assigned slug.
|
|
17
|
+
- **Models::Publishable**: scopes branch on the column type so a boolean publishable column uses equality predicates instead of nonsensical timestamp comparisons.
|
|
18
|
+
- **Models::Hashable**: validates that `length` is positive; `:integer` drops dead padding code.
|
|
19
|
+
- **Models::Taggable**: `tagged_with` matches consistently (all branches use LIKE) and escapes the delimiter so a wildcard delimiter matches literally.
|
|
20
|
+
- **Models::Monetizable** / **Support::Money**: no spurious `-` for amounts that round to zero; `subunit_to_unit: 0` is rejected.
|
|
21
|
+
- **Models::Sequenceable**: the generation-time clock is memoized so a record's period anchor stays consistent (no boundary straddle).
|
|
22
|
+
- **gemspec**: `spec.metadata` is merged rather than reassigned, preserving the `license` key.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- **Models::Activatable** / **Models::Expirable**: `prefix:` / `suffix:` options to affix scope names, so `.active` / `.expired` can coexist with the same-named scopes from sibling concerns on one model.
|
|
26
|
+
- **Models::Publishable**: `before/after_publish` and `before/after_unpublish` lifecycle hooks.
|
|
27
|
+
- **Models::Stateable**: `before/after_transition` hooks fired by guarded `<event>!` transitions.
|
|
28
|
+
- **Models::Hashable**: `unique: true` retries on an in-Ruby collision before insert (parity with Tokenizable).
|
|
29
|
+
- **Controllers::Sortable**: applies multiple whitelisted columns from a comma-separated `params[:sort]`.
|
|
30
|
+
- **Controllers::Paginatable**: `pagination_meta(relation)` for body-based pagination (composes with `Respondable`'s `meta:`).
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
- **Controllers::ErrorHandleable**: the default 404 message is now generic (`"Resource not found"`); override `handle_record_not_found` to surface detail in non-production environments.
|
|
34
|
+
|
|
35
|
+
### Docs
|
|
36
|
+
- Rewrote `CLAUDE.md` to cover all 29 concerns and 7 support modules, the model/controller layout, the macro conventions, the supported dependency ranges, and the release process.
|
|
37
|
+
- Noted native Rails 7.1+ alternatives (`normalizes`, `generates_token_for`) in `Normalizable` / `Tokenizable`.
|
|
38
|
+
|
|
3
39
|
## 1.14.1 (2026-06-07)
|
|
4
40
|
|
|
5
41
|
### Fixed
|
|
@@ -50,8 +50,11 @@ module ConcernsOnRails
|
|
|
50
50
|
|
|
51
51
|
wanted = roles.map(&:to_s)
|
|
52
52
|
check = proc do
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
# respond_to?(via, true): current_user is usually private (Devise) or
|
|
54
|
+
# a helper_method (which keeps it private on the instance), so the
|
|
55
|
+
# default public-only check would resolve nil and deny everyone.
|
|
56
|
+
actor = respond_to?(via, true) ? send(via) : nil
|
|
57
|
+
actor.respond_to?(role_method, true) && wanted.include?(actor.send(role_method).to_s)
|
|
55
58
|
end
|
|
56
59
|
add_authorization_rule(check: check, only: only, except: except, status: status, message: message)
|
|
57
60
|
end
|
|
@@ -113,7 +116,8 @@ module ConcernsOnRails
|
|
|
113
116
|
end
|
|
114
117
|
|
|
115
118
|
def authorization_actor
|
|
116
|
-
|
|
119
|
+
# include_private: true — current_user is typically private/helper_method.
|
|
120
|
+
respond_to?(:current_user, true) ? current_user : nil
|
|
117
121
|
end
|
|
118
122
|
|
|
119
123
|
def authorization_action_name
|
|
@@ -30,9 +30,12 @@ module ConcernsOnRails
|
|
|
30
30
|
rescue_from "ActiveRecord::RecordInvalid", with: :handle_record_invalid
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
def handle_record_not_found(
|
|
33
|
+
def handle_record_not_found(_error)
|
|
34
|
+
# Use a generic message: the raw RecordNotFound message leaks the model
|
|
35
|
+
# class name and the queried attribute/value to API clients. Subclasses
|
|
36
|
+
# can override this method to surface detail in non-production envs.
|
|
34
37
|
render_error_envelope(
|
|
35
|
-
message:
|
|
38
|
+
message: "Resource not found",
|
|
36
39
|
code: "not_found",
|
|
37
40
|
status: :not_found
|
|
38
41
|
)
|
|
@@ -60,10 +60,24 @@ module ConcernsOnRails
|
|
|
60
60
|
options[:with].call(relation, value)
|
|
61
61
|
elsif options[:scope]
|
|
62
62
|
relation.public_send(options[:scope])
|
|
63
|
-
|
|
63
|
+
elsif filterable_scalar?(value)
|
|
64
64
|
relation.where(field => value)
|
|
65
|
+
else
|
|
66
|
+
# A nested/structured param (e.g. ?status[gt]=5) in direct-where mode
|
|
67
|
+
# would raise TypeError ("can't quote Hash") and surface as a 500.
|
|
68
|
+
# Ignore it instead — hash/array shaping must go through a `with:` lambda.
|
|
69
|
+
relation
|
|
65
70
|
end
|
|
66
71
|
end
|
|
72
|
+
|
|
73
|
+
# Scalars (and arrays, which AR turns into `IN (...)`) are safe to pass to
|
|
74
|
+
# .where; a Hash / ActionController::Parameters is not.
|
|
75
|
+
def filterable_scalar?(value)
|
|
76
|
+
return false if value.is_a?(Hash)
|
|
77
|
+
return false if defined?(ActionController::Parameters) && value.is_a?(ActionController::Parameters)
|
|
78
|
+
|
|
79
|
+
true
|
|
80
|
+
end
|
|
67
81
|
end
|
|
68
82
|
end
|
|
69
83
|
end
|
|
@@ -77,14 +77,36 @@ module ConcernsOnRails
|
|
|
77
77
|
end
|
|
78
78
|
|
|
79
79
|
def parse_accept_language(header, allowed)
|
|
80
|
-
header
|
|
81
|
-
lang = part.split(";").first.to_s.strip.split("-").first
|
|
80
|
+
ranked_accept_languages(header).each do |lang|
|
|
82
81
|
match = match_locale(lang, allowed)
|
|
83
82
|
return match if match
|
|
84
83
|
end
|
|
85
84
|
nil
|
|
86
85
|
end
|
|
87
86
|
|
|
87
|
+
# Languages from an Accept-Language header, q=0 dropped, highest-q first
|
|
88
|
+
# (RFC 7231 preference order).
|
|
89
|
+
def ranked_accept_languages(header)
|
|
90
|
+
pairs = header.split(",").filter_map do |part|
|
|
91
|
+
token, *params = part.split(";").map(&:strip)
|
|
92
|
+
quality = accept_language_quality(params)
|
|
93
|
+
next if quality <= 0.0
|
|
94
|
+
|
|
95
|
+
lang = token.to_s.split("-").first
|
|
96
|
+
[lang, quality] if lang.present?
|
|
97
|
+
end
|
|
98
|
+
pairs.sort_by { |(_lang, quality)| -quality }.map(&:first)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The q-value (relative quality) of an Accept-Language part: 1.0 when
|
|
102
|
+
# absent, 0.0 when malformed. q=0 means "not acceptable" and is dropped.
|
|
103
|
+
def accept_language_quality(params)
|
|
104
|
+
qparam = params.find { |p| p.start_with?("q=") }
|
|
105
|
+
return 1.0 unless qparam
|
|
106
|
+
|
|
107
|
+
Float(qparam[2..], exception: false) || 0.0
|
|
108
|
+
end
|
|
109
|
+
|
|
88
110
|
def match_locale(candidate, allowed)
|
|
89
111
|
return nil if candidate.blank?
|
|
90
112
|
|
|
@@ -41,7 +41,10 @@ module ConcernsOnRails
|
|
|
41
41
|
per_page = pagination_per_page
|
|
42
42
|
offset = (page - 1) * per_page
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
counted = relation.except(:order, :limit, :offset).count
|
|
45
|
+
# `.count` on a grouped relation returns a Hash (group => count); for
|
|
46
|
+
# offset pagination the meaningful total is the number of groups.
|
|
47
|
+
total = counted.is_a?(Hash) ? counted.length : counted
|
|
45
48
|
total_pages = per_page.positive? ? (total.to_f / per_page).ceil : 0
|
|
46
49
|
|
|
47
50
|
records = relation.limit(per_page).offset(offset)
|
|
@@ -50,6 +53,21 @@ module ConcernsOnRails
|
|
|
50
53
|
records
|
|
51
54
|
end
|
|
52
55
|
|
|
56
|
+
# Pagination metadata for a relation WITHOUT applying limit/offset — handy
|
|
57
|
+
# for body-based pagination (compose with Respondable's `meta:`).
|
|
58
|
+
def pagination_meta(relation)
|
|
59
|
+
page = pagination_page
|
|
60
|
+
per_page = pagination_per_page
|
|
61
|
+
counted = relation.except(:order, :limit, :offset).count
|
|
62
|
+
total = counted.is_a?(Hash) ? counted.length : counted
|
|
63
|
+
{
|
|
64
|
+
total: total,
|
|
65
|
+
page: page,
|
|
66
|
+
per_page: per_page,
|
|
67
|
+
total_pages: per_page.positive? ? (total.to_f / per_page).ceil : 0
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
53
71
|
private
|
|
54
72
|
|
|
55
73
|
def pagination_page
|
|
@@ -85,11 +85,13 @@ module ConcernsOnRails
|
|
|
85
85
|
"ActionController::ContentSecurityPolicy (Rails 5.2+)"
|
|
86
86
|
end
|
|
87
87
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
88
|
+
# The policy block is ONLY accepted by content_security_policy; the
|
|
89
|
+
# report-only variant is a flag toggle that takes no block. So always
|
|
90
|
+
# define the policy via content_security_policy, then additionally mark
|
|
91
|
+
# it report-only when requested — otherwise a report-only rollout would
|
|
92
|
+
# silently register no policy at all (the block would be dropped).
|
|
93
|
+
content_security_policy(**action_opts, &block)
|
|
94
|
+
content_security_policy_report_only(true, **action_opts) if report_only
|
|
93
95
|
end
|
|
94
96
|
end
|
|
95
97
|
|
|
@@ -44,21 +44,27 @@ module ConcernsOnRails
|
|
|
44
44
|
# Apply ordering to a relation based on params[:sort] / params[:direction].
|
|
45
45
|
# Falls back to defaults; never orders by a non-whitelisted column.
|
|
46
46
|
def sorted(relation)
|
|
47
|
-
|
|
48
|
-
return relation
|
|
47
|
+
fields = sort_fields
|
|
48
|
+
return relation if fields.empty?
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
# reorder (not order) so the user-requested columns REPLACE any prior
|
|
51
|
+
# ORDER BY — including a model default_scope order. Multiple whitelisted
|
|
52
|
+
# columns (comma-separated in params[:sort]) are applied in request order.
|
|
53
|
+
direction = sort_direction
|
|
54
|
+
ordering = fields.to_h { |field| [field, direction] }
|
|
55
|
+
relation.reorder(ordering)
|
|
51
56
|
end
|
|
52
57
|
|
|
53
58
|
private
|
|
54
59
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
# Whitelisted sort columns from params[:sort] (comma-separated), preserving
|
|
61
|
+
# request order; falls back to the configured default when none are valid.
|
|
62
|
+
def sort_fields
|
|
63
|
+
requested = params[:sort].to_s.split(",").map { |token| token.strip.to_sym }
|
|
64
|
+
allowed = requested & self.class.sortable_allowed_fields
|
|
65
|
+
return allowed unless allowed.empty?
|
|
66
|
+
|
|
67
|
+
Array(self.class.sortable_default_field).compact
|
|
62
68
|
end
|
|
63
69
|
|
|
64
70
|
def sort_direction
|
|
@@ -30,12 +30,20 @@ module ConcernsOnRails
|
|
|
30
30
|
class_methods do
|
|
31
31
|
include ConcernsOnRails::Support::ColumnGuard
|
|
32
32
|
|
|
33
|
-
def activatable_by(field = DEFAULT_FIELD)
|
|
33
|
+
def activatable_by(field = DEFAULT_FIELD, prefix: nil, suffix: nil)
|
|
34
34
|
self.activatable_field = field.to_sym
|
|
35
35
|
ensure_columns!("ConcernsOnRails::Models::Activatable", activatable_field)
|
|
36
36
|
|
|
37
|
-
scope
|
|
38
|
-
|
|
37
|
+
# Affix the scope names so two concerns that each define `.active`
|
|
38
|
+
# (e.g. SoftDeletable / Expirable) can coexist on one model.
|
|
39
|
+
scope activatable_scope_name(:active, prefix, suffix), -> { where(activatable_field => true) }
|
|
40
|
+
scope activatable_scope_name(:inactive, prefix, suffix), -> { where(activatable_field => [false, nil]) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def activatable_scope_name(base, prefix, suffix)
|
|
46
|
+
[prefix, base, suffix].compact.join("_").to_sym
|
|
39
47
|
end
|
|
40
48
|
end
|
|
41
49
|
|
|
@@ -56,7 +64,9 @@ module ConcernsOnRails
|
|
|
56
64
|
end
|
|
57
65
|
|
|
58
66
|
def toggle_active!
|
|
59
|
-
|
|
67
|
+
# Lock the row for the read-modify-write so concurrent toggles don't lose
|
|
68
|
+
# an update (with_lock wraps a transaction + SELECT ... FOR UPDATE).
|
|
69
|
+
with_lock { active? ? deactivate! : activate! }
|
|
60
70
|
end
|
|
61
71
|
end
|
|
62
72
|
end
|
|
@@ -9,21 +9,6 @@ module ConcernsOnRails
|
|
|
9
9
|
|
|
10
10
|
included do
|
|
11
11
|
class_attribute :expirable_field, instance_accessor: false, default: DEFAULT_FIELD
|
|
12
|
-
|
|
13
|
-
scope :active, lambda {
|
|
14
|
-
column = arel_table[expirable_field]
|
|
15
|
-
where(column.eq(nil).or(column.gt(Time.zone.now)))
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
scope :expired, lambda {
|
|
19
|
-
where(arel_table[expirable_field].lteq(Time.zone.now))
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
scope :expiring_within, lambda { |duration|
|
|
23
|
-
column = arel_table[expirable_field]
|
|
24
|
-
now = Time.zone.now
|
|
25
|
-
where(column.gt(now)).where(column.lteq(now + duration))
|
|
26
|
-
}
|
|
27
12
|
end
|
|
28
13
|
|
|
29
14
|
class_methods do
|
|
@@ -33,9 +18,34 @@ module ConcernsOnRails
|
|
|
33
18
|
# Example:
|
|
34
19
|
# expirable_by # uses :expires_at
|
|
35
20
|
# expirable_by :valid_until
|
|
36
|
-
def expirable_by(field = DEFAULT_FIELD)
|
|
21
|
+
def expirable_by(field = DEFAULT_FIELD, prefix: nil, suffix: nil)
|
|
37
22
|
self.expirable_field = field.to_sym
|
|
38
23
|
ensure_columns!("ConcernsOnRails::Models::Expirable", expirable_field)
|
|
24
|
+
define_expirable_scopes(prefix, suffix)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# Scopes live here (not in `included do`) so their names can be affixed —
|
|
30
|
+
# letting Expirable's `.active`/`.expired` coexist with the same-named
|
|
31
|
+
# scopes from SoftDeletable / Activatable on a single model.
|
|
32
|
+
def define_expirable_scopes(prefix, suffix)
|
|
33
|
+
scope expirable_scope_name(:active, prefix, suffix), lambda {
|
|
34
|
+
column = arel_table[expirable_field]
|
|
35
|
+
where(column.eq(nil).or(column.gt(Time.zone.now)))
|
|
36
|
+
}
|
|
37
|
+
scope expirable_scope_name(:expired, prefix, suffix), lambda {
|
|
38
|
+
where(arel_table[expirable_field].lteq(Time.zone.now))
|
|
39
|
+
}
|
|
40
|
+
scope expirable_scope_name(:expiring_within, prefix, suffix), lambda { |duration|
|
|
41
|
+
column = arel_table[expirable_field]
|
|
42
|
+
now = Time.zone.now
|
|
43
|
+
where(column.gt(now)).where(column.lteq(now + duration))
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def expirable_scope_name(base, prefix, suffix)
|
|
48
|
+
[prefix, base, suffix].compact.join("_").to_sym
|
|
39
49
|
end
|
|
40
50
|
end
|
|
41
51
|
|
|
@@ -74,8 +84,6 @@ module ConcernsOnRails
|
|
|
74
84
|
(value - now).seconds
|
|
75
85
|
end
|
|
76
86
|
|
|
77
|
-
private
|
|
78
|
-
|
|
79
87
|
def expiry_extension_base
|
|
80
88
|
value = self[self.class.expirable_field]
|
|
81
89
|
now = Time.zone.now
|
|
@@ -7,12 +7,14 @@ module ConcernsOnRails
|
|
|
7
7
|
extend ActiveSupport::Concern
|
|
8
8
|
|
|
9
9
|
VALID_TYPES = %i[hex uuid integer custom].freeze
|
|
10
|
+
MAX_GENERATION_ATTEMPTS = 10
|
|
10
11
|
|
|
11
12
|
included do
|
|
12
13
|
class_attribute :hashable_field, instance_accessor: false
|
|
13
14
|
class_attribute :hashable_type, instance_accessor: false, default: :hex
|
|
14
15
|
class_attribute :hashable_length, instance_accessor: false, default: 16
|
|
15
16
|
class_attribute :hashable_alphabet, instance_accessor: false, default: nil
|
|
17
|
+
class_attribute :hashable_unique, instance_accessor: false, default: false
|
|
16
18
|
end
|
|
17
19
|
|
|
18
20
|
class_methods do
|
|
@@ -25,11 +27,12 @@ module ConcernsOnRails
|
|
|
25
27
|
# hashable_by :external_id, type: :uuid
|
|
26
28
|
# hashable_by :code, type: :integer, length: 6
|
|
27
29
|
# hashable_by :code, type: :custom, length: 8, alphabet: "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
|
28
|
-
def hashable_by(field, type: :hex, length: 16, alphabet: nil)
|
|
30
|
+
def hashable_by(field, type: :hex, length: 16, alphabet: nil, unique: false)
|
|
29
31
|
self.hashable_field = field.to_sym
|
|
30
32
|
self.hashable_type = type.to_sym
|
|
31
33
|
self.hashable_length = length.to_i
|
|
32
34
|
self.hashable_alphabet = alphabet
|
|
35
|
+
self.hashable_unique = unique
|
|
33
36
|
|
|
34
37
|
ensure_columns!("ConcernsOnRails::Models::Hashable", hashable_field)
|
|
35
38
|
validate_hashable_options!
|
|
@@ -47,7 +50,7 @@ module ConcernsOnRails
|
|
|
47
50
|
case hashable_type
|
|
48
51
|
when :hex then SecureRandom.hex(hashable_length)
|
|
49
52
|
when :uuid then SecureRandom.uuid
|
|
50
|
-
when :integer then SecureRandom.random_number(10**hashable_length)
|
|
53
|
+
when :integer then SecureRandom.random_number(10**hashable_length)
|
|
51
54
|
when :custom then ConcernsOnRails::Support::RandomValue.from_alphabet(hashable_alphabet, hashable_length)
|
|
52
55
|
end
|
|
53
56
|
end
|
|
@@ -60,10 +63,19 @@ module ConcernsOnRails
|
|
|
60
63
|
"ConcernsOnRails::Models::Hashable: unknown type '#{hashable_type}'. Valid types: #{VALID_TYPES.join(', ')}"
|
|
61
64
|
end
|
|
62
65
|
|
|
66
|
+
if length_bearing_hashable_type? && !hashable_length.positive?
|
|
67
|
+
raise ArgumentError, "ConcernsOnRails::Models::Hashable: length must be a positive integer"
|
|
68
|
+
end
|
|
69
|
+
|
|
63
70
|
return unless hashable_type == :custom && (!hashable_alphabet.is_a?(String) || hashable_alphabet.empty?)
|
|
64
71
|
|
|
65
72
|
raise ArgumentError, "ConcernsOnRails::Models::Hashable: type :custom requires a non-empty alphabet: String"
|
|
66
73
|
end
|
|
74
|
+
|
|
75
|
+
# :uuid ignores length; the others derive their size from it.
|
|
76
|
+
def length_bearing_hashable_type?
|
|
77
|
+
%i[hex integer custom].include?(hashable_type)
|
|
78
|
+
end
|
|
67
79
|
end
|
|
68
80
|
|
|
69
81
|
# Assigns the generated value only when the field is blank,
|
|
@@ -72,7 +84,22 @@ module ConcernsOnRails
|
|
|
72
84
|
field = self.class.hashable_field
|
|
73
85
|
return if self[field].present?
|
|
74
86
|
|
|
75
|
-
self[field] = self.class.
|
|
87
|
+
self[field] = if self.class.hashable_unique
|
|
88
|
+
unique_hashable_value(field)
|
|
89
|
+
else
|
|
90
|
+
self.class.generate_hashable_value
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Best-effort uniqueness: retry on an in-Ruby collision before insert. Pair
|
|
95
|
+
# with a unique DB index for the real guarantee (mirrors Tokenizable).
|
|
96
|
+
def unique_hashable_value(field)
|
|
97
|
+
ConcernsOnRails::Models::Hashable::MAX_GENERATION_ATTEMPTS.times do
|
|
98
|
+
candidate = self.class.generate_hashable_value
|
|
99
|
+
return candidate unless self.class.unscoped.exists?(field => candidate)
|
|
100
|
+
end
|
|
101
|
+
raise "ConcernsOnRails::Models::Hashable: could not generate a unique value for '#{field}' " \
|
|
102
|
+
"after #{ConcernsOnRails::Models::Hashable::MAX_GENERATION_ATTEMPTS} attempts"
|
|
76
103
|
end
|
|
77
104
|
end
|
|
78
105
|
end
|
|
@@ -42,6 +42,10 @@ module ConcernsOnRails
|
|
|
42
42
|
|
|
43
43
|
raise ArgumentError, "ConcernsOnRails::Models::Monetizable: :as cannot be combined with multiple fields" if as && fields.size > 1
|
|
44
44
|
|
|
45
|
+
unless subunit_to_unit.to_i.positive?
|
|
46
|
+
raise ArgumentError, "ConcernsOnRails::Models::Monetizable: :subunit_to_unit must be a positive integer"
|
|
47
|
+
end
|
|
48
|
+
|
|
45
49
|
ensure_columns!("ConcernsOnRails::Models::Monetizable", fields)
|
|
46
50
|
config = { unit: unit, precision: precision, delimiter: delimiter, separator: separator, subunit_to_unit: subunit_to_unit }
|
|
47
51
|
fields.each { |cents_field| define_money_accessors(cents_field.to_sym, as, config) }
|
|
@@ -2,6 +2,10 @@ require "active_support/concern"
|
|
|
2
2
|
|
|
3
3
|
module ConcernsOnRails
|
|
4
4
|
module Models
|
|
5
|
+
# Declarative attribute normalization that runs in before_validation.
|
|
6
|
+
#
|
|
7
|
+
# On Rails 7.1+ you may prefer the framework-native `normalizes` macro for
|
|
8
|
+
# new code; this concern provides the same ergonomics on Rails 5.0–7.0.
|
|
5
9
|
module Normalizable
|
|
6
10
|
extend ActiveSupport::Concern
|
|
7
11
|
|
|
@@ -5,18 +5,42 @@ module ConcernsOnRails
|
|
|
5
5
|
module Publishable
|
|
6
6
|
extend ActiveSupport::Concern
|
|
7
7
|
|
|
8
|
-
included do
|
|
8
|
+
included do # rubocop:disable Metrics/BlockLength
|
|
9
9
|
class_attribute :publishable_field, instance_accessor: false, default: :published_at
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
# All scopes branch on the column type: a boolean publishable column (which
|
|
12
|
+
# the macro and instance methods also accept) needs equality predicates,
|
|
13
|
+
# not the timestamp `<= now` / `> now` comparisons that produce nonsensical
|
|
14
|
+
# SQL against a boolean.
|
|
15
|
+
scope :published, lambda {
|
|
16
|
+
if publishable_boolean_column?
|
|
17
|
+
where(publishable_field => true)
|
|
18
|
+
else
|
|
19
|
+
where(arel_table[publishable_field].lteq(Time.zone.now))
|
|
20
|
+
end
|
|
21
|
+
}
|
|
12
22
|
scope :unpublished, lambda {
|
|
13
|
-
|
|
14
|
-
|
|
23
|
+
if publishable_boolean_column?
|
|
24
|
+
unscope(where: publishable_field).where(publishable_field => [nil, false])
|
|
25
|
+
else
|
|
26
|
+
column = arel_table[publishable_field]
|
|
27
|
+
unscope(where: publishable_field).where(column.eq(nil).or(column.gt(Time.zone.now)))
|
|
28
|
+
end
|
|
29
|
+
}
|
|
30
|
+
# Set, but the publish time is still in the future (timestamp columns only).
|
|
31
|
+
scope :scheduled, lambda {
|
|
32
|
+
next none if publishable_boolean_column?
|
|
33
|
+
|
|
34
|
+
unscope(where: publishable_field).where(arel_table[publishable_field].gt(Time.zone.now))
|
|
35
|
+
}
|
|
36
|
+
# Never published — a true draft.
|
|
37
|
+
scope :draft, lambda {
|
|
38
|
+
if publishable_boolean_column?
|
|
39
|
+
unscope(where: publishable_field).where(publishable_field => [nil, false])
|
|
40
|
+
else
|
|
41
|
+
unscope(where: publishable_field).where(publishable_field => nil)
|
|
42
|
+
end
|
|
15
43
|
}
|
|
16
|
-
# Set, but the publish time is still in the future.
|
|
17
|
-
scope :scheduled, -> { unscope(where: publishable_field).where(arel_table[publishable_field].gt(Time.zone.now)) }
|
|
18
|
-
# Never set — a true draft.
|
|
19
|
-
scope :draft, -> { unscope(where: publishable_field).where(publishable_field => nil) }
|
|
20
44
|
end
|
|
21
45
|
|
|
22
46
|
class_methods do
|
|
@@ -31,6 +55,12 @@ module ConcernsOnRails
|
|
|
31
55
|
enable_published_default_scope if default_scope
|
|
32
56
|
end
|
|
33
57
|
|
|
58
|
+
# True when the configured column is a boolean (vs a datetime timestamp);
|
|
59
|
+
# the scopes use this to pick equality vs time-comparison predicates.
|
|
60
|
+
def publishable_boolean_column?
|
|
61
|
+
columns_hash[publishable_field.to_s]&.type == :boolean
|
|
62
|
+
end
|
|
63
|
+
|
|
34
64
|
private
|
|
35
65
|
|
|
36
66
|
# Routed through a helper so the `default_scope:` keyword doesn't shadow
|
|
@@ -44,15 +74,27 @@ module ConcernsOnRails
|
|
|
44
74
|
# Publish the record
|
|
45
75
|
# Example:
|
|
46
76
|
# record.publish!
|
|
77
|
+
# Lifecycle hooks — override in the model (mirrors SoftDeletable's hooks).
|
|
78
|
+
def before_publish; end
|
|
79
|
+
def after_publish; end
|
|
80
|
+
def before_unpublish; end
|
|
81
|
+
def after_unpublish; end
|
|
82
|
+
|
|
47
83
|
def publish!
|
|
48
|
-
|
|
84
|
+
before_publish
|
|
85
|
+
result = update(self.class.publishable_field => Time.zone.now)
|
|
86
|
+
after_publish if result
|
|
87
|
+
result
|
|
49
88
|
end
|
|
50
89
|
|
|
51
90
|
# Unpublish the record
|
|
52
91
|
# Example:
|
|
53
92
|
# record.unpublish!
|
|
54
93
|
def unpublish!
|
|
55
|
-
|
|
94
|
+
before_unpublish
|
|
95
|
+
result = update(self.class.publishable_field => nil)
|
|
96
|
+
after_unpublish if result
|
|
97
|
+
result
|
|
56
98
|
end
|
|
57
99
|
|
|
58
100
|
# Check if the record is published
|
|
@@ -23,6 +23,9 @@ module ConcernsOnRails
|
|
|
23
23
|
# :all splits on whitespace and requires every term to match.
|
|
24
24
|
# match: :contains (default, "%q%"), :prefix ("q%"), or :exact ("q").
|
|
25
25
|
# case_sensitive: false (default) emits ILIKE on Postgres; true emits LIKE.
|
|
26
|
+
# NOTE: this only affects Postgres. On MySQL/SQLite, LIKE
|
|
27
|
+
# case sensitivity is governed by the column collation, so
|
|
28
|
+
# the flag is effectively a no-op there.
|
|
26
29
|
#
|
|
27
30
|
# Uses Arel's `matches`. The query is escaped before interpolation, so
|
|
28
31
|
# `%` / `_` / `\` from user input are treated as literals.
|
|
@@ -23,7 +23,21 @@ module ConcernsOnRails
|
|
|
23
23
|
# if we don't override this method, friendly_id will not generate the new slug when update
|
|
24
24
|
define_method :should_generate_new_friendly_id? do
|
|
25
25
|
field = self.class.sluggable_field
|
|
26
|
-
|
|
26
|
+
slug_column = self.class.friendly_id_config.slug_column
|
|
27
|
+
|
|
28
|
+
# An explicitly-assigned slug wins — don't overwrite it with a generated
|
|
29
|
+
# one when the slug column itself is being changed in this save.
|
|
30
|
+
changing_slug = respond_to?("will_save_change_to_#{slug_column}?") &&
|
|
31
|
+
send("will_save_change_to_#{slug_column}?")
|
|
32
|
+
return false if changing_slug
|
|
33
|
+
|
|
34
|
+
source_changed = respond_to?("will_save_change_to_#{field}?") &&
|
|
35
|
+
send("will_save_change_to_#{field}?")
|
|
36
|
+
# Backfill a missing slug even when the source did not change, so
|
|
37
|
+
# legacy/imported rows with a NULL slug still self-heal.
|
|
38
|
+
slug_missing = send(slug_column).blank? && slug_source.present?
|
|
39
|
+
|
|
40
|
+
source_changed || slug_missing
|
|
27
41
|
end
|
|
28
42
|
end
|
|
29
43
|
|
|
@@ -23,7 +23,12 @@ module ConcernsOnRails
|
|
|
23
23
|
# `with_deleted` peels off the default scope so deleted + non-deleted are both returned.
|
|
24
24
|
scope :with_deleted, -> { unscope(where: soft_delete_field) }
|
|
25
25
|
# Records soft-deleted within the last `duration` (e.g. `deleted_within(7.days)`).
|
|
26
|
-
|
|
26
|
+
# Uses an explicit `>=` rather than an endless range (`x..`): AR only
|
|
27
|
+
# translates an endless range to a `>=` predicate on Rails 6.0+, but this
|
|
28
|
+
# gem supports Rails >= 5.0.
|
|
29
|
+
scope :deleted_within, lambda { |duration|
|
|
30
|
+
soft_deleted.where("#{connection.quote_column_name(soft_delete_field.to_s)} >= ?", duration.ago)
|
|
31
|
+
}
|
|
27
32
|
|
|
28
33
|
# Hide soft-deleted rows from `.all` only when enabled (the default). The block is
|
|
29
34
|
# evaluated lazily, so toggling `soft_delete_default_scope` via the macro takes effect.
|
|
@@ -46,7 +51,10 @@ module ConcernsOnRails
|
|
|
46
51
|
|
|
47
52
|
# Soft-delete every matching record, wrapped in a transaction so the batch is atomic.
|
|
48
53
|
def soft_delete_all
|
|
49
|
-
|
|
54
|
+
# Roll the whole batch back if any record fails to soft-delete, so the
|
|
55
|
+
# documented atomicity actually holds (soft_delete! returns falsey on a
|
|
56
|
+
# validation failure rather than raising).
|
|
57
|
+
transaction { all.each { |record| record.soft_delete! || raise(ActiveRecord::Rollback) } }
|
|
50
58
|
end
|
|
51
59
|
|
|
52
60
|
# Override destroy_all to soft delete. Kept for backwards compatibility, but prefer the
|
|
@@ -60,6 +60,11 @@ module ConcernsOnRails
|
|
|
60
60
|
update!(self.class.stateable_field => state.to_s)
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
+
# Transition lifecycle hooks — override in the model. Fired by guarded
|
|
64
|
+
# <event>! transitions (not by direct <state>! setters or transition_to!).
|
|
65
|
+
def before_transition(_event, _from, _to); end
|
|
66
|
+
def after_transition(_event, _from, _to); end
|
|
67
|
+
|
|
63
68
|
# Defined as a real module (not `class_methods do`) so all the private
|
|
64
69
|
# builder helpers live under a single `private` and aren't constrained by
|
|
65
70
|
# Metrics/BlockLength. ActiveSupport::Concern auto-extends `ClassMethods`.
|
|
@@ -154,11 +159,13 @@ module ConcernsOnRails
|
|
|
154
159
|
|
|
155
160
|
# Instance-level guarded transition body, shared by every `<event>!`.
|
|
156
161
|
def stateable_perform_transition!(field, to, from, event)
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
end
|
|
162
|
+
current = self[field].to_s
|
|
163
|
+
raise InvalidTransition, "#{self.class.name}: cannot #{event} from '#{self[field]}'" unless from.empty? || from.include?(current)
|
|
160
164
|
|
|
161
|
-
|
|
165
|
+
before_transition(event, current, to)
|
|
166
|
+
result = update!(field => to)
|
|
167
|
+
after_transition(event, current, to)
|
|
168
|
+
result
|
|
162
169
|
end
|
|
163
170
|
end
|
|
164
171
|
end
|
|
@@ -96,11 +96,15 @@ module ConcernsOnRails
|
|
|
96
96
|
# LIKE escape), so a tag containing `_` or `%` matches literally.
|
|
97
97
|
def taggable_clause(tag)
|
|
98
98
|
column = "#{connection.quote_table_name(table_name)}.#{connection.quote_column_name(taggable_field)}"
|
|
99
|
-
|
|
99
|
+
# Escape the delimiter too (not just the tag): a delimiter that is a LIKE
|
|
100
|
+
# wildcard (% or _) must match literally. Use LIKE for the whole-column
|
|
101
|
+
# branch as well, so casing is uniform across all four branches — the
|
|
102
|
+
# previous `= ?` was case-sensitive while LIKE is not.
|
|
103
|
+
delim = taggable_escape_like(taggable_delimiter)
|
|
100
104
|
escaped = taggable_escape_like(tag)
|
|
101
105
|
esc = " ESCAPE '\\'"
|
|
102
|
-
["(#{column}
|
|
103
|
-
[
|
|
106
|
+
["(#{column} LIKE ?#{esc} OR #{column} LIKE ?#{esc} OR #{column} LIKE ?#{esc} OR #{column} LIKE ?#{esc})",
|
|
107
|
+
[escaped, "#{escaped}#{delim}%", "%#{delim}#{escaped}", "%#{delim}#{escaped}#{delim}%"]]
|
|
104
108
|
end
|
|
105
109
|
|
|
106
110
|
# Treat the user's tag as a LIKE literal: %, _ and \ are not wildcards.
|
|
@@ -20,11 +20,14 @@ module ConcernsOnRails
|
|
|
20
20
|
# user.api_token? # true if present
|
|
21
21
|
#
|
|
22
22
|
# User.find_by_api_token(token) # Rails default
|
|
23
|
-
# User.authenticate_by_api_token(token) #
|
|
23
|
+
# User.authenticate_by_api_token(token) # constant-time compare; returns record or nil
|
|
24
24
|
#
|
|
25
25
|
# Unlike Hashable, one model can declare multiple token fields, generation is
|
|
26
26
|
# URL-safe by default, and `assign_tokenizable_value` retries on uniqueness
|
|
27
27
|
# collisions before insert (best-effort; pair with a unique DB index).
|
|
28
|
+
#
|
|
29
|
+
# For stateless / self-expiring tokens (password resets, email confirmations)
|
|
30
|
+
# on Rails 7.1+, consider the framework-native `generates_token_for` instead.
|
|
28
31
|
module Tokenizable
|
|
29
32
|
extend ActiveSupport::Concern
|
|
30
33
|
|
|
@@ -88,6 +91,10 @@ module ConcernsOnRails
|
|
|
88
91
|
define_singleton_method("authenticate_by_#{field}") { |value| timing_safe_find(field, value) }
|
|
89
92
|
end
|
|
90
93
|
|
|
94
|
+
# NOTE: the find_by below is an indexed SQL equality, which is not itself
|
|
95
|
+
# timing-safe; secure_compare only hardens the in-Ruby comparison of the
|
|
96
|
+
# already-fetched candidate. For a truly constant-time lookup, store and
|
|
97
|
+
# query a digest instead of the raw token.
|
|
91
98
|
def timing_safe_find(field, value)
|
|
92
99
|
return nil if value.blank?
|
|
93
100
|
|
|
@@ -26,7 +26,10 @@ module ConcernsOnRails
|
|
|
26
26
|
whole = delimit(whole, delimiter)
|
|
27
27
|
number = precision.positive? ? "#{whole}#{separator}#{frac.ljust(precision, '0')[0, precision]}" : whole
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
# Take the sign from the ROUNDED magnitude so a value that rounds to zero
|
|
30
|
+
# (e.g. -0.001 at precision 2) never prints a spurious "-".
|
|
31
|
+
sign = decimal.negative? && !rounded.zero? ? "-" : ""
|
|
32
|
+
"#{sign}#{unit}#{number}"
|
|
30
33
|
end
|
|
31
34
|
|
|
32
35
|
# Insert the thousands delimiter into a non-negative integer string.
|
|
@@ -67,7 +67,14 @@ module ConcernsOnRails
|
|
|
67
67
|
# during before_create — fall back to the current time, which is what the
|
|
68
68
|
# timestamp will resolve to anyway.
|
|
69
69
|
def base_time(record)
|
|
70
|
-
|
|
70
|
+
return Time.current unless record
|
|
71
|
+
|
|
72
|
+
# Memoize the fallback "now" on the record so every base_time call within a
|
|
73
|
+
# single create resolves to the SAME instant — otherwise two Time.current
|
|
74
|
+
# reads could straddle a period boundary (year/month/day) and disagree.
|
|
75
|
+
record.created_at ||
|
|
76
|
+
record.instance_variable_get(:@_sequenceable_now) ||
|
|
77
|
+
record.instance_variable_set(:@_sequenceable_now, Time.current)
|
|
71
78
|
end
|
|
72
79
|
end
|
|
73
80
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: concerns_on_rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.15.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-10 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -112,6 +112,7 @@ homepage: https://vsn2015.github.io/concerns_on_rails
|
|
|
112
112
|
licenses:
|
|
113
113
|
- MIT
|
|
114
114
|
metadata:
|
|
115
|
+
license: MIT
|
|
115
116
|
homepage_uri: https://vsn2015.github.io/concerns_on_rails
|
|
116
117
|
source_code_uri: https://github.com/VSN2015/concerns_on_rails
|
|
117
118
|
changelog_uri: https://github.com/VSN2015/concerns_on_rails/blob/master/CHANGELOG.md
|