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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0cbda4a7f45769f7997d0f5367d04a594946bee53e45e759a461bb73a535522e
4
- data.tar.gz: 692b5092f4b784a113dd09935eda88fc16bd1f0a484f349ae53c77f8b945b14d
3
+ metadata.gz: 9d0ca1b10759cf285948bef864e3cba343b3d1caa5e461b000af0486b4d51781
4
+ data.tar.gz: 1006768d58216c32a190c551ede050eb2e12ac24d13daf847600e83d1f219f52
5
5
  SHA512:
6
- metadata.gz: 215b94501afdbb59bfed5633eaa649efb244e2c9a01e21ac30a63ea1bf4de0ffa9f5b2c71da92d5fd980f90cc7d8ef397f17fa0c708d16be31059f7d29facf91
7
- data.tar.gz: 3778aa7588aef6315a40707fdfd1d01b2092d65fb4b5717a16a4ac4f81300f23086aa4a3f0133d86a84d84acbb9b210a7ee45f23d2806e6c9d8ade8797238733
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
- actor = respond_to?(via) ? send(via) : nil
54
- actor.respond_to?(role_method) && wanted.include?(actor.public_send(role_method).to_s)
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
- respond_to?(:current_user) ? current_user : nil
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(error)
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: error.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
- else
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.split(",").each do |part|
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
- total = relation.except(:order, :limit, :offset).count
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
- if report_only
89
- content_security_policy_report_only(true, **action_opts, &block)
90
- else
91
- content_security_policy(**action_opts, &block)
92
- end
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
- field = sort_field
48
- return relation unless field
47
+ fields = sort_fields
48
+ return relation if fields.empty?
49
49
 
50
- relation.order(field => sort_direction)
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
- def sort_field
56
- requested = params[:sort]&.to_sym
57
- if requested && self.class.sortable_allowed_fields.include?(requested)
58
- requested
59
- else
60
- self.class.sortable_default_field
61
- end
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 :active, -> { where(activatable_field => true) }
38
- scope :inactive, -> { where(activatable_field => [false, nil]) }
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
- active? ? deactivate! : activate!
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).to_s.rjust(hashable_length, "0").to_i
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.generate_hashable_value
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
- scope :published, -> { where(arel_table[publishable_field].lteq(Time.zone.now)) }
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
- column = arel_table[publishable_field]
14
- unscope(where: publishable_field).where(column.eq(nil).or(column.gt(Time.zone.now)))
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
- update(self.class.publishable_field => Time.zone.now)
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
- update(self.class.publishable_field => nil)
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
- respond_to?("will_save_change_to_#{field}?") && send("will_save_change_to_#{field}?")
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
- scope :deleted_within, ->(duration) { soft_deleted.where(soft_delete_field => duration.ago..) }
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
- transaction { all.each(&:soft_delete!) }
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
- unless from.empty? || from.include?(self[field].to_s)
158
- raise InvalidTransition, "#{self.class.name}: cannot #{event} from '#{self[field]}'"
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
- update!(field => to)
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
- delim = taggable_delimiter
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} = ? OR #{column} LIKE ?#{esc} OR #{column} LIKE ?#{esc} OR #{column} LIKE ?#{esc})",
103
- [tag, "#{escaped}#{delim}%", "%#{delim}#{escaped}", "%#{delim}#{escaped}#{delim}%"]]
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) # timing-safe lookup, returns record or nil
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
- "#{'-' if decimal.negative?}#{unit}#{number}"
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
- record&.created_at || Time.current
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
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.14.1".freeze
2
+ VERSION = "1.15.0".freeze
3
3
  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.14.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-06 00:00:00.000000000 Z
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