concerns_on_rails 1.28.8 → 1.29.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: 3ae4fb29ea12389fc8aaff341a565a482bf3c8ef99fdcfc7791c64c7302d1ca5
4
- data.tar.gz: 02ea08bceb25e2a479e27ef00342c69cfeac2ffc8129b689269f423819c0c0ae
3
+ metadata.gz: f00644495f4a20c3265cfa9289708136b4bb086aebeeb16026ebd2c9e12f7991
4
+ data.tar.gz: 01162f76ade27fb2b42aa93c3317819fb4db96c761717fdc20cafeedd213046f
5
5
  SHA512:
6
- metadata.gz: 4ac2edd1717ef0e4c157b851e75554ba057321e1ae9f283bcdb015e9e6d49d63f1f4bf9bc9ee4419e0b28a111487147516957a03b17ae3915c7ac23f8a721109
7
- data.tar.gz: 826dd0234e22faa23b089fbedd7900c8bc847f8f7af5670c6a6b0dafc22ec838b29e0cf196b0269b8dde798302d43c677eb4c93d60396db207d6e733d0f16ff2
6
+ metadata.gz: fad1b9c385054e869d5dcd4012f1e06182b3597c63a500d6513521fbefd758480ffcaf85922c0d805a2d1b07430f03a3d0a3fb1184f510450adfabd311155630
7
+ data.tar.gz: e7a299357dc9f389a4e9e99241af15c783806a96dc4b9037fe5283d1737038b63f53224a3c6e77e5f5b0a926e3d341df435c8c7b0d319a73258b33a489a15614
data/CHANGELOG.md CHANGED
@@ -1,5 +1,106 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.29.0 (2026-09-21)
4
+
5
+ CI now runs **every Rails line the gemspec admits** — 6.0, 6.1, 7.0, 7.1, 7.2, 8.0 and 8.1 —
6
+ where it previously ran 7.0 through 8.0. Extending the matrix downwards found 201 failures
7
+ on the two new old lines and 0 on the new top line; all of them are fixed, and all seven
8
+ legs are green and blocking.
9
+
10
+ ### Changed
11
+ - **Rails 5.x is no longer declared.** The `actionpack` / `activerecord` / `activesupport`
12
+ floor moves from `>= 5.0` to `>= 6.0`. This drops nothing that ever worked: with
13
+ `required_ruby_version = ">= 3.2.0"`, the `>= 5.0` half was unreachable by construction —
14
+ Ruby 3's keyword-argument separation breaks Active Record 5.2's own `create_table`
15
+ (`schema_statements.rb:290`), so no bundle could satisfy both. Verified rather than
16
+ assumed: on Ruby 3.2 the suite fails 1,302 examples on Rails 5.2, every one of them
17
+ inside the framework.
18
+
19
+ ### Fixed
20
+ - **Controllers::Includable**: `?fields[table]=...` raised `LocalJumpError` ("no block
21
+ given") on Rails 6.0. `requested_fields` called the block-less `params[:fields].each_pair`
22
+ and chained `with_object`, but `ActionController::Parameters#each_pair` only began
23
+ returning an Enumerator in 6.1. It now iterates with a block, which behaves the same on
24
+ every line.
25
+ - **Models::Searchable**: the generated `search` scope raised
26
+ `ArgumentError: wrong number of arguments (given 2, expected 1)` for every call on Rails
27
+ 6.0. Rails 6.0 forwards a scope's arguments through `define_method(name) { |*args| }`
28
+ with no `ruby2_keywords`, so under Ruby 3 the `ranked:` keyword reaches the body as a
29
+ trailing positional Hash. The scope now takes its options positionally and splats them
30
+ back out — same call signature, and an unknown key still raises.
31
+
32
+ ### Documented
33
+ - Two features need a line newer than the 6.0 floor, and both now say so in the README's
34
+ compatibility table and in their own docs: `Controllers::Sortable`'s `nulls:` ordering
35
+ (Rails 6.1+, already raised at macro time) and `Models::Aliasable`'s **query side**
36
+ (Rails 6.1+ — resolving a `where`-hash key to an association, which is what makes Rails
37
+ alias the join, landed in 6.1; Aliasable's read/write side works on 6.0).
38
+
39
+ ### Internal
40
+ - `gemfiles/rails_6.0.gemfile`, `rails_6.1.gemfile` and `rails_8.1.gemfile`; every gemfile
41
+ below 7.1 now pins `concurrent-ruby < 1.3.5`, which dropped its own `require "logger"`
42
+ that Active Support only started doing for itself in 7.1.
43
+ - The spec harness clears Active Record's schema cache and (on <= 6.1)
44
+ `ActiveSupport::Dependencies::Reference` between examples, gives anonymous spec models a
45
+ `model_name`, and gains a `min_rails:` metadata tag for examples whose feature is version
46
+ gated. Without the first two, specs that reuse table and model names across files saw the
47
+ PREVIOUS file's columns and classes on 6.0/6.1.
48
+
49
+ ## 1.28.9 (2026-09-19)
50
+
51
+ CI now runs the matrix the gemspec actually claims: Rails 7.0–8.0 across Ruby 3.2/3.3/3.4,
52
+ plus **PostgreSQL and MySQL** alongside SQLite. The suite had only ever run on SQLite, which
53
+ is the most permissive of the three, and the first green-on-SQLite run of the new matrix
54
+ failed 29 examples on MySQL and 7 on PostgreSQL.
55
+
56
+ Almost all of those were specs asserting SQLite's own SQL rather than the gem misbehaving —
57
+ but one was a real bug, and it is the reason this is a release and not just a CI change.
58
+
59
+ ### Fixed
60
+ - **Models::Taggable**: `tagged_with` was broken on MySQL and inconsistent on PostgreSQL.
61
+ - On **MySQL** every `tagged_with` query was a **syntax error**. The clause inlined
62
+ `ESCAPE '\'` into hand-written SQL, and MySQL treats backslash as an escape character
63
+ inside string literals, so the escape consumed its own closing quote. SQLite and
64
+ PostgreSQL (with `standard_conforming_strings`) read it as a literal backslash, which is
65
+ why this was invisible for so long. The clause is now built with Arel `matches`, so the
66
+ adapter quotes the escape character itself — one code path, no per-adapter branching.
67
+ - On **PostgreSQL** `tagged_with` was case-SENSITIVE, while on SQLite and MySQL it folded
68
+ case: the same query returned different rows depending on the database. It is now
69
+ case-insensitive on all three (`ILIKE` on PostgreSQL).
70
+
71
+ ⚠️ **PostgreSQL users: this widens `tagged_with`.** A lookup that was case-sensitive before
72
+ now also matches differently-cased tags. That is the documented intent of the concern and
73
+ it makes the three adapters agree, but it is a behaviour change on upgrade. If you relied
74
+ on case-sensitive matching, declare `taggable_by :tags, downcase: true` and fold on write.
75
+ SQLite and MySQL users see no change. Note the Ruby-side helpers (`tagged_with?`,
76
+ `all_tags`, `tag_counts`) still compare exactly — `downcase: true` is what makes the scope
77
+ and the helpers agree.
78
+
79
+ ### Internal
80
+ - **CI**: `.github/workflows/ci.yml` gains a Rails-version axis (7.0, 7.1, 7.2, 8.0 × Ruby
81
+ 3.2/3.3/3.4, via `gemfiles/*.gemfile`) and an adapter axis (PostgreSQL and MySQL service
82
+ containers). `DB=postgresql` / `DB=mysql2` selects the adapter locally; SQLite stays the
83
+ default so a plain checkout still needs no services. (#102)
84
+ - **Specs**: examples that asserted SQL now build the expected fragment from the
85
+ connection's own quoting, via new `TestDatabase.quoted_table` / `quoted_column` /
86
+ `qualified` helpers and `sqlite?` / `postgresql?` / `mysql?` predicates — so an assertion
87
+ means the same thing on every adapter instead of encoding SQLite's. Several examples got
88
+ *stronger* in the process: the Sortable NULL-ordering pair now asserts the PostgreSQL
89
+ native-`NULLS` branch and the portable-`CASE` branch separately, each also asserting the
90
+ other is absent.
91
+ - Three examples depended on SQLite-only tolerance rather than on the gem: a grouped
92
+ relation selecting `*` (rejected by PostgreSQL, and by MySQL under `only_full_group_by`),
93
+ a non-finite Float written to a float column (SQLite's adapter overrides `quote` for
94
+ those; MySQL emits a bare `NaN`), and a NULL page-boundary fixture that assumed SQLite's
95
+ NULLs-first ordering (PostgreSQL sorts NULLs last ascending).
96
+
97
+ ### Known gap
98
+ - `Models::Storable` guards its JSON extraction with `json_valid` on SQLite only. MySQL has
99
+ `JSON_VALID()` (5.7.8+) and could have the same guard; it was left alone deliberately
100
+ rather than shipped unverified, since a wrong guess there would break every
101
+ `where_<key>` query on that adapter. PostgreSQL has no equivalent before PG 16's
102
+ `IS JSON`. Now that the MySQL leg runs, this is a small follow-up.
103
+
3
104
  ## 1.28.8 (2026-09-19)
4
105
 
5
106
  The last six open feature PRs, released as a patch by request. These had never been
data/README.md CHANGED
@@ -11,7 +11,7 @@ One `include`, one declarative macro — done.
11
11
  [![CI](https://github.com/VSN2015/concerns_on_rails/actions/workflows/ci.yml/badge.svg)](https://github.com/VSN2015/concerns_on_rails/actions/workflows/ci.yml)
12
12
  [![Docs](https://img.shields.io/badge/docs-vsn2015.github.io-6f42c1?logo=readthedocs&logoColor=white)](https://vsn2015.github.io/concerns_on_rails)
13
13
  [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org)
14
- [![Rails](https://img.shields.io/badge/rails-5.0--8.x-CC0000?logo=rubyonrails&logoColor=white)](https://rubyonrails.org)
14
+ [![Rails](https://img.shields.io/badge/rails-6.0--8.1-CC0000?logo=rubyonrails&logoColor=white)](https://rubyonrails.org)
15
15
  [![License: MIT](https://img.shields.io/badge/license-MIT-3fb950.svg)](#-license)
16
16
 
17
17
  ### [📖 **Documentation**](https://vsn2015.github.io/concerns_on_rails) &nbsp;·&nbsp; [💎 **RubyGems**](https://rubygems.org/gems/concerns_on_rails) &nbsp;·&nbsp; [📝 **Changelog**](CHANGELOG.md) &nbsp;·&nbsp; [🐛 **Issues**](https://github.com/VSN2015/concerns_on_rails/issues)
@@ -148,7 +148,7 @@ across all 43 concerns — press <kbd>/</kbd> and type.
148
148
  - **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable), and both load **lazily**: an app that never includes those concerns never loads them. Depends on `activerecord`/`actionpack`/`activesupport`, not the full `rails` meta-gem; controller concerns have zero extra deps
149
149
  - **Schema-validated configuration** — every macro checks that the configured columns exist and raises `ArgumentError` early — listing *every* missing column at once, with one ready-to-paste `rails generate migration` command that adds them all
150
150
  - **Composable** — concerns are independent; mix and match per model
151
- - **Tested like an app, not a snippet** — **1,828 RSpec examples** run against a real database on every CI build
151
+ - **Tested like an app, not a snippet** — **1,830 RSpec examples** run against a real database on every CI build
152
152
  - **Documented twice** — everything in this README also lives as a per-concern page on the [docs site](https://vsn2015.github.io/concerns_on_rails), searchable and deep-linkable
153
153
 
154
154
  ---
@@ -158,7 +158,7 @@ across all 43 concerns — press <kbd>/</kbd> and type.
158
158
  Add to your application's `Gemfile`:
159
159
 
160
160
  ```ruby
161
- gem "concerns_on_rails", "~> 1.28"
161
+ gem "concerns_on_rails", "~> 1.29"
162
162
  ```
163
163
 
164
164
  Or pull the latest from GitHub:
@@ -200,7 +200,25 @@ still wins over the gem-wide fallback.
200
200
  ## 🧪 Compatibility
201
201
 
202
202
  - **Ruby**: 3.2+
203
- - **Rails**: declared 5.0 through 8.x — in practice the Ruby 3.2 requirement means Rails 7.0.4+ is what can actually install the gem; the test suite runs against Rails 7.1
203
+ - **Rails**: 6.0 through 8.1
204
+
205
+ Every one of those lines runs the full suite on every push — Rails 6.0, 6.1, 7.0, 7.1, 7.2,
206
+ 8.0 and 8.1 on SQLite, plus PostgreSQL and MySQL, and Ruby 3.3/3.4 against the current
207
+ lines. See [`.github/workflows/ci.yml`](.github/workflows/ci.yml).
208
+
209
+ **Rails 5.x is not supported**, despite what older versions of this gem declared. Rails 5
210
+ cannot run on any Ruby this gem supports: Ruby 3 separated keyword arguments, which breaks
211
+ Active Record 5.2's own `create_table`, and `required_ruby_version` here is `>= 3.2.0`. The
212
+ old `>= 5.0` dependency was therefore unreachable — no bundle could ever have satisfied
213
+ both halves of it.
214
+
215
+ Two things need a Rails line newer than the 6.0 floor. Both raise or are documented where
216
+ they apply, and nothing else in the gem is affected:
217
+
218
+ | Feature | Needs | Below that |
219
+ |---|---|---|
220
+ | `Controllers::Sortable` — `nulls:` ordering | Rails 6.1 | Raises `ArgumentError` at macro time (Arel gained the ordering nodes in 6.1) |
221
+ | `Models::Aliasable` — query side (`joins`/`where`/`eager_load` through an alias) | Rails 6.1 | Rails resolves a `where`-hash key to a table name, not an association, so the alias is not applied to the join. Readers, writers and `build_`/`create_` work on 6.0 |
204
222
 
205
223
  ---
206
224
 
@@ -765,7 +783,7 @@ User.find_by(email: User.normalize(:email, params[:email]))
765
783
  - `with:` takes a preset, a Proc, or an Array of them (applied in order); every entry is validated at class load.
766
784
  - `nil` values are skipped — no `nil → ""` coercion (use `:nullify_blank` for the opposite direction).
767
785
  - Preset normalizers pass non-string values through unchanged.
768
- - Works on Rails 5+ (no dependency on Rails 7.1's built-in `normalizes`).
786
+ - Works on Rails 6.0+ (no dependency on Rails 7.1's built-in `normalizes`).
769
787
 
770
788
  ---
771
789
 
@@ -1200,6 +1218,7 @@ Article.published.tag_counts(limit: 20) # => { "ruby" => 12, "rails" => 7
1200
1218
 
1201
1219
  **Notes**
1202
1220
  - Matching is **boundary-safe** — searching `rail` does not match `rails`. An explicit SQL `ESCAPE` clause makes tags containing `_` / `%` match literally on every adapter.
1221
+ - `tagged_with` matches **case-insensitively on every adapter** — `LIKE` on SQLite and MySQL, `ILIKE` on PostgreSQL — so one call means one thing everywhere (how non-ASCII characters fold is still the database collation's business). The Ruby-side helpers (`tagged_with?`, `all_tags`, `tag_counts`) compare exactly, so `downcase: true` — which folds on write — is what makes the scope and the helpers agree.
1203
1222
  - Tags are normalized in `before_validation`, so a direct `record.tags = "a, b"` assignment is cleaned too. An empty list stores `NULL`.
1204
1223
  - `tag_counts` runs one `GROUP BY` on the raw column — identical tag strings ship once with their row count and are split in Ruby — so it scales with distinct tag strings, not rows; ordered by count desc then name, `limit:` keeps the top N.
1205
1224
  - Reach for [`acts-as-taggable-on`](https://github.com/mbleigh/acts-as-taggable-on) when you need tag contexts, ownership, or polymorphic tags shared across models.
@@ -1428,6 +1447,10 @@ unlike Publishable/Expirable/Activatable, this one has no validators gate.
1428
1447
 
1429
1448
  ## 🪞 Aliasable
1430
1449
 
1450
+ > **Rails 6.1+ for the query side.** `joins` / `where` / `eager_load` through an alias
1451
+ > needs Rails to resolve a `where`-hash key to an association, which landed in 6.1. The
1452
+ > read/write side (readers, writers, `build_`/`create_`, the ids pair) works on 6.0.
1453
+
1431
1454
  Alias an existing association under a second name with **full** semantics — read, write/assign, build/create, and the query side (`joins` / `includes` / `where`-hash) — not just a delegated reader. (`alias_attribute` covers columns only; Rails has no built-in association aliasing.)
1432
1455
 
1433
1456
  ```ruby
@@ -2208,7 +2231,7 @@ When several rules apply to one request the `X-RateLimit-*` headers describe the
2208
2231
  - The store MUST support **atomic increment-with-expiry** (`Rails.cache` with `#increment`, or Redis) — a non-atomic store under-counts under concurrency.
2209
2232
  - There is **no in-process default store** on purpose: the first throttled request raises `ArgumentError` until you set `throttleable_store` (or the gem-wide fallback `ConcernsOnRails.setup { |c| c.cache_store = -> { Rails.cache } }`), so you never silently rate-limit per-process.
2210
2233
  - When `Respondable` is included, the 429 body delegates to `render_error` (`code: "rate_limited"`).
2211
- - Backports the essentials of Rails 7.2's `rate_limit` (with standardized headers) to Rails 5.0+. For richer rules (fail2ban, allow/deny lists, exponential backoff) reach for [`rack-attack`](https://github.com/rack/rack-attack).
2234
+ - Backports the essentials of Rails 7.2's `rate_limit` (with standardized headers) to Rails 6.0+. For richer rules (fail2ban, allow/deny lists, exponential backoff) reach for [`rack-attack`](https://github.com/rack/rack-attack).
2212
2235
 
2213
2236
  ---
2214
2237
 
@@ -2502,9 +2525,9 @@ Point your agent at `llms.txt` for an overview, or paste a single concern's `.md
2502
2525
 
2503
2526
  ```sh
2504
2527
  bundle install # install dev dependencies
2505
- bundle exec rspec # run the test suite (1,828 examples)
2528
+ bundle exec rspec # run the test suite (1,830 examples)
2506
2529
  gem build concerns_on_rails.gemspec # build the gem
2507
- gem install ./concerns_on_rails-1.28.8.gem # install locally
2530
+ gem install ./concerns_on_rails-1.29.0.gem # install locally
2508
2531
 
2509
2532
  # Preview the docs site locally (GitHub Pages serves docs/ as-is):
2510
2533
  cd docs && python3 -m http.server 8000 # → http://localhost:8000
@@ -130,17 +130,22 @@ module ConcernsOnRails
130
130
  return {} unless raw.respond_to?(:each_pair)
131
131
 
132
132
  allowed = self.class.includable_fields
133
- # Iterate via each_pair: in a real controller `raw` is an
133
+ # Iterate via each_pair WITH A BLOCK: in a real controller `raw` is an
134
134
  # ActionController::Parameters, which has each_pair but no Enumerable —
135
135
  # calling each_with_object directly on it was a guaranteed
136
- # NoMethodError 500 for every ?fields[...]= request.
137
- raw.each_pair.with_object({}) do |(table, cols), memo|
136
+ # NoMethodError 500 for every ?fields[...]= request. Its block-less
137
+ # form is no good either: Parameters#each_pair only started returning
138
+ # an Enumerator in Rails 6.1, so `raw.each_pair.with_object` raised
139
+ # LocalJumpError ("no block given") on 6.0.
140
+ memo = {}
141
+ raw.each_pair do |table, cols|
138
142
  key = table.to_sym
139
143
  next unless allowed.key?(key)
140
144
 
141
145
  permitted = split_field_list(cols) & allowed[key]
142
146
  memo[key] = permitted unless permitted.empty?
143
147
  end
148
+ memo
144
149
  end
145
150
 
146
151
  private
@@ -46,6 +46,13 @@ module ConcernsOnRails
46
46
  # must match the name you joined under (same rule as stock Rails):
47
47
  # joins(:sections).where(sections: {...}) works,
48
48
  # joins(:chapters).where(sections: {...}) does not.
49
+ # * THE QUERY SIDE NEEDS RAILS 6.1+. Resolving a where-hash key to an
50
+ # ASSOCIATION rather than to a literal table name landed in 6.1, and
51
+ # that resolution is what makes Rails alias the join to the
52
+ # reflection name. On 6.0 the same call emits
53
+ # INNER JOIN "chapters" ... WHERE "sections"... — a reference to an
54
+ # alias that was never created, which the database rejects. Readers,
55
+ # writers, build_/create_ and the ids pair all work on 6.0.
49
56
  # * The belongs_to foreign-key attribute is NOT aliased — pair with
50
57
  # Rails' alias_attribute (e.g. :writer_id, :author_id) if needed.
51
58
  # * has_and_belongs_to_many cannot be aliased — use has_many :through.
@@ -74,7 +74,14 @@ module ConcernsOnRails
74
74
  self.searchable_case_sensitive = case_sensitive
75
75
  self.searchable_ranked = ranked
76
76
 
77
- scope :search, ->(query, ranked: nil) { search_relation(query, ranked: ranked) }
77
+ # Options are taken POSITIONALLY, not as keywords. Rails 6.0 forwards a
78
+ # scope's arguments through `define_method(name) { |*args| ... }` with no
79
+ # `ruby2_keywords`, so under Ruby 3 a `ranked:` keyword reaches the body as
80
+ # a trailing positional Hash and a kwarg-taking lambda dies with
81
+ # `wrong number of arguments (given 2, expected 1)`. Splatting an options
82
+ # hash back out here behaves identically on 6.0 through 8.1 — and still
83
+ # raises on an unknown key, because `search_relation` names its keywords.
84
+ scope :search, ->(query, opts = {}) { search_relation(query, **opts) }
78
85
  end
79
86
 
80
87
  # `ranked:` nil defers to the macro's setting; true/false override it.
@@ -28,6 +28,12 @@ module ConcernsOnRails
28
28
  #
29
29
  # Notes:
30
30
  # * Matching is boundary-safe ("rail" does not match "rails").
31
+ # * `tagged_with` matches case-INsensitively on every adapter — LIKE on
32
+ # SQLite and MySQL, ILIKE on PostgreSQL — so one call means one thing
33
+ # everywhere (how non-ASCII characters fold is still the database
34
+ # collation's business). The Ruby-side helpers (`tagged_with?`,
35
+ # `all_tags`, `tag_counts`) compare exactly, so `downcase: true`, which
36
+ # folds on write, is what makes the scope and the helpers agree.
31
37
  # * A tag cannot contain the delimiter (default ",") — input containing
32
38
  # it is split into multiple tags on the spot (`add_tags("a,b")` adds
33
39
  # "a" and "b"), everywhere, so what you read back always matches what
@@ -40,6 +46,10 @@ module ConcernsOnRails
40
46
  LABEL = "ConcernsOnRails::Models::Taggable".freeze
41
47
  DEFAULT_FIELD = :tags
42
48
  DEFAULT_DELIMITER = ",".freeze
49
+ # Same LIKE-escaping contract as Models::Searchable: the adapter quotes
50
+ # the escape character for us, so a backslash is portable here.
51
+ LIKE_ESCAPE = "\\".freeze
52
+ LIKE_SPECIAL = /[\\%_]/
43
53
 
44
54
  included do
45
55
  class_attribute :taggable_field, instance_accessor: false, default: DEFAULT_FIELD
@@ -68,9 +78,10 @@ module ConcernsOnRails
68
78
  tags = taggable_clean_all(names)
69
79
  return all if tags.empty?
70
80
 
71
- clauses = tags.map { |t| taggable_clause(t) }
72
- sql = clauses.map(&:first).join(any ? " OR " : " AND ")
73
- where(sql, *clauses.flat_map(&:last))
81
+ predicates = tags.map { |tag| taggable_predicate(tag) }
82
+ return where(predicates.reduce { |memo, node| memo.or(node) }) if any
83
+
84
+ predicates.reduce(all) { |memo, node| memo.where(node) }
74
85
  end
75
86
 
76
87
  # All distinct tags currently stored across the table, sorted.
@@ -150,26 +161,36 @@ module ConcernsOnRails
150
161
  .reject(&:blank?).uniq
151
162
  end
152
163
 
153
- # Boundary-safe match for one tag against the delimiter-joined column.
154
- # Returns [sql_fragment, [bind_params...]]. An explicit ESCAPE clause makes
155
- # the backslash escaping below work on every adapter (SQLite has no default
156
- # LIKE escape), so a tag containing `_` or `%` matches literally.
157
- def taggable_clause(tag)
158
- column = "#{connection.quote_table_name(table_name)}.#{connection.quote_column_name(taggable_field)}"
159
- # Escape the delimiter too (not just the tag): a delimiter that is a LIKE
160
- # wildcard (% or _) must match literally. Use LIKE for the whole-column
161
- # branch as well, so casing is uniform across all four branches — the
162
- # previous `= ?` was case-sensitive while LIKE is not.
164
+ # Boundary-safe match for one tag against the delimiter-joined column:
165
+ # the tag alone, first, last, or somewhere in the middle. Built from
166
+ # Arel's `matches` rather than a hand-written LIKE string for two
167
+ # reasons.
168
+ #
169
+ # 1. The ESCAPE character is then quoted by the adapter. An inlined
170
+ # `ESCAPE '\'` is a syntax error on MySQL, where a backslash escapes
171
+ # its own closing quote inside a string literal, even though the very
172
+ # same text is fine on SQLite and on PostgreSQL.
173
+ # 2. `case_sensitive: false` emits ILIKE on PostgreSQL, whose LIKE —
174
+ # unlike SQLite's, and unlike MySQL's under a default _ci collation —
175
+ # is case-sensitive, so `tagged_with` used to mean something
176
+ # different there.
177
+ #
178
+ # An explicit ESCAPE is still what makes a tag containing `_` or `%`
179
+ # match literally (SQLite has no default LIKE escape).
180
+ def taggable_predicate(tag)
181
+ column = arel_table[taggable_field]
182
+ # Escape the delimiter too (not just the tag): a delimiter that is a
183
+ # LIKE wildcard (% or _) must match literally.
163
184
  delim = taggable_escape_like(taggable_delimiter)
164
185
  escaped = taggable_escape_like(tag)
165
- esc = " ESCAPE '\\'"
166
- ["(#{column} LIKE ?#{esc} OR #{column} LIKE ?#{esc} OR #{column} LIKE ?#{esc} OR #{column} LIKE ?#{esc})",
167
- [escaped, "#{escaped}#{delim}%", "%#{delim}#{escaped}", "%#{delim}#{escaped}#{delim}%"]]
186
+ [escaped, "#{escaped}#{delim}%", "%#{delim}#{escaped}", "%#{delim}#{escaped}#{delim}%"]
187
+ .map { |pattern| column.matches(pattern, LIKE_ESCAPE, false) }
188
+ .reduce { |memo, node| memo.or(node) }
168
189
  end
169
190
 
170
191
  # Treat the user's tag as a LIKE literal: %, _ and \ are not wildcards.
171
192
  def taggable_escape_like(str)
172
- str.gsub(/[\\%_]/) { |char| "\\#{char}" }
193
+ str.gsub(LIKE_SPECIAL) { |char| "#{LIKE_ESCAPE}#{char}" }
173
194
  end
174
195
  end
175
196
 
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.28.8".freeze
2
+ VERSION = "1.29.0".freeze
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: concerns_on_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.28.8
4
+ version: 1.29.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-09-19 00:00:00.000000000 Z
11
+ date: 2026-09-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actionpack
@@ -16,7 +16,7 @@ dependencies:
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: '5.0'
19
+ version: '6.0'
20
20
  - - "<"
21
21
  - !ruby/object:Gem::Version
22
22
  version: '9'
@@ -26,7 +26,7 @@ dependencies:
26
26
  requirements:
27
27
  - - ">="
28
28
  - !ruby/object:Gem::Version
29
- version: '5.0'
29
+ version: '6.0'
30
30
  - - "<"
31
31
  - !ruby/object:Gem::Version
32
32
  version: '9'
@@ -36,7 +36,7 @@ dependencies:
36
36
  requirements:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
- version: '5.0'
39
+ version: '6.0'
40
40
  - - "<"
41
41
  - !ruby/object:Gem::Version
42
42
  version: '9'
@@ -46,7 +46,7 @@ dependencies:
46
46
  requirements:
47
47
  - - ">="
48
48
  - !ruby/object:Gem::Version
49
- version: '5.0'
49
+ version: '6.0'
50
50
  - - "<"
51
51
  - !ruby/object:Gem::Version
52
52
  version: '9'
@@ -56,7 +56,7 @@ dependencies:
56
56
  requirements:
57
57
  - - ">="
58
58
  - !ruby/object:Gem::Version
59
- version: '5.0'
59
+ version: '6.0'
60
60
  - - "<"
61
61
  - !ruby/object:Gem::Version
62
62
  version: '9'
@@ -66,7 +66,7 @@ dependencies:
66
66
  requirements:
67
67
  - - ">="
68
68
  - !ruby/object:Gem::Version
69
- version: '5.0'
69
+ version: '6.0'
70
70
  - - "<"
71
71
  - !ruby/object:Gem::Version
72
72
  version: '9'