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 +4 -4
- data/CHANGELOG.md +28 -0
- data/README.md +145 -3
- data/lib/concerns_on_rails/controllers/error_handleable.rb +72 -0
- data/lib/concerns_on_rails/legacy_aliases.rb +3 -0
- data/lib/concerns_on_rails/models/activatable.rb +65 -0
- data/lib/concerns_on_rails/models/searchable.rb +58 -0
- data/lib/concerns_on_rails/models/tokenizable.rb +145 -0
- data/lib/concerns_on_rails/version.rb +1 -1
- data/lib/concerns_on_rails.rb +4 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5126c79ca466ff1a85a20eb0d4a29f7a2d471bc4186e7b4762481bfad2084f42
|
|
4
|
+
data.tar.gz: '086922bf94b7efa7ef99699e4d96ab4e24ae2e731c2308bd1b0a8471e1f065a2'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
- **
|
|
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.
|
|
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.
|
|
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
|
|
@@ -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
|
data/lib/concerns_on_rails.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
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:
|