concerns_on_rails 1.28.8 → 1.28.9

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: f292a3f1cf7d1fae3b1e47be9daa416ae0b6c8b69ce0d4fd2efd583496f2d9aa
4
+ data.tar.gz: 661ffce3f3fa4fb305957b239fbb560752ea22f369da3268d03575efb35701da
5
5
  SHA512:
6
- metadata.gz: 4ac2edd1717ef0e4c157b851e75554ba057321e1ae9f283bcdb015e9e6d49d63f1f4bf9bc9ee4419e0b28a111487147516957a03b17ae3915c7ac23f8a721109
7
- data.tar.gz: 826dd0234e22faa23b089fbedd7900c8bc847f8f7af5670c6a6b0dafc22ec838b29e0cf196b0269b8dde798302d43c677eb4c93d60396db207d6e733d0f16ff2
6
+ metadata.gz: a49931859e637327e55313e7473236a4ceca6a5f61c01f7f701a7d7acba317c8fe6a83ec05ec2dafa5d7e61da45d8d2623a26f15a876d9e7d12036cd9c40c451
7
+ data.tar.gz: 625395f36729c2d789b303cfeec6537dfa173364b70c0a6c971707616a0fb3161d5eef083417aadcc9586a7b99c89d9a7617355e08284cdb76d210a62a3df82f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,60 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.28.9 (2026-09-19)
4
+
5
+ CI now runs the matrix the gemspec actually claims: Rails 7.0–8.0 across Ruby 3.2/3.3/3.4,
6
+ plus **PostgreSQL and MySQL** alongside SQLite. The suite had only ever run on SQLite, which
7
+ is the most permissive of the three, and the first green-on-SQLite run of the new matrix
8
+ failed 29 examples on MySQL and 7 on PostgreSQL.
9
+
10
+ Almost all of those were specs asserting SQLite's own SQL rather than the gem misbehaving —
11
+ but one was a real bug, and it is the reason this is a release and not just a CI change.
12
+
13
+ ### Fixed
14
+ - **Models::Taggable**: `tagged_with` was broken on MySQL and inconsistent on PostgreSQL.
15
+ - On **MySQL** every `tagged_with` query was a **syntax error**. The clause inlined
16
+ `ESCAPE '\'` into hand-written SQL, and MySQL treats backslash as an escape character
17
+ inside string literals, so the escape consumed its own closing quote. SQLite and
18
+ PostgreSQL (with `standard_conforming_strings`) read it as a literal backslash, which is
19
+ why this was invisible for so long. The clause is now built with Arel `matches`, so the
20
+ adapter quotes the escape character itself — one code path, no per-adapter branching.
21
+ - On **PostgreSQL** `tagged_with` was case-SENSITIVE, while on SQLite and MySQL it folded
22
+ case: the same query returned different rows depending on the database. It is now
23
+ case-insensitive on all three (`ILIKE` on PostgreSQL).
24
+
25
+ ⚠️ **PostgreSQL users: this widens `tagged_with`.** A lookup that was case-sensitive before
26
+ now also matches differently-cased tags. That is the documented intent of the concern and
27
+ it makes the three adapters agree, but it is a behaviour change on upgrade. If you relied
28
+ on case-sensitive matching, declare `taggable_by :tags, downcase: true` and fold on write.
29
+ SQLite and MySQL users see no change. Note the Ruby-side helpers (`tagged_with?`,
30
+ `all_tags`, `tag_counts`) still compare exactly — `downcase: true` is what makes the scope
31
+ and the helpers agree.
32
+
33
+ ### Internal
34
+ - **CI**: `.github/workflows/ci.yml` gains a Rails-version axis (7.0, 7.1, 7.2, 8.0 × Ruby
35
+ 3.2/3.3/3.4, via `gemfiles/*.gemfile`) and an adapter axis (PostgreSQL and MySQL service
36
+ containers). `DB=postgresql` / `DB=mysql2` selects the adapter locally; SQLite stays the
37
+ default so a plain checkout still needs no services. (#102)
38
+ - **Specs**: examples that asserted SQL now build the expected fragment from the
39
+ connection's own quoting, via new `TestDatabase.quoted_table` / `quoted_column` /
40
+ `qualified` helpers and `sqlite?` / `postgresql?` / `mysql?` predicates — so an assertion
41
+ means the same thing on every adapter instead of encoding SQLite's. Several examples got
42
+ *stronger* in the process: the Sortable NULL-ordering pair now asserts the PostgreSQL
43
+ native-`NULLS` branch and the portable-`CASE` branch separately, each also asserting the
44
+ other is absent.
45
+ - Three examples depended on SQLite-only tolerance rather than on the gem: a grouped
46
+ relation selecting `*` (rejected by PostgreSQL, and by MySQL under `only_full_group_by`),
47
+ a non-finite Float written to a float column (SQLite's adapter overrides `quote` for
48
+ those; MySQL emits a bare `NaN`), and a NULL page-boundary fixture that assumed SQLite's
49
+ NULLs-first ordering (PostgreSQL sorts NULLs last ascending).
50
+
51
+ ### Known gap
52
+ - `Models::Storable` guards its JSON extraction with `json_valid` on SQLite only. MySQL has
53
+ `JSON_VALID()` (5.7.8+) and could have the same guard; it was left alone deliberately
54
+ rather than shipped unverified, since a wrong guess there would break every
55
+ `where_<key>` query on that adapter. PostgreSQL has no equivalent before PG 16's
56
+ `IS JSON`. Now that the MySQL leg runs, this is a small follow-up.
57
+
3
58
  ## 1.28.8 (2026-09-19)
4
59
 
5
60
  The last six open feature PRs, released as a patch by request. These had never been
data/README.md CHANGED
@@ -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
  ---
@@ -1200,6 +1200,7 @@ Article.published.tag_counts(limit: 20) # => { "ruby" => 12, "rails" => 7
1200
1200
 
1201
1201
  **Notes**
1202
1202
  - Matching is **boundary-safe** — searching `rail` does not match `rails`. An explicit SQL `ESCAPE` clause makes tags containing `_` / `%` match literally on every adapter.
1203
+ - `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
1204
  - Tags are normalized in `before_validation`, so a direct `record.tags = "a, b"` assignment is cleaned too. An empty list stores `NULL`.
1204
1205
  - `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
1206
  - 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.
@@ -2502,9 +2503,9 @@ Point your agent at `llms.txt` for an overview, or paste a single concern's `.md
2502
2503
 
2503
2504
  ```sh
2504
2505
  bundle install # install dev dependencies
2505
- bundle exec rspec # run the test suite (1,828 examples)
2506
+ bundle exec rspec # run the test suite (1,830 examples)
2506
2507
  gem build concerns_on_rails.gemspec # build the gem
2507
- gem install ./concerns_on_rails-1.28.8.gem # install locally
2508
+ gem install ./concerns_on_rails-1.28.9.gem # install locally
2508
2509
 
2509
2510
  # Preview the docs site locally (GitHub Pages serves docs/ as-is):
2510
2511
  cd docs && python3 -m http.server 8000 # → http://localhost:8000
@@ -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.28.9".freeze
3
3
  end
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.28.8
4
+ version: 1.28.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Nguyen