concerns_on_rails 1.6.0 → 1.8.2

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: 6a0e7d53400756b8d2d3c27685da15e9f087f152fd6098beefb418a139f0b710
4
- data.tar.gz: b11475b0b5c0b494d6ef4f356840bf63570519f85ba9c4d348d11026997937af
3
+ metadata.gz: 5126c79ca466ff1a85a20eb0d4a29f7a2d471bc4186e7b4762481bfad2084f42
4
+ data.tar.gz: '086922bf94b7efa7ef99699e4d96ab4e24ae2e731c2308bd1b0a8471e1f065a2'
5
5
  SHA512:
6
- metadata.gz: 4377941466783167c7f797e00b45a13d4280ce19127323407a671c30ee85bf1791500a60203e3e2141510bbb699fbad6bda5febd5b6706639236dca62741fc20
7
- data.tar.gz: 9eb22fb7cca58c2bebd9544a558bbb7e060450a02377cee7f5d5ed13b68c453a26d67aca894ed3939aa5d79dfcc92c8874215f646db79b5441151c3a44070a55
6
+ metadata.gz: 6b3e968d8cf97c9d11b14ff1d8374a64731ed3416d114ca0acf525a64f65843a35f0261da219d9f725e539eba7ac6460ba331a3aaf2b701b88627d841ac70a2a
7
+ data.tar.gz: 61f80dadd2112d36c1c1ceaf88fe01fb5dcf68f22911e1535e68414ecdbaca07ca363365c7ec3032c2698aec1cb0929d054036d2b8ebf73d1c347093976f3bb4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.8.2 (2026-05-22)
4
+
5
+ ### Internal
6
+ - Regenerated `Gemfile.lock` so the pinned `concerns_on_rails` version matches the gemspec. No behavior change.
7
+
8
+ ### Notes
9
+ - The `v1.8.1` tag was pushed but failed CI (`bundle install --deployment` rejected the stale `Gemfile.lock`); `1.8.2` is the first usable release of the Tokenizable concern.
10
+
11
+ ## 1.8.1 (2026-05-22)
12
+
13
+ ### Internal
14
+ - Refactored `Models::Tokenizable` `class_methods` blocks to satisfy `Metrics/BlockLength`. No behavior change.
15
+
16
+ ### Notes
17
+ - The `v1.8.0` tag was pushed but failed RuboCop; `1.8.1` is the first usable release of the Tokenizable concern.
18
+
19
+ ## 1.8.0 (2026-05-22)
20
+
21
+ ### Added
22
+ - **Models::Tokenizable**: Security-token generation for API keys, invite codes, share links, password-reset tokens. Each `tokenizable_by` call adds an independently-configured field (one model can hold many tokens). Defaults to 32-char URL-safe values; also supports `:hex`, `:alphanumeric`, and `:numeric` types with a configurable `length:`. Auto-generates on create with best-effort uniqueness retry, and provides `regenerate_<field>!`, `revoke_<field>!`, `<field>?`, and a timing-safe `.authenticate_by_<field>` class method.
23
+
24
+ ## 1.7.0 (2026-05-21)
25
+
26
+ ### Added
27
+ - **Models::Searchable**: LIKE-based search across one or more columns via `searchable_by :title, :body`. Adds a `.search(query)` scope that uses Arel's `matches` (emits `ILIKE` on Postgres, `LIKE` elsewhere), ORs predicates across all configured fields, escapes user input so `%` / `_` / `\` are treated as literals, and returns the full relation for blank queries.
28
+ - **Models::Activatable**: Boolean active/inactive toggle via `activatable_by` (defaults to the `:active` column). Adds `.active` / `.inactive` scopes (treats `NULL` as inactive), predicates (`active?`, `inactive?`), and mutators (`activate!`, `deactivate!`, `toggle_active!`).
29
+ - **Controllers::ErrorHandleable**: `rescue_from` handlers for `ActiveRecord::RecordNotFound` (404), `ActionController::ParameterMissing` (400), and `ActiveRecord::RecordInvalid` (422) that render the same JSON envelope as `Respondable#render_error`. Each handler is overridable for custom wording. Pairs naturally with `Respondable`.
30
+
3
31
  ## 1.6.0 (2026-05-19)
4
32
 
5
33
  ### Added
data/README.md CHANGED
@@ -33,11 +33,15 @@ Article.published.without_deleted.find("hello-world")
33
33
  - [Schedulable](#-schedulable) — `starts_at` / `ends_at` time windows
34
34
  - [Expirable](#-expirable) — single-timestamp expiry
35
35
  - [Normalizable](#-normalizable) — attribute normalization (`:email`, `:phone`, …)
36
+ - [Searchable](#-searchable) — LIKE/ILIKE search across configured columns
37
+ - [Activatable](#-activatable) — boolean active/inactive toggle
38
+ - [Tokenizable](#-tokenizable) — security tokens with timing-safe lookup
36
39
  - **Controller concerns**
37
40
  - [Paginatable](#-paginatable) — offset pagination with headers
38
41
  - [Filterable](#-filterable) — declarative URL-param filters
39
42
  - [Sortable (controller)](#-sortable-controller) — URL-param ordering with allow-list
40
43
  - [Respondable](#-respondable) — standardized JSON envelopes
44
+ - [ErrorHandleable](#-errorhandleable) — JSON `rescue_from` handlers for common controller errors
41
45
  - [Module paths & namespacing](#-module-paths--namespacing)
42
46
  - [Development](#-development)
43
47
  - [Contributing](#-contributing)
@@ -47,7 +51,7 @@ Article.published.without_deleted.find("hello-world")
47
51
 
48
52
  ## ✨ Why this gem?
49
53
 
50
- - **Eight model concerns + four controller concerns**, all production-ready
54
+ - **Eleven model concerns + five controller concerns**, all production-ready
51
55
  - **One include, one macro** — no boilerplate, no glue code
52
56
  - **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
53
57
  - **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
@@ -60,7 +64,7 @@ Article.published.without_deleted.find("hello-world")
60
64
  Add to your application's `Gemfile`:
61
65
 
62
66
  ```ruby
63
- gem "concerns_on_rails", "~> 1.6"
67
+ gem "concerns_on_rails", "~> 1.8"
64
68
  ```
65
69
 
66
70
  Or pull the latest from GitHub:
@@ -429,6 +433,99 @@ User.create(phone: "+1 (415) 555-1234").phone # => "14155551234"
429
433
 
430
434
  ---
431
435
 
436
+ ## 🔍 Searchable
437
+
438
+ LIKE-based search across one or more columns — no external search engine, no extra gems.
439
+
440
+ ```ruby
441
+ class Article < ApplicationRecord
442
+ include ConcernsOnRails::Searchable
443
+
444
+ searchable_by :title, :body
445
+ end
446
+
447
+ Article.search("hello") # WHERE title ILIKE '%hello%' OR body ILIKE '%hello%'
448
+ Article.search("") # no-op — returns the full relation
449
+ Article.search("foo").where(state: 1) # chainable like any scope
450
+ ```
451
+
452
+ **Notes**
453
+ - Uses Arel's `matches`, which emits `ILIKE` on Postgres (case-insensitive) and `LIKE` elsewhere.
454
+ - The query is escaped before interpolation — `%`, `_`, and `\` from user input are treated as literals, not wildcards.
455
+ - Blank or nil queries return the relation unchanged so it's safe to drop into a controller pipeline.
456
+ - Single-term substring match by design; reach for `pg_search` / Elasticsearch when you need ranking, stemming, or multi-term queries.
457
+
458
+ ---
459
+
460
+ ## ✅ Activatable
461
+
462
+ Boolean active/inactive toggle backed by a single column.
463
+
464
+ ```ruby
465
+ class Subscription < ApplicationRecord
466
+ include ConcernsOnRails::Activatable
467
+
468
+ activatable_by # defaults to :active
469
+ # activatable_by :enabled # custom column name
470
+ end
471
+
472
+ sub = Subscription.create!(active: true)
473
+ sub.active? # => true
474
+ sub.deactivate!
475
+ sub.inactive? # => true
476
+ sub.toggle_active! # flips back to true
477
+
478
+ Subscription.active # WHERE active = TRUE
479
+ Subscription.inactive # WHERE active = FALSE OR active IS NULL
480
+ ```
481
+
482
+ **Notes**
483
+ - `NULL` is treated as inactive (same convention as most apps' "unset = off").
484
+ - The configured column must exist; `activatable_by` raises `ArgumentError` otherwise.
485
+ - `SoftDeletable` also defines a `.active` scope (alias of `.without_deleted`). If both concerns are included on the same model, the later one wins — include the one whose `.active` semantics you want last, or stick to one of them.
486
+
487
+ ---
488
+
489
+ ## 🔑 Tokenizable
490
+
491
+ Generate and manage security tokens — API keys, invite codes, share links, password-reset tokens. One model can declare any number of independently-configured token fields.
492
+
493
+ ```ruby
494
+ class User < ApplicationRecord
495
+ include ConcernsOnRails::Tokenizable
496
+
497
+ tokenizable_by :api_token # 32-char URL-safe
498
+ tokenizable_by :reset_password_token, length: 24
499
+ tokenizable_by :invite_code, type: :alphanumeric, length: 8
500
+ end
501
+
502
+ user = User.create! # all three tokens auto-generated
503
+ user.api_token # => "k3Jf...g2" (32 URL-safe chars)
504
+ user.api_token? # => true
505
+
506
+ user.regenerate_api_token! # rotates and persists
507
+ user.revoke_api_token! # nils the column
508
+
509
+ User.find_by_api_token(token) # Rails default
510
+ User.authenticate_by_api_token(token) # timing-safe; returns user or nil
511
+ ```
512
+
513
+ **Options**
514
+
515
+ | Option | Default | Notes |
516
+ | -------- | ----------- | ------------------------------------------------------------- |
517
+ | `type:` | `:urlsafe` | One of `:urlsafe`, `:hex`, `:alphanumeric`, `:numeric` |
518
+ | `length:`| `32` | Character length of the generated token |
519
+
520
+ **Notes**
521
+ - URL-safe by default (`A–Z`, `a–z`, `0–9`, `-`, `_`) — drop straight into URLs and headers.
522
+ - Caller-supplied values are respected: `User.create!(api_token: "preset")` won't be overwritten.
523
+ - Generation does a best-effort uniqueness check before insert and retries up to 10 times. Pair with a `unique` DB index for real safety, especially for short alphanumeric/numeric codes.
524
+ - `.authenticate_by_<field>` uses `ActiveSupport::SecurityUtils.secure_compare` to avoid leaking partial matches via response timing.
525
+ - Distinct from `Hashable`: Hashable handles a single random field; Tokenizable focuses on security tokens (multi-field, URL-safe default, timing-safe lookup, revocation).
526
+
527
+ ---
528
+
432
529
  # 🎮 Controller Concerns
433
530
 
434
531
  Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
@@ -567,6 +664,51 @@ end
567
664
 
568
665
  ---
569
666
 
667
+ ## 🛟 ErrorHandleable
668
+
669
+ Install `rescue_from` handlers for the three most common controller exceptions and render them as the same JSON envelope used by Respondable.
670
+
671
+ ```ruby
672
+ class Api::BaseController < ApplicationController
673
+ include ConcernsOnRails::Controllers::Respondable # recommended
674
+ include ConcernsOnRails::Controllers::ErrorHandleable
675
+ end
676
+ ```
677
+
678
+ **Handled exceptions**
679
+
680
+ | Exception | Status | `code` |
681
+ |----------------------------------------|--------|-----------------------|
682
+ | `ActiveRecord::RecordNotFound` | 404 | `"not_found"` |
683
+ | `ActionController::ParameterMissing` | 400 | `"parameter_missing"` |
684
+ | `ActiveRecord::RecordInvalid` | 422 | `"record_invalid"` |
685
+
686
+ Response shape (matches `Respondable#render_error`):
687
+
688
+ ```json
689
+ { "success": false, "error": { "message": "...", "code": "...", "details": [...] } }
690
+ ```
691
+
692
+ **Overriding a handler**
693
+
694
+ Each handler is a public instance method, so subclasses can customize the message or response shape without re-declaring the `rescue_from`:
695
+
696
+ ```ruby
697
+ class Api::BaseController < ApplicationController
698
+ include ConcernsOnRails::Controllers::ErrorHandleable
699
+
700
+ def handle_record_not_found(error)
701
+ render json: { success: false, error: { message: "Not here, friend." } }, status: :not_found
702
+ end
703
+ end
704
+ ```
705
+
706
+ **Notes**
707
+ - When `Respondable` is also included, the handlers delegate to `render_error` so the envelope shape stays in one place. Otherwise they render the same envelope inline.
708
+ - `RecordInvalid.details` are populated from `error.record.errors.full_messages`.
709
+
710
+ ---
711
+
570
712
  ## 🗂️ Module paths & namespacing
571
713
 
572
714
  Every concern is available under two paths:
@@ -598,7 +740,7 @@ Both forms reference the same module, so you can freely mix them.
598
740
  bundle install # install dev dependencies
599
741
  bundle exec rspec # run the test suite
600
742
  gem build concerns_on_rails.gemspec # build the gem
601
- gem install ./concerns_on_rails-1.6.0.gem # install locally
743
+ gem install ./concerns_on_rails-1.7.0.gem # install locally
602
744
  ```
603
745
 
604
746
  The test suite uses an in-memory SQLite database and a lightweight `FakeController` harness for controller-concern specs — no Rails routes or boot required.
@@ -0,0 +1,72 @@
1
+ require "active_support/concern"
2
+
3
+ module ConcernsOnRails
4
+ module Controllers
5
+ # Installs `rescue_from` handlers for the three most common controller
6
+ # exceptions and renders them as the JSON error envelope used by Respondable.
7
+ #
8
+ # class Api::BaseController < ApplicationController
9
+ # include ConcernsOnRails::Controllers::Respondable # optional, but recommended
10
+ # include ConcernsOnRails::Controllers::ErrorHandleable
11
+ # end
12
+ #
13
+ # Handled:
14
+ # * ActiveRecord::RecordNotFound → 404 not_found
15
+ # * ActionController::ParameterMissing → 400 parameter_missing
16
+ # * ActiveRecord::RecordInvalid → 422 record_invalid (with field errors)
17
+ #
18
+ # If Respondable is also included on the controller, the handlers delegate
19
+ # to `render_error` so the envelope shape stays in one place. Otherwise the
20
+ # handlers render the same envelope inline.
21
+ #
22
+ # Each handler is a public instance method, so subclasses can override the
23
+ # message wording or response shape without re-declaring the `rescue_from`.
24
+ module ErrorHandleable
25
+ extend ActiveSupport::Concern
26
+
27
+ included do
28
+ rescue_from "ActiveRecord::RecordNotFound", with: :handle_record_not_found
29
+ rescue_from "ActionController::ParameterMissing", with: :handle_parameter_missing
30
+ rescue_from "ActiveRecord::RecordInvalid", with: :handle_record_invalid
31
+ end
32
+
33
+ def handle_record_not_found(error)
34
+ render_error_envelope(
35
+ message: error.message,
36
+ code: "not_found",
37
+ status: :not_found
38
+ )
39
+ end
40
+
41
+ def handle_parameter_missing(error)
42
+ render_error_envelope(
43
+ message: "Parameter missing: #{error.param}",
44
+ code: "parameter_missing",
45
+ status: :bad_request
46
+ )
47
+ end
48
+
49
+ def handle_record_invalid(error)
50
+ record = error.respond_to?(:record) ? error.record : nil
51
+ details = record.respond_to?(:errors) ? record.errors.full_messages : nil
52
+
53
+ render_error_envelope(
54
+ message: error.message,
55
+ code: "record_invalid",
56
+ status: :unprocessable_entity,
57
+ errors: details
58
+ )
59
+ end
60
+
61
+ private
62
+
63
+ def render_error_envelope(message:, code:, status:, errors: nil)
64
+ return render_error(message: message, code: code, status: status, errors: errors) if respond_to?(:render_error)
65
+
66
+ error = { message: message, code: code }
67
+ error[:details] = errors if errors
68
+ render json: { success: false, error: error }, status: status
69
+ end
70
+ end
71
+ end
72
+ end
@@ -10,4 +10,7 @@ module ConcernsOnRails
10
10
  Schedulable = Models::Schedulable
11
11
  Expirable = Models::Expirable
12
12
  Normalizable = Models::Normalizable
13
+ Searchable = Models::Searchable
14
+ Activatable = Models::Activatable
15
+ Tokenizable = Models::Tokenizable
13
16
  end
@@ -0,0 +1,65 @@
1
+ require "active_support/concern"
2
+
3
+ module ConcernsOnRails
4
+ module Models
5
+ # Boolean active/inactive toggle backed by a single column.
6
+ #
7
+ # class Subscription < ApplicationRecord
8
+ # include ConcernsOnRails::Activatable
9
+ #
10
+ # activatable_by # defaults to :active
11
+ # # activatable_by :enabled # custom column name
12
+ # end
13
+ #
14
+ # Subscription.active # WHERE active = TRUE
15
+ # Subscription.inactive # WHERE active = FALSE OR active IS NULL
16
+ #
17
+ # NULL is treated as inactive, mirroring how unset booleans behave in most apps.
18
+ #
19
+ # Note: SoftDeletable also defines a `.active` scope (alias of `.without_deleted`).
20
+ # If both concerns are included on the same model, the later one wins.
21
+ module Activatable
22
+ extend ActiveSupport::Concern
23
+
24
+ DEFAULT_FIELD = :active
25
+
26
+ included do
27
+ class_attribute :activatable_field, instance_accessor: false, default: DEFAULT_FIELD
28
+ end
29
+
30
+ class_methods do
31
+ def activatable_by(field = DEFAULT_FIELD)
32
+ self.activatable_field = field.to_sym
33
+
34
+ unless column_names.include?(activatable_field.to_s)
35
+ raise ArgumentError,
36
+ "ConcernsOnRails::Models::Activatable: activatable_field '#{activatable_field}' does not exist in the database"
37
+ end
38
+
39
+ scope :active, -> { where(activatable_field => true) }
40
+ scope :inactive, -> { where(activatable_field => [false, nil]) }
41
+ end
42
+ end
43
+
44
+ def active?
45
+ self[self.class.activatable_field] == true
46
+ end
47
+
48
+ def inactive?
49
+ !active?
50
+ end
51
+
52
+ def activate!
53
+ update(self.class.activatable_field => true)
54
+ end
55
+
56
+ def deactivate!
57
+ update(self.class.activatable_field => false)
58
+ end
59
+
60
+ def toggle_active!
61
+ active? ? deactivate! : activate!
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,58 @@
1
+ require "active_support/concern"
2
+
3
+ module ConcernsOnRails
4
+ module Models
5
+ # LIKE-based search across one or more columns.
6
+ #
7
+ # class Article < ApplicationRecord
8
+ # include ConcernsOnRails::Searchable
9
+ #
10
+ # searchable_by :title, :body
11
+ # end
12
+ #
13
+ # Article.search("hello") # WHERE title ILIKE '%hello%' OR body ILIKE '%hello%'
14
+ # Article.search("") # no-op — returns the full relation
15
+ # Article.search("foo").where(...) # chainable like any scope
16
+ #
17
+ # Uses Arel's `matches`, which emits ILIKE on Postgres and LIKE elsewhere —
18
+ # so case-insensitivity comes for free on PG. The query is escaped before
19
+ # interpolation, so `%` / `_` / `\` from user input are treated as literals.
20
+ module Searchable
21
+ extend ActiveSupport::Concern
22
+
23
+ LIKE_ESCAPE = "\\".freeze
24
+ LIKE_SPECIAL = /[\\%_]/
25
+
26
+ included do
27
+ class_attribute :searchable_fields, instance_accessor: false, default: []
28
+ end
29
+
30
+ class_methods do
31
+ def searchable_by(*fields)
32
+ raise ArgumentError, "ConcernsOnRails::Models::Searchable: at least one field is required" if fields.empty?
33
+
34
+ fields.each do |field|
35
+ unless column_names.include?(field.to_s)
36
+ raise ArgumentError,
37
+ "ConcernsOnRails::Models::Searchable: field '#{field}' does not exist in the database"
38
+ end
39
+ end
40
+
41
+ self.searchable_fields = fields.map(&:to_sym)
42
+
43
+ scope :search, ->(query) { search_relation(query) }
44
+ end
45
+
46
+ def search_relation(query)
47
+ return all if query.nil? || query.to_s.strip.empty?
48
+
49
+ escaped = query.to_s.gsub(LIKE_SPECIAL) { |c| "#{LIKE_ESCAPE}#{c}" }
50
+ pattern = "%#{escaped}%"
51
+
52
+ predicates = searchable_fields.map { |field| arel_table[field].matches(pattern, LIKE_ESCAPE) }
53
+ where(predicates.reduce { |memo, p| memo.or(p) })
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,145 @@
1
+ require "active_support/concern"
2
+ require "active_support/security_utils"
3
+ require "securerandom"
4
+
5
+ module ConcernsOnRails
6
+ module Models
7
+ # Generates and manages security tokens (API keys, invite codes, share links).
8
+ #
9
+ # class User < ApplicationRecord
10
+ # include ConcernsOnRails::Tokenizable
11
+ #
12
+ # tokenizable_by :api_token # 32-char URL-safe
13
+ # tokenizable_by :reset_password_token, length: 24
14
+ # tokenizable_by :invite_code, type: :alphanumeric, length: 8
15
+ # end
16
+ #
17
+ # user = User.create! # tokens auto-generated on create
18
+ # user.regenerate_api_token! # new value, persisted
19
+ # user.revoke_api_token! # sets the column to nil
20
+ # user.api_token? # true if present
21
+ #
22
+ # User.find_by_api_token(token) # Rails default
23
+ # User.authenticate_by_api_token(token) # timing-safe lookup, returns record or nil
24
+ #
25
+ # Unlike Hashable, one model can declare multiple token fields, generation is
26
+ # URL-safe by default, and `assign_tokenizable_value` retries on uniqueness
27
+ # collisions before insert (best-effort; pair with a unique DB index).
28
+ module Tokenizable
29
+ extend ActiveSupport::Concern
30
+
31
+ VALID_TYPES = %i[urlsafe hex alphanumeric numeric].freeze
32
+ ALPHANUMERIC_ALPHABET = (("A".."Z").to_a + ("a".."z").to_a + ("0".."9").to_a).freeze
33
+ NUMERIC_ALPHABET = ("0".."9").to_a.freeze
34
+ MAX_GENERATION_ATTEMPTS = 10
35
+
36
+ included do
37
+ class_attribute :tokenizable_fields, instance_accessor: false, default: {}
38
+ end
39
+
40
+ class_methods do
41
+ # Configure a tokenizable field.
42
+ #
43
+ # Options:
44
+ # type: one of :urlsafe (default), :hex, :alphanumeric, :numeric
45
+ # length: character length of the generated token (default 32)
46
+ def tokenizable_by(field, type: :urlsafe, length: 32)
47
+ field = field.to_sym
48
+ type = type.to_sym
49
+ length = length.to_i
50
+
51
+ validate_tokenizable_options!(field, type, length)
52
+
53
+ # Build a fresh hash so subclasses don't mutate the parent's config.
54
+ self.tokenizable_fields = tokenizable_fields.merge(field => { type: type, length: length })
55
+
56
+ before_create -> { assign_tokenizable_value(field) }
57
+
58
+ define_tokenizable_methods(field)
59
+ end
60
+
61
+ # Generate a new random value for the given field using its configured type/length.
62
+ def generate_tokenizable_value(field)
63
+ config = tokenizable_fields.fetch(field) do
64
+ raise ArgumentError, "ConcernsOnRails::Models::Tokenizable: '#{field}' is not a tokenizable field"
65
+ end
66
+
67
+ length = config[:length]
68
+
69
+ case config[:type]
70
+ when :urlsafe then SecureRandom.urlsafe_base64(length)[0, length]
71
+ when :hex then SecureRandom.hex((length + 1) / 2)[0, length]
72
+ when :alphanumeric then random_string_from_alphabet(ALPHANUMERIC_ALPHABET, length)
73
+ when :numeric then random_string_from_alphabet(NUMERIC_ALPHABET, length)
74
+ end
75
+ end
76
+ end
77
+
78
+ class_methods do
79
+ private
80
+
81
+ def define_tokenizable_methods(field)
82
+ define_method("regenerate_#{field}!") { update!(field => self.class.generate_tokenizable_value(field)) }
83
+ define_method("revoke_#{field}!") { update!(field => nil) }
84
+ define_method("#{field}?") { self[field].present? }
85
+ define_singleton_method("authenticate_by_#{field}") { |value| timing_safe_find(field, value) }
86
+ end
87
+
88
+ def timing_safe_find(field, value)
89
+ return nil if value.blank?
90
+
91
+ candidate = find_by(field => value)
92
+ return nil unless candidate
93
+
94
+ stored = candidate[field].to_s
95
+ given = value.to_s
96
+ return nil unless stored.bytesize == given.bytesize
97
+
98
+ ActiveSupport::SecurityUtils.secure_compare(stored, given) ? candidate : nil
99
+ end
100
+ end
101
+
102
+ class_methods do
103
+ def validate_tokenizable_options!(field, type, length)
104
+ unless column_names.include?(field.to_s)
105
+ raise ArgumentError,
106
+ "ConcernsOnRails::Models::Tokenizable: tokenizable field '#{field}' does not exist in the database"
107
+ end
108
+
109
+ unless VALID_TYPES.include?(type)
110
+ raise ArgumentError,
111
+ "ConcernsOnRails::Models::Tokenizable: unknown type '#{type}'. Valid types: #{VALID_TYPES.join(', ')}"
112
+ end
113
+
114
+ return if length.positive?
115
+
116
+ raise ArgumentError, "ConcernsOnRails::Models::Tokenizable: length must be a positive integer"
117
+ end
118
+
119
+ def random_string_from_alphabet(alphabet, length)
120
+ Array.new(length) { alphabet[SecureRandom.random_number(alphabet.size)] }.join
121
+ end
122
+
123
+ private :validate_tokenizable_options!, :random_string_from_alphabet
124
+ end
125
+
126
+ # Assigns the generated value only when blank, so callers can pass an explicit one.
127
+ # Retries up to MAX_GENERATION_ATTEMPTS times if the in-Ruby uniqueness check hits a
128
+ # collision — useful for short codes; a unique DB index is still the real guarantee.
129
+ def assign_tokenizable_value(field)
130
+ return if self[field].present?
131
+
132
+ MAX_GENERATION_ATTEMPTS.times do
133
+ candidate = self.class.generate_tokenizable_value(field)
134
+ unless self.class.unscoped.exists?(field => candidate)
135
+ self[field] = candidate
136
+ return
137
+ end
138
+ end
139
+
140
+ raise "ConcernsOnRails::Models::Tokenizable: could not generate a unique value for '#{field}' " \
141
+ "after #{MAX_GENERATION_ATTEMPTS} attempts — consider a longer length or a larger alphabet"
142
+ end
143
+ end
144
+ end
145
+ end
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.6.0".freeze
2
+ VERSION = "1.8.2".freeze
3
3
  end
@@ -15,12 +15,16 @@ require "concerns_on_rails/models/hashable"
15
15
  require "concerns_on_rails/models/schedulable"
16
16
  require "concerns_on_rails/models/expirable"
17
17
  require "concerns_on_rails/models/normalizable"
18
+ require "concerns_on_rails/models/searchable"
19
+ require "concerns_on_rails/models/activatable"
20
+ require "concerns_on_rails/models/tokenizable"
18
21
 
19
22
  # Controller concerns
20
23
  require "concerns_on_rails/controllers/paginatable"
21
24
  require "concerns_on_rails/controllers/filterable"
22
25
  require "concerns_on_rails/controllers/sortable"
23
26
  require "concerns_on_rails/controllers/respondable"
27
+ require "concerns_on_rails/controllers/error_handleable"
24
28
 
25
29
  # Backwards compatibility (top-level aliases for pre-1.6 module paths)
26
30
  require "concerns_on_rails/legacy_aliases"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: concerns_on_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.0
4
+ version: 1.8.2
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-05-19 00:00:00.000000000 Z
11
+ date: 2026-05-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -70,19 +70,23 @@ files:
70
70
  - CODE_OF_CONDUCT.md
71
71
  - README.md
72
72
  - lib/concerns_on_rails.rb
73
+ - lib/concerns_on_rails/controllers/error_handleable.rb
73
74
  - lib/concerns_on_rails/controllers/filterable.rb
74
75
  - lib/concerns_on_rails/controllers/paginatable.rb
75
76
  - lib/concerns_on_rails/controllers/respondable.rb
76
77
  - lib/concerns_on_rails/controllers/sortable.rb
77
78
  - lib/concerns_on_rails/legacy_aliases.rb
79
+ - lib/concerns_on_rails/models/activatable.rb
78
80
  - lib/concerns_on_rails/models/expirable.rb
79
81
  - lib/concerns_on_rails/models/hashable.rb
80
82
  - lib/concerns_on_rails/models/normalizable.rb
81
83
  - lib/concerns_on_rails/models/publishable.rb
82
84
  - lib/concerns_on_rails/models/schedulable.rb
85
+ - lib/concerns_on_rails/models/searchable.rb
83
86
  - lib/concerns_on_rails/models/sluggable.rb
84
87
  - lib/concerns_on_rails/models/soft_deletable.rb
85
88
  - lib/concerns_on_rails/models/sortable.rb
89
+ - lib/concerns_on_rails/models/tokenizable.rb
86
90
  - lib/concerns_on_rails/version.rb
87
91
  homepage: https://github.com/VSN2015/concerns_on_rails
88
92
  licenses: