concerns_on_rails 1.6.0 → 1.7.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: 6a0e7d53400756b8d2d3c27685da15e9f087f152fd6098beefb418a139f0b710
4
- data.tar.gz: b11475b0b5c0b494d6ef4f356840bf63570519f85ba9c4d348d11026997937af
3
+ metadata.gz: 7bdaf4512b253a9c3b7307ace69f16d616f180d38ce3a3f5292099f0c97dedb5
4
+ data.tar.gz: de2843d32ebd1cea0e3c9fce3e8f5d6d18adaa6eb6e92ce4056af3bb2ab4d368
5
5
  SHA512:
6
- metadata.gz: 4377941466783167c7f797e00b45a13d4280ce19127323407a671c30ee85bf1791500a60203e3e2141510bbb699fbad6bda5febd5b6706639236dca62741fc20
7
- data.tar.gz: 9eb22fb7cca58c2bebd9544a558bbb7e060450a02377cee7f5d5ed13b68c453a26d67aca894ed3939aa5d79dfcc92c8874215f646db79b5441151c3a44070a55
6
+ metadata.gz: 315ff80a677ef032630d0b81f8c0b5c3b88d132a288ad075ed00732e2d676ccef954725c8966231af793ee89dc2485cb48e6b0c1de03ea93d47c7f283545815d
7
+ data.tar.gz: 13c59b78fc2b9dbd8dfca9877550a3a7fcd778fca493dd1b97c9151f9c4d111f94aa8eb3d8692b9b7e0299ccd52bf270944dee2a6987aae3c88917532cce2404
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.7.0 (2026-05-21)
4
+
5
+ ### Added
6
+ - **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.
7
+ - **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!`).
8
+ - **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`.
9
+
3
10
  ## 1.6.0 (2026-05-19)
4
11
 
5
12
  ### Added
data/README.md CHANGED
@@ -33,11 +33,14 @@ 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
36
38
  - **Controller concerns**
37
39
  - [Paginatable](#-paginatable) — offset pagination with headers
38
40
  - [Filterable](#-filterable) — declarative URL-param filters
39
41
  - [Sortable (controller)](#-sortable-controller) — URL-param ordering with allow-list
40
42
  - [Respondable](#-respondable) — standardized JSON envelopes
43
+ - [ErrorHandleable](#-errorhandleable) — JSON `rescue_from` handlers for common controller errors
41
44
  - [Module paths & namespacing](#-module-paths--namespacing)
42
45
  - [Development](#-development)
43
46
  - [Contributing](#-contributing)
@@ -47,7 +50,7 @@ Article.published.without_deleted.find("hello-world")
47
50
 
48
51
  ## ✨ Why this gem?
49
52
 
50
- - **Eight model concerns + four controller concerns**, all production-ready
53
+ - **Ten model concerns + five controller concerns**, all production-ready
51
54
  - **One include, one macro** — no boilerplate, no glue code
52
55
  - **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
53
56
  - **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
@@ -60,7 +63,7 @@ Article.published.without_deleted.find("hello-world")
60
63
  Add to your application's `Gemfile`:
61
64
 
62
65
  ```ruby
63
- gem "concerns_on_rails", "~> 1.6"
66
+ gem "concerns_on_rails", "~> 1.7"
64
67
  ```
65
68
 
66
69
  Or pull the latest from GitHub:
@@ -429,6 +432,59 @@ User.create(phone: "+1 (415) 555-1234").phone # => "14155551234"
429
432
 
430
433
  ---
431
434
 
435
+ ## 🔍 Searchable
436
+
437
+ LIKE-based search across one or more columns — no external search engine, no extra gems.
438
+
439
+ ```ruby
440
+ class Article < ApplicationRecord
441
+ include ConcernsOnRails::Searchable
442
+
443
+ searchable_by :title, :body
444
+ end
445
+
446
+ Article.search("hello") # WHERE title ILIKE '%hello%' OR body ILIKE '%hello%'
447
+ Article.search("") # no-op — returns the full relation
448
+ Article.search("foo").where(state: 1) # chainable like any scope
449
+ ```
450
+
451
+ **Notes**
452
+ - Uses Arel's `matches`, which emits `ILIKE` on Postgres (case-insensitive) and `LIKE` elsewhere.
453
+ - The query is escaped before interpolation — `%`, `_`, and `\` from user input are treated as literals, not wildcards.
454
+ - Blank or nil queries return the relation unchanged so it's safe to drop into a controller pipeline.
455
+ - Single-term substring match by design; reach for `pg_search` / Elasticsearch when you need ranking, stemming, or multi-term queries.
456
+
457
+ ---
458
+
459
+ ## ✅ Activatable
460
+
461
+ Boolean active/inactive toggle backed by a single column.
462
+
463
+ ```ruby
464
+ class Subscription < ApplicationRecord
465
+ include ConcernsOnRails::Activatable
466
+
467
+ activatable_by # defaults to :active
468
+ # activatable_by :enabled # custom column name
469
+ end
470
+
471
+ sub = Subscription.create!(active: true)
472
+ sub.active? # => true
473
+ sub.deactivate!
474
+ sub.inactive? # => true
475
+ sub.toggle_active! # flips back to true
476
+
477
+ Subscription.active # WHERE active = TRUE
478
+ Subscription.inactive # WHERE active = FALSE OR active IS NULL
479
+ ```
480
+
481
+ **Notes**
482
+ - `NULL` is treated as inactive (same convention as most apps' "unset = off").
483
+ - The configured column must exist; `activatable_by` raises `ArgumentError` otherwise.
484
+ - `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.
485
+
486
+ ---
487
+
432
488
  # 🎮 Controller Concerns
433
489
 
434
490
  Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
@@ -567,6 +623,51 @@ end
567
623
 
568
624
  ---
569
625
 
626
+ ## 🛟 ErrorHandleable
627
+
628
+ Install `rescue_from` handlers for the three most common controller exceptions and render them as the same JSON envelope used by Respondable.
629
+
630
+ ```ruby
631
+ class Api::BaseController < ApplicationController
632
+ include ConcernsOnRails::Controllers::Respondable # recommended
633
+ include ConcernsOnRails::Controllers::ErrorHandleable
634
+ end
635
+ ```
636
+
637
+ **Handled exceptions**
638
+
639
+ | Exception | Status | `code` |
640
+ |----------------------------------------|--------|-----------------------|
641
+ | `ActiveRecord::RecordNotFound` | 404 | `"not_found"` |
642
+ | `ActionController::ParameterMissing` | 400 | `"parameter_missing"` |
643
+ | `ActiveRecord::RecordInvalid` | 422 | `"record_invalid"` |
644
+
645
+ Response shape (matches `Respondable#render_error`):
646
+
647
+ ```json
648
+ { "success": false, "error": { "message": "...", "code": "...", "details": [...] } }
649
+ ```
650
+
651
+ **Overriding a handler**
652
+
653
+ Each handler is a public instance method, so subclasses can customize the message or response shape without re-declaring the `rescue_from`:
654
+
655
+ ```ruby
656
+ class Api::BaseController < ApplicationController
657
+ include ConcernsOnRails::Controllers::ErrorHandleable
658
+
659
+ def handle_record_not_found(error)
660
+ render json: { success: false, error: { message: "Not here, friend." } }, status: :not_found
661
+ end
662
+ end
663
+ ```
664
+
665
+ **Notes**
666
+ - 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.
667
+ - `RecordInvalid.details` are populated from `error.record.errors.full_messages`.
668
+
669
+ ---
670
+
570
671
  ## 🗂️ Module paths & namespacing
571
672
 
572
673
  Every concern is available under two paths:
@@ -598,7 +699,7 @@ Both forms reference the same module, so you can freely mix them.
598
699
  bundle install # install dev dependencies
599
700
  bundle exec rspec # run the test suite
600
701
  gem build concerns_on_rails.gemspec # build the gem
601
- gem install ./concerns_on_rails-1.6.0.gem # install locally
702
+ gem install ./concerns_on_rails-1.7.0.gem # install locally
602
703
  ```
603
704
 
604
705
  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,6 @@ 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
13
15
  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
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.6.0".freeze
2
+ VERSION = "1.7.0".freeze
3
3
  end
@@ -15,12 +15,15 @@ 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"
18
20
 
19
21
  # Controller concerns
20
22
  require "concerns_on_rails/controllers/paginatable"
21
23
  require "concerns_on_rails/controllers/filterable"
22
24
  require "concerns_on_rails/controllers/sortable"
23
25
  require "concerns_on_rails/controllers/respondable"
26
+ require "concerns_on_rails/controllers/error_handleable"
24
27
 
25
28
  # Backwards compatibility (top-level aliases for pre-1.6 module paths)
26
29
  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.7.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-05-19 00:00:00.000000000 Z
11
+ date: 2026-05-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -70,16 +70,19 @@ 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