concerns_on_rails 1.11.1 → 1.11.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: f8d7976f6ba1afbdd29545f992c77fe50f42771828cb01ae14ea4dc194d6f36a
4
- data.tar.gz: d159c7e96f3e430e71aa9d3d18f5ce8d70323cb6c13cb8fd403c8b122c4a0fb8
3
+ metadata.gz: 9eb1080168652222770c2ea933745175091c64599ea39e309220a9be8b241b1c
4
+ data.tar.gz: f39962ffb8a0e359d2137f1df9440be0a58a17d8c05d78181ba17d2ae1d0433b
5
5
  SHA512:
6
- metadata.gz: da311c75a34438a39c2bc2e2dc48916e205a15fee4aa40aeb609136ad2a26cba40b3c2ca919f5812e48aedde54bd22ac72ff778e86162e1f4ef29920468470b2
7
- data.tar.gz: 9ad51a591b17b48c79afcb571090aa402338a87f491c0aba2f27b89ad0d28fd16f1e5a89b9f54f61790cc1c5ac15bec35e97698b3f7afefd157144cf513101c3
6
+ metadata.gz: ee0491e22fe01259415dbc266d19b9a2e966c53e9f0a0b3acded0aa5dd60e2e7085faeeff0b7995e6d6e1849612f1611a551bd2dbb5837f2757cb0de25ebfb72
7
+ data.tar.gz: 6297b08086c0b8417eff076393d725e40d6e469780fb8d60e5525a1b3a3a53d57fa44a6e90457f4625a7ed7012494cb80d385e5138c8b6e8b7d4836e0ed79cdd
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.11.2 (2026-06-06)
4
+
5
+ ### Added
6
+ - **Models::Taggable**: Lightweight, dependency-free tagging over a single string column (no join tables, no tagging engine; works on any database including SQLite). `taggable_by :tags` adds `tag_list` get/set (accepts a String or an Array, stripped + de-duped), `add_tags` / `remove_tags`, a `tagged_with?` predicate, a boundary-safe `tagged_with(*tags, any:)` class scope (AND by default, OR with `any: true`), and `all_tags`. Options: `delimiter:` and `downcase:`. Reach for `acts-as-taggable-on` when you need tag contexts, ownership, or tag clouds.
7
+ - **Models::SoftDeletable**: `soft_deletable_by` gained `default_scope:` (default `true`) to opt out of the deleted-hiding `default_scope`; new explicit `soft_delete_all` class method (preferred over the `destroy_all` override).
8
+ - **Models::Sluggable**: `sluggable_by` gained `reserved_words:` (reject slugs like `new` / `edit` / `admin` — saving such a record fails validation) and `finders: true` (`Model.find` accepts a slug directly), layering friendly_id's `:reserved` / `:finders` modules.
9
+ - **Models::Sortable**: `sortable_by` now threads acts_as_list's `scope:` (independent position sequence per group) and `add_new_at:` (`:top` / `:bottom`) options through to `acts_as_list`.
10
+
11
+ ### Fixed
12
+ - **Models::SoftDeletable**: `soft_delete!` / `restore!` (and the bulk `soft_delete_all` / `restore_all` / `destroy_all`) now run inside a transaction, so a raising `before_*` / `after_*` hook rolls the timestamp change back instead of leaving a half-applied state — adopting `discard`'s transactional playbook.
13
+
14
+ ### Notes
15
+ - All changes are backward-compatible: the soft-delete `default_scope` stays on by default and `destroy_all` continues to soft-delete. New models are encouraged to set `default_scope: false` and use the explicit `soft_delete_all`.
16
+
3
17
  ## 1.11.1 (2026-06-05)
4
18
 
5
19
  ### Added
data/README.md CHANGED
@@ -39,6 +39,7 @@ Article.published.without_deleted.find("hello-world")
39
39
  - [Sequenceable](#-sequenceable) — ordered, human-friendly reference numbers
40
40
  - [Stateable](#-stateable) — lightweight string-backed state machine
41
41
  - [Addressable](#-addressable) — postal address normalization + format validation
42
+ - [Taggable](#-taggable) — lightweight tagging over a single column
42
43
  - **Controller concerns**
43
44
  - [Paginatable](#-paginatable) — offset pagination with headers
44
45
  - [Filterable](#-filterable) — declarative URL-param filters
@@ -55,7 +56,7 @@ Article.published.without_deleted.find("hello-world")
55
56
 
56
57
  ## ✨ Why this gem?
57
58
 
58
- - **Fourteen model concerns + six controller concerns**, all production-ready
59
+ - **Fifteen model concerns + six controller concerns**, all production-ready
59
60
  - **One include, one macro** — no boilerplate, no glue code
60
61
  - **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
61
62
  - **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
@@ -158,6 +159,13 @@ Post.friendly.find("old-slug") # still resolves to the renamed post
158
159
 
159
160
  # Unique slug only within a scope column (same slug allowed in different accounts)
160
161
  sluggable_by :title, scope: :account_id
162
+
163
+ # Reject reserved slugs — saving a record whose slug would be reserved fails validation
164
+ sluggable_by :title, reserved_words: %w[new edit admin]
165
+
166
+ # Let Model.find accept a slug directly (not just the id)
167
+ sluggable_by :title, finders: true
168
+ Post.find("hello-world") # resolves by slug
161
169
  ```
162
170
 
163
171
  **Notes**
@@ -191,6 +199,8 @@ Task.last.move_higher
191
199
  sortable_by :priority # ascending priority
192
200
  sortable_by priority: :desc # descending priority
193
201
  sortable_by :position, use_acts_as_list: false # just the default_scope ordering, no acts_as_list
202
+ sortable_by :position, scope: :list_id # independent position sequence per list (acts_as_list scope:)
203
+ sortable_by :position, add_new_at: :top # new rows insert at the top (acts_as_list add_new_at:)
194
204
  ```
195
205
 
196
206
  **Notes**
@@ -246,7 +256,7 @@ publishable_by :published_at, default_scope: true
246
256
 
247
257
  ## ❌ SoftDeletable
248
258
 
249
- Soft delete records using a timestamp field (default: `deleted_at`). Includes a `default_scope` that hides deleted records and overrides `destroy_all` to soft-delete in bulk.
259
+ Soft delete records using a timestamp field (default: `deleted_at`). By default a `default_scope` hides deleted records — **opt out** with `soft_deletable_by :deleted_at, default_scope: false` and chain `.without_deleted` explicitly (the safer choice for new models, avoiding `default_scope`'s join/uniqueness footguns). `soft_delete!` / `restore!` and the bulk helpers run inside a transaction, so a raising hook rolls the change back.
250
260
 
251
261
  ```ruby
252
262
  class User < ApplicationRecord
@@ -280,11 +290,14 @@ User.unscoped # everything (deleted + non-deleted)
280
290
  **Bulk operations**
281
291
 
282
292
  ```ruby
283
- User.destroy_all # soft-deletes all matching records
293
+ User.soft_delete_all # soft-deletes all matching records (explicit — preferred)
294
+ User.destroy_all # alias of soft_delete_all (kept for backwards compatibility)
284
295
  User.really_destroy_all # hard-deletes all matching records
285
296
  User.restore_all # restores all soft-deleted records
286
297
  ```
287
298
 
299
+ All of these run in a transaction, so a raising hook rolls the whole batch back.
300
+
288
301
  **Lifecycle hooks** — override these methods on the model:
289
302
 
290
303
  ```ruby
@@ -775,6 +788,45 @@ end
775
788
 
776
789
  ---
777
790
 
791
+ ## 🏷️ Taggable
792
+
793
+ Lightweight, dependency-free tagging stored in a **single string column** — no join tables, no tagging engine. Works on any database, including SQLite.
794
+
795
+ ```ruby
796
+ class Article < ApplicationRecord
797
+ include ConcernsOnRails::Taggable
798
+
799
+ taggable_by :tags # default column :tags
800
+ # taggable_by :skills, downcase: true # custom column, case-folded
801
+ end
802
+
803
+ article = Article.new
804
+ article.tag_list = "Ruby, Rails, Ruby" # accepts a String or an Array
805
+ article.tag_list # => ["Ruby", "Rails"] (stripped + de-duped)
806
+ article.add_tags("api")
807
+ article.remove_tags("Rails")
808
+ article.tagged_with?("ruby") # => membership predicate
809
+ article.save!
810
+
811
+ Article.tagged_with("ruby", "rails") # records carrying BOTH tags
812
+ Article.tagged_with("ruby", "go", any: true) # records carrying ANY tag
813
+ Article.all_tags # => sorted unique tags in use
814
+ ```
815
+
816
+ **Options**
817
+
818
+ | Option | Default | Purpose |
819
+ |--------------|---------|------------------------------------------------------------------|
820
+ | `delimiter:` | `","` | Character joining the stored tags (a tag must not contain it). |
821
+ | `downcase:` | `false` | Case-fold tags on write so matching is case-insensitive. |
822
+
823
+ **Notes**
824
+ - Matching is **boundary-safe** — searching `rail` does not match `rails`. An explicit SQL `ESCAPE` clause makes tags containing `_` / `%` match literally on every adapter.
825
+ - Tags are normalized in `before_validation`, so a direct `record.tags = "a, b"` assignment is cleaned too. An empty list stores `NULL`.
826
+ - Reach for [`acts-as-taggable-on`](https://github.com/mbleigh/acts-as-taggable-on) when you need tag contexts, ownership, counts/clouds, or polymorphic tags shared across models.
827
+
828
+ ---
829
+
778
830
  # 🎮 Controller Concerns
779
831
 
780
832
  Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
@@ -1023,13 +1075,29 @@ Both forms reference the same module, so you can freely mix them.
1023
1075
 
1024
1076
  ---
1025
1077
 
1078
+ ## 🧭 Philosophy & when to reach for a dedicated gem
1079
+
1080
+ `concerns_on_rails` aims to cover the common 80% of each behavior with **one `include` + one declarative macro**, **schema-validated** configuration, **no-surprise defaults**, and **lean dependencies** (only `acts_as_list` and `friendly_id`; controller concerns have none). It is deliberately *not* a re-implementation of the category leaders — reach for a dedicated gem when you outgrow the basics:
1081
+
1082
+ | Need | Use instead |
1083
+ |------|-------------|
1084
+ | Complex state machines (callbacks, transition logging) | [`aasm`](https://github.com/aasm/aasm) |
1085
+ | Association-cascade soft delete / sentinel-aware unique indexes | [`paranoia`](https://github.com/rubysherpas/paranoia) or [`discard`](https://github.com/jhawthorn/discard) |
1086
+ | Tagging with contexts, ownership, or tag clouds | [`acts-as-taggable-on`](https://github.com/mbleigh/acts-as-taggable-on) |
1087
+ | Full-text search with ranking / stemming | [`pg_search`](https://github.com/Casecommons/pg_search) / Elasticsearch |
1088
+ | Audit trails / version history | [`paper_trail`](https://github.com/paper-trail-gem/paper_trail) / [`audited`](https://github.com/collectiveidea/audited) |
1089
+
1090
+ `Sluggable` wraps [`friendly_id`](https://github.com/norman/friendly_id) and `Sortable` wraps [`acts_as_list`](https://github.com/brendon/acts_as_list), so you get those leaders' engines behind the declarative macro.
1091
+
1092
+ ---
1093
+
1026
1094
  ## 🛠️ Development
1027
1095
 
1028
1096
  ```sh
1029
1097
  bundle install # install dev dependencies
1030
1098
  bundle exec rspec # run the test suite
1031
1099
  gem build concerns_on_rails.gemspec # build the gem
1032
- gem install ./concerns_on_rails-1.11.0.gem # install locally
1100
+ gem install ./concerns_on_rails-1.11.2.gem # install locally
1033
1101
  ```
1034
1102
 
1035
1103
  The test suite uses an in-memory SQLite database and a lightweight `FakeController` harness for controller-concern specs — no Rails routes or boot required.
@@ -16,4 +16,5 @@ module ConcernsOnRails
16
16
  Stateable = Models::Stateable
17
17
  Addressable = Models::Addressable
18
18
  Sequenceable = Models::Sequenceable
19
+ Taggable = Models::Taggable
19
20
  end
@@ -34,27 +34,40 @@ module ConcernsOnRails
34
34
  # Define sluggable field, with optional friendly_id features.
35
35
  # Example:
36
36
  # sluggable_by :wonderful_name
37
- # sluggable_by :title, history: true # old slugs keep resolving (needs a friendly_id_slugs table)
38
- # sluggable_by :title, scope: :account_id # slugs unique per scope column
39
- def sluggable_by(field, history: false, scope: nil)
37
+ # sluggable_by :title, history: true # old slugs keep resolving (needs a friendly_id_slugs table)
38
+ # sluggable_by :title, scope: :account_id # slugs unique per scope column
39
+ # sluggable_by :title, reserved_words: %w[new] # block these slugs (a UUID is appended instead)
40
+ # sluggable_by :title, finders: true # Model.find accepts a slug directly
41
+ def sluggable_by(field, history: false, scope: nil, reserved_words: nil, finders: false)
40
42
  self.sluggable_field = field.to_sym
41
43
  ensure_columns!("ConcernsOnRails::Models::Sluggable", [sluggable_field, scope].compact)
42
- reconfigure_friendly_id(history: history, scope: scope) if history || scope
44
+ return unless history || scope || reserved_words || finders
45
+
46
+ reconfigure_friendly_id(history: history, scope: scope,
47
+ reserved_words: reserved_words, finders: finders)
43
48
  end
44
49
 
45
50
  private
46
51
 
47
52
  # Re-runs friendly_id with the extra modules. friendly_id merges config
48
- # across calls, so this layers :history / :scoped onto the base :slugged.
49
- def reconfigure_friendly_id(history:, scope:)
53
+ # across calls, so this layers :history / :scoped / :finders / :reserved onto :slugged.
54
+ def reconfigure_friendly_id(history:, scope:, reserved_words: nil, finders: false)
50
55
  modules = [:slugged]
51
56
  modules << :history if history
52
57
  modules << :scoped if scope
53
- options = { use: modules }
54
- options[:scope] = scope if scope
58
+ modules << :finders if finders
59
+ modules << :reserved if reserved_words
55
60
  # friendly_id's second argument is a positional options hash (not kwargs),
56
61
  # so pass it positionally to stay correct on both Ruby 2.7 and 3.x.
57
- friendly_id(:slug_source, options)
62
+ friendly_id(:slug_source, friendly_id_options(modules, scope, reserved_words))
63
+ end
64
+
65
+ # Build friendly_id's positional options hash from the resolved modules.
66
+ def friendly_id_options(modules, scope, reserved_words)
67
+ options = { use: modules }
68
+ options[:scope] = scope if scope
69
+ options[:reserved_words] = Array(reserved_words).map(&:to_s) if reserved_words
70
+ options
58
71
  end
59
72
  end
60
73
 
@@ -9,6 +9,11 @@ module ConcernsOnRails
9
9
  # declare class attributes and set default values
10
10
  class_attribute :soft_delete_field, instance_accessor: false, default: :deleted_at
11
11
  class_attribute :soft_delete_touch, instance_accessor: false, default: true
12
+ # Whether `.all` hides soft-deleted rows via a default_scope. ON by default for
13
+ # backwards compatibility; opt out with `soft_deletable_by ..., default_scope: false`.
14
+ # A default_scope is sticky and breaks unscoped joins / uniqueness validations /
15
+ # eager-loading, so new models are encouraged to disable it and chain `.without_deleted`.
16
+ class_attribute :soft_delete_default_scope, instance_accessor: false, default: true
12
17
 
13
18
  # scopes
14
19
  scope :active, -> { unscope(where: soft_delete_field).where(soft_delete_field => nil) }
@@ -19,25 +24,35 @@ module ConcernsOnRails
19
24
  scope :with_deleted, -> { unscope(where: soft_delete_field) }
20
25
  # Records soft-deleted within the last `duration` (e.g. `deleted_within(7.days)`).
21
26
  scope :deleted_within, ->(duration) { soft_deleted.where(soft_delete_field => duration.ago..) }
22
- # Optionally, uncomment to hide deleted by default:
23
- default_scope { without_deleted }
27
+
28
+ # Hide soft-deleted rows from `.all` only when enabled (the default). The block is
29
+ # evaluated lazily, so toggling `soft_delete_default_scope` via the macro takes effect.
30
+ default_scope { soft_delete_default_scope ? without_deleted : all }
24
31
  end
25
32
 
26
33
  class_methods do
27
34
  include ConcernsOnRails::Support::ColumnGuard
28
35
 
29
- # Define soft delete field and options
36
+ # Define soft delete field and options.
30
37
  # Example:
31
38
  # soft_deletable_by :deleted_at, touch: false
32
- def soft_deletable_by(field = nil, touch: true)
39
+ # soft_deletable_by :deleted_at, default_scope: false # don't hide deleted rows from .all
40
+ def soft_deletable_by(field = nil, touch: true, default_scope: true)
33
41
  self.soft_delete_field = field || :deleted_at
34
42
  self.soft_delete_touch = touch
43
+ self.soft_delete_default_scope = default_scope
35
44
  ensure_columns!("ConcernsOnRails::Models::SoftDeletable", soft_delete_field)
36
45
  end
37
46
 
38
- # Override destroy_all to perform soft delete on all records
47
+ # Soft-delete every matching record, wrapped in a transaction so the batch is atomic.
48
+ def soft_delete_all
49
+ transaction { all.each(&:soft_delete!) }
50
+ end
51
+
52
+ # Override destroy_all to soft delete. Kept for backwards compatibility, but prefer the
53
+ # explicit `soft_delete_all` — silently redefining a standard AR method is a known footgun.
39
54
  def destroy_all
40
- all.each(&:soft_delete!)
55
+ soft_delete_all
41
56
  end
42
57
 
43
58
  # Provide really_destroy_all to hard delete all records
@@ -45,9 +60,9 @@ module ConcernsOnRails
45
60
  unscoped.delete_all
46
61
  end
47
62
 
48
- # Restore every soft-deleted record (mirror of the destroy_all override).
63
+ # Restore every soft-deleted record, atomically (mirror of soft_delete_all).
49
64
  def restore_all
50
- soft_deleted.each(&:restore!)
65
+ transaction { soft_deleted.each(&:restore!) }
51
66
  end
52
67
  end
53
68
 
@@ -60,26 +75,34 @@ module ConcernsOnRails
60
75
  def soft_delete!
61
76
  return true if deleted?
62
77
 
63
- before_soft_delete
64
- result = if self.class.soft_delete_touch
65
- update(self.class.soft_delete_field => Time.zone.now)
66
- else
67
- update_column(self.class.soft_delete_field, Time.zone.now)
68
- end
69
- after_soft_delete if result
78
+ result = false
79
+ # Wrap the timestamp change and its hooks in a transaction so a raising
80
+ # before/after hook rolls the change back instead of leaving a half-applied state.
81
+ transaction do
82
+ before_soft_delete
83
+ result = if self.class.soft_delete_touch
84
+ update(self.class.soft_delete_field => Time.zone.now)
85
+ else
86
+ update_column(self.class.soft_delete_field, Time.zone.now)
87
+ end
88
+ after_soft_delete if result
89
+ end
70
90
  result
71
91
  end
72
92
 
73
93
  def restore!
74
94
  return true unless deleted?
75
95
 
76
- before_restore
77
- result = if self.class.soft_delete_touch
78
- update(self.class.soft_delete_field => nil)
79
- else
80
- update_column(self.class.soft_delete_field, nil)
81
- end
82
- after_restore if result
96
+ result = false
97
+ transaction do
98
+ before_restore
99
+ result = if self.class.soft_delete_touch
100
+ update(self.class.soft_delete_field => nil)
101
+ else
102
+ update_column(self.class.soft_delete_field, nil)
103
+ end
104
+ after_restore if result
105
+ end
83
106
  result
84
107
  end
85
108
 
@@ -34,14 +34,16 @@ module ConcernsOnRails
34
34
  class_methods do
35
35
  include ConcernsOnRails::Support::ColumnGuard
36
36
 
37
- # Define sortable field and direction
37
+ # Define sortable field and direction.
38
38
  # Example:
39
39
  # sortable_by :position
40
40
  # sortable_by position: :asc
41
41
  # sortable_by position: :desc
42
42
  #
43
43
  # sortable_by :position, use_acts_as_list: false
44
- def sortable_by(field_config = nil, use_acts_as_list: true, **field_options)
44
+ # sortable_by :position, scope: :list_id # independent ordering within each list
45
+ # sortable_by :position, add_new_at: :top # new records go to the top of the list
46
+ def sortable_by(field_config = nil, use_acts_as_list: true, scope: nil, add_new_at: nil, **field_options)
45
47
  field_config = field_options if field_config.nil? && field_options.any?
46
48
 
47
49
  # parse field_config
@@ -56,7 +58,14 @@ module ConcernsOnRails
56
58
 
57
59
  ensure_columns!("ConcernsOnRails::Models::Sortable", sortable_field)
58
60
 
59
- acts_as_list column: sortable_field if use_acts_as_list
61
+ return unless use_acts_as_list
62
+
63
+ # Thread acts_as_list's own options through (scope: for per-group ordering,
64
+ # add_new_at: for where freshly-inserted rows land).
65
+ list_options = { column: sortable_field }
66
+ list_options[:scope] = scope unless scope.nil?
67
+ list_options[:add_new_at] = add_new_at unless add_new_at.nil?
68
+ acts_as_list(list_options)
60
69
  end
61
70
 
62
71
  private
@@ -0,0 +1,160 @@
1
+ require "active_support/concern"
2
+
3
+ module ConcernsOnRails
4
+ module Models
5
+ # Lightweight, dependency-free tagging over a single string column.
6
+ # Tags are stored delimiter-joined in one column — no join tables, no
7
+ # tagging engine — so it works on any database, including SQLite.
8
+ #
9
+ # class Article < ApplicationRecord
10
+ # include ConcernsOnRails::Taggable
11
+ #
12
+ # taggable_by :tags # default column :tags
13
+ # # taggable_by :skills, downcase: true # custom column, case-folded
14
+ # end
15
+ #
16
+ # a = Article.new
17
+ # a.tag_list = "Ruby, Rails, Ruby" # accepts a String or an Array
18
+ # a.tag_list # => ["Ruby", "Rails"] (stripped + de-duped)
19
+ # a.add_tags("api"); a.remove_tags("Rails")
20
+ # a.tagged_with?("ruby") # membership predicate
21
+ # a.save!
22
+ #
23
+ # Article.tagged_with("ruby", "rails") # records carrying BOTH tags
24
+ # Article.tagged_with("ruby", "go", any: true) # records carrying ANY tag
25
+ # Article.all_tags # sorted unique tags in use
26
+ #
27
+ # Notes:
28
+ # * Matching is boundary-safe ("rail" does not match "rails").
29
+ # * A tag must not contain the delimiter (default ",").
30
+ # * Reach for acts-as-taggable-on when you need tag contexts, ownership,
31
+ # tag counts/clouds, or polymorphic tags shared across models.
32
+ module Taggable
33
+ extend ActiveSupport::Concern
34
+
35
+ LABEL = "ConcernsOnRails::Models::Taggable".freeze
36
+ DEFAULT_FIELD = :tags
37
+ DEFAULT_DELIMITER = ",".freeze
38
+
39
+ included do
40
+ class_attribute :taggable_field, instance_accessor: false, default: DEFAULT_FIELD
41
+ class_attribute :taggable_delimiter, instance_accessor: false, default: DEFAULT_DELIMITER
42
+ class_attribute :taggable_downcase, instance_accessor: false, default: false
43
+ end
44
+
45
+ # Real module (not `class_methods do`) so the private query helpers live
46
+ # under a single `private`. ActiveSupport::Concern auto-extends ClassMethods.
47
+ module ClassMethods
48
+ include ConcernsOnRails::Support::ColumnGuard
49
+
50
+ # Configure the tag column. See the module docs for the DSL.
51
+ def taggable_by(field = DEFAULT_FIELD, delimiter: DEFAULT_DELIMITER, downcase: false)
52
+ self.taggable_field = field.to_sym
53
+ self.taggable_delimiter = delimiter.to_s
54
+ self.taggable_downcase = downcase
55
+ ensure_columns!(LABEL, taggable_field)
56
+
57
+ before_validation :taggable_normalize!
58
+ end
59
+
60
+ # Records carrying the given tags. `any: true` matches ANY tag (OR);
61
+ # the default requires ALL tags (AND). Returns a chainable relation.
62
+ def tagged_with(*names, any: false)
63
+ tags = taggable_clean_all(names)
64
+ return all if tags.empty?
65
+
66
+ clauses = tags.map { |t| taggable_clause(t) }
67
+ sql = clauses.map(&:first).join(any ? " OR " : " AND ")
68
+ where(sql, *clauses.flat_map(&:last))
69
+ end
70
+
71
+ # All distinct tags currently stored across the table, sorted.
72
+ def all_tags
73
+ pluck(taggable_field).flat_map { |raw| taggable_split(raw) }.uniq.sort
74
+ end
75
+
76
+ # Split a raw stored column value into a normalized tag array.
77
+ def taggable_split(raw)
78
+ taggable_clean_all(raw.to_s.split(taggable_delimiter))
79
+ end
80
+
81
+ # Normalize a single tag (strip + optional downcase).
82
+ def taggable_clean(tag)
83
+ tag = tag.to_s.strip
84
+ taggable_downcase ? tag.downcase : tag
85
+ end
86
+
87
+ private
88
+
89
+ def taggable_clean_all(names)
90
+ names.flatten.map { |t| taggable_clean(t) }.reject(&:blank?).uniq
91
+ end
92
+
93
+ # Boundary-safe match for one tag against the delimiter-joined column.
94
+ # Returns [sql_fragment, [bind_params...]]. An explicit ESCAPE clause makes
95
+ # the backslash escaping below work on every adapter (SQLite has no default
96
+ # LIKE escape), so a tag containing `_` or `%` matches literally.
97
+ def taggable_clause(tag)
98
+ column = "#{connection.quote_table_name(table_name)}.#{connection.quote_column_name(taggable_field)}"
99
+ delim = taggable_delimiter
100
+ escaped = taggable_escape_like(tag)
101
+ 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}%"]]
104
+ end
105
+
106
+ # Treat the user's tag as a LIKE literal: %, _ and \ are not wildcards.
107
+ def taggable_escape_like(str)
108
+ str.gsub(/[\\%_]/) { |char| "\\#{char}" }
109
+ end
110
+ end
111
+
112
+ # ---- instance methods ----
113
+
114
+ def tag_list
115
+ self.class.taggable_split(self[self.class.taggable_field])
116
+ end
117
+
118
+ def tag_list=(value)
119
+ tags = taggable_coerce(value)
120
+ self[self.class.taggable_field] = tags.empty? ? nil : tags.join(self.class.taggable_delimiter)
121
+ end
122
+
123
+ def add_tags(*names)
124
+ self.tag_list = tag_list + names.flatten.map { |t| self.class.taggable_clean(t) }
125
+ tag_list
126
+ end
127
+ alias add_tag add_tags
128
+
129
+ def remove_tags(*names)
130
+ drop = names.flatten.map { |t| self.class.taggable_clean(t) }
131
+ self.tag_list = tag_list.reject { |t| drop.include?(t) }
132
+ tag_list
133
+ end
134
+ alias remove_tag remove_tags
135
+
136
+ def tagged_with?(tag)
137
+ tag_list.include?(self.class.taggable_clean(tag))
138
+ end
139
+ alias has_tag? tagged_with?
140
+
141
+ private
142
+
143
+ # before_validation hook — re-normalize whatever sits in the column, covering
144
+ # direct `record.tags = "..."` assignment, not just the tag_list= setter.
145
+ def taggable_normalize!
146
+ field = self.class.taggable_field
147
+ raw = self[field]
148
+ return if raw.nil?
149
+
150
+ tags = self.class.taggable_split(raw)
151
+ self[field] = tags.empty? ? nil : tags.join(self.class.taggable_delimiter)
152
+ end
153
+
154
+ def taggable_coerce(value)
155
+ items = value.is_a?(Array) ? value : value.to_s.split(self.class.taggable_delimiter)
156
+ items.map { |t| self.class.taggable_clean(t) }.reject(&:blank?).uniq
157
+ end
158
+ end
159
+ end
160
+ end
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.11.1".freeze
2
+ VERSION = "1.11.2".freeze
3
3
  end
@@ -28,6 +28,7 @@ require "concerns_on_rails/models/tokenizable"
28
28
  require "concerns_on_rails/models/stateable"
29
29
  require "concerns_on_rails/models/addressable"
30
30
  require "concerns_on_rails/models/sequenceable"
31
+ require "concerns_on_rails/models/taggable"
31
32
 
32
33
  # Controller concerns
33
34
  require "concerns_on_rails/controllers/paginatable"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: concerns_on_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.11.1
4
+ version: 1.11.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Nguyen
@@ -90,6 +90,7 @@ files:
90
90
  - lib/concerns_on_rails/models/soft_deletable.rb
91
91
  - lib/concerns_on_rails/models/sortable.rb
92
92
  - lib/concerns_on_rails/models/stateable.rb
93
+ - lib/concerns_on_rails/models/taggable.rb
93
94
  - lib/concerns_on_rails/models/tokenizable.rb
94
95
  - lib/concerns_on_rails/support/address_data.rb
95
96
  - lib/concerns_on_rails/support/column_guard.rb