concerns_on_rails 1.11.0 → 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: a34c6e186b582806890012bb0f8bf049c1fd0f56f74007af7669cdb5ae713eb2
4
- data.tar.gz: 298f957409be23ecfd9bbd0de46161ac56af7a01a6a300d29cf3bff9ca2b94d3
3
+ metadata.gz: 9eb1080168652222770c2ea933745175091c64599ea39e309220a9be8b241b1c
4
+ data.tar.gz: f39962ffb8a0e359d2137f1df9440be0a58a17d8c05d78181ba17d2ae1d0433b
5
5
  SHA512:
6
- metadata.gz: 6a96e094791d487cd9a534e4263de4bd902af9b533a8607ceb20906d4b38827c82ee0b370cb885d8a5800aef8a03a9b84cc718dc00074da1c0b1f35ade4fc2b3
7
- data.tar.gz: e803d31da5694c6ad1a0ea28d928f9d552e75ded84420aa94cb816538e4cbe9b4a228f2b9dfa9428a8091b369444909d750523d2c5a0e9edae1f2df6fec95a78
6
+ metadata.gz: ee0491e22fe01259415dbc266d19b9a2e966c53e9f0a0b3acded0aa5dd60e2e7085faeeff0b7995e6d6e1849612f1611a551bd2dbb5837f2757cb0de25ebfb72
7
+ data.tar.gz: 6297b08086c0b8417eff076393d725e40d6e469780fb8d60e5525a1b3a3a53d57fa44a6e90457f4625a7ed7012494cb80d385e5138c8b6e8b7d4836e0ed79cdd
data/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
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
+
17
+ ## 1.11.1 (2026-06-05)
18
+
19
+ ### Added
20
+ - **Models::Addressable**: `addressable_by` gained four options:
21
+ - `lengths:` — per-part length limits (`{ line1: 100, city: 3..50 }`); an Integer is a positive maximum, a Range is `min..max` (inclusive/exclusive, endless, and beginless all supported). Length is measured on the normalized value; messages mirror Rails (singular/plural). Bad bounds raise an `ArgumentError` at load time.
22
+ - `allow_blank:` — per-field opt-out (an Array of parts, or `true`) for the length check when a value is blank. Independent of `required:`.
23
+ - `normalize_country:` — opt-in canonicalization of a country value to its ISO 3166-1 alpha-2 code: a recognized English name (`"Canada"`) or 3-letter alpha-3 (`"CAN"`) maps to the alpha-2 (`"CA"`); unrecognized values are left untouched. Lets postal/state validation recognize a named country.
24
+ - `if:` / `unless:` — standard Rails validation conditions (Symbol, Proc, or Array) gating the address validations. Normalization still runs unconditionally.
25
+
26
+ ### Fixed
27
+ - **Models::Addressable**: a present-but-unrecognized country (e.g. a full name with `normalize_country` off, or an invalid code) no longer borrows the `default_country`'s postal/state rules. It now falls back to the permissive postal pattern and skips state validation, so valid foreign postal codes aren't rejected against the wrong country. `default_country` still applies when the country column is absent or blank.
28
+
29
+ ### Internal
30
+ - **Support::AddressData**: added `COUNTRY_DATA` (all 249 ISO 3166-1 countries → `[name, alpha-3]`) as the single source of truth; `ISO_COUNTRY_CODES` and the name / alpha-3 lookups are derived from it, and a new `normalize_country_code` backs the country normalization.
31
+
3
32
  ## 1.10.0 (2026-06-03)
4
33
 
5
34
  ### 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
@@ -725,6 +738,9 @@ class Place < ApplicationRecord
725
738
  required: %i[line1 city postal_code country],
726
739
  default_country: "GB", # country used when the record has none
727
740
  validate_state: true, # opt-in US/CA state-code check
741
+ lengths: { line1: 100, city: 50, postal_code: 5..10 }, # max (Int) or min..max (Range)
742
+ allow_blank: %i[state], # these parts skip the length check when blank
743
+ normalize_country: true, # "Canada"/"CAN" -> "CA" (off by default)
728
744
  verify_with: ->(rec) { Usps.verify(rec) } # opt-in external verifier
729
745
  end
730
746
  ```
@@ -735,20 +751,25 @@ end
735
751
  |-------------------|--------------------------------------|---------------------------------------------------------------------|
736
752
  | `line1:` … `country:` | same-named columns | Map each canonical part to a real column. Missing columns are skipped. |
737
753
  | `required:` | `%i[line1 city postal_code country]` | Parts (by canonical name) that must be present. Each must map to an existing column. |
738
- | `default_country:`| `"US"` | Country used to pick the postal-code format when the record has no recognized country. |
754
+ | `default_country:`| `"US"` | Postal-code format used when the country column is **absent or blank**. A present-but-unrecognized country value falls back to the permissive pattern instead see the postal note below. |
739
755
  | `validate_state:` | `false` | When `true`, validates the state against US / CA code sets. |
756
+ | `lengths:` | `{}` | Per-part length limits: `{ line1: 100, city: 50, postal_code: 5..10 }`. An Integer is a (positive) maximum; a Range is `min..max` (non-negative, satisfiable; endless `3..` and beginless `..50` allowed). Only the parts you list are checked. Bad bounds raise an `ArgumentError` at load time. |
757
+ | `allow_blank:` | `false` | Per-field opt-out for the length check: an Array of parts (e.g. `%i[line2 state]`), or `true` for all parts. A blank value for an allowed part skips its length check. Independent of `required:`. |
758
+ | `normalize_country:` | `false` | When `true`, canonicalize the country to its ISO 3166-1 alpha-2 code: an English name (`"Canada"`, `"United States"`) or a 3-letter alpha-3 (`"CAN"`, `"USA"`) maps to the alpha-2 (`"CA"`, `"US"`); unrecognized values are left untouched. This also lets postal/state validation recognize a named country. |
740
759
  | `verify_with:` | `nil` | A callable for real-world verification (see below). |
760
+ | `if:` / `unless:` | `nil` | Standard Rails validation conditions (Symbol, Proc, or Array) gating the address **validations** — e.g. `if: :on_addresses?`. Normalization still runs unconditionally. |
741
761
 
742
762
  **What it normalizes** (in `before_validation`)
743
763
  - Text parts: `strip` + `squish`.
744
764
  - `postal_code`: squish + upcase, with canonical spacing for CA (`A1A1A1` → `A1A 1A1`).
745
- - `country` / `state`: upcased when they look like a 2-letter code (full names left alone).
765
+ - `country` / `state`: upcased when they look like a 2-letter code (full names left alone). With `normalize_country: true`, a recognized ISO country name or alpha-3 code is canonicalized to its alpha-2 (`"Canada"`/`"CAN"` → `"CA"`); unrecognized values are left as-is.
746
766
 
747
767
  **What it validates**
748
768
  - Presence of every `required:` part.
749
769
  - `country`: a 2-letter value must be a real ISO 3166-1 alpha-2 code.
750
- - `postal_code`: matched against a per-country pattern (US, CA, GB, AU, DE, FR) with a permissive fallback for everything else.
770
+ - `postal_code`: matched against a per-country pattern (US, CA, GB, AU, DE, FR) with a permissive fallback for everything else. A strict per-country pattern is only applied when the country is a recognized ISO alpha-2 code (or `default_country` when the country column is absent/blank); a present-but-unrecognized country (e.g. a full name like `"Canada"`) uses the permissive pattern, so valid foreign codes aren't rejected against the wrong country.
751
771
  - `state`: only when `validate_state: true` and the country is US/CA.
772
+ - `lengths`: each listed part's length (measured on the **normalized** value) must fit its `min..max` (Integer = max only). Errors mirror Rails, including singular/plural: `"is too short (minimum is N characters)"` / `"is too long (maximum is 1 character)"`. A blank value satisfies a max-only rule and, by default, **fails a minimum greater than 0** — list the part in `allow_blank:` to skip the check when blank. `allow_blank` is independent of `required:`: presence is still governed solely by `required:`, so a required part with a minimum that's left blank reports both `"can't be blank"` and `"is too short …"`. Note length is counted *after* normalization, so a CA `postal_code` includes the inserted space (`"A1A 1A1"` is 7) — size limits accordingly.
752
773
 
753
774
  **External verification (`verify_with:`)** — runs **only after** structural validation passes, so you never spend an API call on an obviously-broken address. The callable receives the record and may either add to `record.errors` itself, or return:
754
775
 
@@ -767,6 +788,45 @@ end
767
788
 
768
789
  ---
769
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
+
770
830
  # 🎮 Controller Concerns
771
831
 
772
832
  Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
@@ -1015,13 +1075,29 @@ Both forms reference the same module, so you can freely mix them.
1015
1075
 
1016
1076
  ---
1017
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
+
1018
1094
  ## 🛠️ Development
1019
1095
 
1020
1096
  ```sh
1021
1097
  bundle install # install dev dependencies
1022
1098
  bundle exec rspec # run the test suite
1023
1099
  gem build concerns_on_rails.gemspec # build the gem
1024
- gem install ./concerns_on_rails-1.11.0.gem # install locally
1100
+ gem install ./concerns_on_rails-1.11.2.gem # install locally
1025
1101
  ```
1026
1102
 
1027
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
@@ -13,7 +13,10 @@ module ConcernsOnRails
13
13
  # addressable_by # standard line1/line2/city/state/postal_code/country columns
14
14
  # # addressable_by line1: :street, postal_code: :zip, country: :country_code,
15
15
  # # required: %i[line1 city postal_code], default_country: "GB",
16
- # # validate_state: true, verify_with: ->(rec) { Usps.verify(rec) }
16
+ # # validate_state: true, verify_with: ->(rec) { Usps.verify(rec) },
17
+ # # lengths: { line1: 100, postal_code: 5..10 }, allow_blank: %i[state],
18
+ # # normalize_country: true, # "Canada"/"CAN" -> "CA"
19
+ # # if: :on_addresses? # Rails-style condition gating the validations
17
20
  # end
18
21
  #
19
22
  # Scope is *format/structure* only — it checks shape, not real-world
@@ -31,15 +34,23 @@ module ConcernsOnRails
31
34
  # Parts required by default (each must map to an existing column).
32
35
  DEFAULT_REQUIRED = %i[line1 city postal_code country].freeze
33
36
 
37
+ # Prefix for the ArgumentErrors raised during configuration.
38
+ LABEL = "ConcernsOnRails::Models::Addressable".freeze
39
+
34
40
  included do
35
41
  class_attribute :addressable_fields, instance_accessor: false, default: {}
36
42
  class_attribute :addressable_required, instance_accessor: false, default: [].freeze
37
43
  class_attribute :addressable_default_country, instance_accessor: false, default: "US"
38
44
  class_attribute :addressable_validate_state, instance_accessor: false, default: false
39
45
  class_attribute :addressable_verifier, instance_accessor: false, default: nil
46
+ class_attribute :addressable_lengths, instance_accessor: false, default: {}
47
+ class_attribute :addressable_allow_blank, instance_accessor: false, default: [].freeze
48
+ class_attribute :addressable_normalize_country, instance_accessor: false, default: false
49
+ class_attribute :addressable_validation_registered, instance_accessor: false, default: false
40
50
 
51
+ # `validate :validate_address` is registered by `addressable_by` (not here) so it can
52
+ # carry the optional if:/unless: condition. Normalization always runs.
41
53
  before_validation :normalize_address
42
- validate :validate_address
43
54
  end
44
55
 
45
56
  # Defined as a real module (not `class_methods do`) so the public macro and
@@ -51,23 +62,29 @@ module ConcernsOnRails
51
62
  # Configure the address. Column overrides are passed as `part: :column`
52
63
  # keyword pairs; everything else tunes behavior. See the module docs.
53
64
  def addressable_by(required: DEFAULT_REQUIRED, default_country: "US",
54
- validate_state: false, verify_with: nil, **mapping)
65
+ validate_state: false, verify_with: nil,
66
+ lengths: {}, allow_blank: false, normalize_country: false, **mapping)
67
+ condition = extract_validation_condition!(mapping)
55
68
  self.addressable_fields = resolve_addressable_fields(mapping)
56
69
  self.addressable_required = Array(required).map(&:to_sym)
57
70
  self.addressable_default_country = default_country.to_s.upcase
58
71
  self.addressable_validate_state = validate_state
59
72
  self.addressable_verifier = verify_with
73
+ self.addressable_lengths = resolve_lengths(lengths)
74
+ self.addressable_allow_blank = resolve_allow_blank(allow_blank)
75
+ self.addressable_normalize_country = normalize_country
60
76
  ensure_required_columns!
77
+ register_address_validation(condition)
61
78
  end
62
79
 
63
80
  private
64
81
 
65
82
  def resolve_addressable_fields(mapping)
66
83
  unknown = mapping.keys.map(&:to_sym) - DEFAULT_FIELDS.keys
67
- raise ArgumentError, "ConcernsOnRails::Models::Addressable: unknown address part(s): #{unknown.join(', ')}" if unknown.any?
84
+ raise ArgumentError, "#{LABEL}: unknown address part(s): #{unknown.join(', ')}" if unknown.any?
68
85
 
69
86
  overrides = mapping.to_h { |part, column| [part.to_sym, column.to_sym] }
70
- ensure_columns!("ConcernsOnRails::Models::Addressable", overrides.values)
87
+ ensure_columns!(LABEL, overrides.values)
71
88
  DEFAULT_FIELDS.merge(overrides).select { |_part, column| column_names.include?(column.to_s) }
72
89
  end
73
90
 
@@ -76,9 +93,86 @@ module ConcernsOnRails
76
93
  return if missing.empty?
77
94
 
78
95
  raise ArgumentError,
79
- "ConcernsOnRails::Models::Addressable: required address part(s) #{missing.join(', ')} " \
96
+ "#{LABEL}: required address part(s) #{missing.join(', ')} " \
80
97
  "have no matching column (table: #{table_name})"
81
98
  end
99
+
100
+ # Normalize the lengths: option into { part => [min, max] }, where min
101
+ # defaults to 0 and max to Infinity so the validator needs no nil guards.
102
+ def resolve_lengths(lengths)
103
+ raise ArgumentError, "#{LABEL}: lengths: must be a Hash (part => Integer or Range)" unless lengths.is_a?(Hash)
104
+
105
+ lengths.to_h do |part, bound|
106
+ sym = part.to_sym
107
+ raise ArgumentError, "#{LABEL}: unknown address part in lengths: #{sym}" unless DEFAULT_FIELDS.key?(sym)
108
+
109
+ [sym, normalize_length_bound(sym, bound)]
110
+ end
111
+ end
112
+
113
+ def normalize_length_bound(part, bound)
114
+ case bound
115
+ when Integer
116
+ raise ArgumentError, "#{LABEL}: lengths[#{part}] must be a positive Integer" unless bound.positive?
117
+
118
+ [0, bound]
119
+ when Range then range_bounds(part, bound)
120
+ else
121
+ raise ArgumentError, "#{LABEL}: lengths[#{part}] must be an Integer or Range, got #{bound.class}"
122
+ end
123
+ end
124
+
125
+ # A Range becomes [min, max] (min defaults to 0, an open end becomes
126
+ # Infinity). Bounds must be non-negative Integers and the range must be
127
+ # satisfiable (min <= max) — otherwise a typo would silently brick a column.
128
+ def range_bounds(part, range)
129
+ min = range.begin || 0
130
+ max = range.end
131
+ validate_range_endpoints!(part, min, max)
132
+ max -= 1 if max && range.exclude_end?
133
+ max ||= Float::INFINITY
134
+ raise ArgumentError, "#{LABEL}: lengths[#{part}] range is empty or inverted (#{range})" if min > max
135
+
136
+ [min, max]
137
+ end
138
+
139
+ def validate_range_endpoints!(part, min, max)
140
+ return if min.is_a?(Integer) && !min.negative? && (max.nil? || (max.is_a?(Integer) && !max.negative?))
141
+
142
+ raise ArgumentError, "#{LABEL}: lengths[#{part}] range must have non-negative Integer bounds"
143
+ end
144
+
145
+ # Normalize allow_blank: into the list of parts whose length check is skipped when blank.
146
+ def resolve_allow_blank(allow_blank)
147
+ case allow_blank
148
+ when true then DEFAULT_FIELDS.keys
149
+ when false, nil then []
150
+ when Array
151
+ parts = allow_blank.map(&:to_sym)
152
+ unknown = parts - DEFAULT_FIELDS.keys
153
+ raise ArgumentError, "#{LABEL}: unknown address part(s) in allow_blank: #{unknown.join(', ')}" if unknown.any?
154
+
155
+ parts
156
+ else
157
+ raise ArgumentError, "#{LABEL}: allow_blank: must be true, false, or an Array of parts"
158
+ end
159
+ end
160
+
161
+ # Pull Rails-style `if:` / `unless:` out of the keyword args (they ride in via
162
+ # **mapping) so the remaining keys are treated as column overrides.
163
+ def extract_validation_condition!(mapping)
164
+ { if: mapping.delete(:if), unless: mapping.delete(:unless) }.compact
165
+ end
166
+
167
+ # Register `validate :validate_address` once, forwarding any if:/unless: condition
168
+ # straight to Rails so it behaves like a normal conditional validation. Normalization
169
+ # (before_validation) is unconditional; the condition only gates the validations.
170
+ def register_address_validation(condition)
171
+ return if addressable_validation_registered
172
+
173
+ self.addressable_validation_registered = true
174
+ validate :validate_address, **condition
175
+ end
82
176
  end
83
177
 
84
178
  # --- Normalization (before_validation) ------------------------------------
@@ -97,6 +191,7 @@ module ConcernsOnRails
97
191
 
98
192
  def validate_address
99
193
  validate_required_parts
194
+ validate_lengths
100
195
  validate_country_code
101
196
  validate_postal_code
102
197
  validate_state_code if self.class.addressable_validate_state
@@ -141,26 +236,46 @@ module ConcernsOnRails
141
236
  DEFAULT_FIELDS.keys.select { |part| self.class.addressable_fields.key?(part) }
142
237
  end
143
238
 
144
- # The 2-letter country code driving postal/state checks: the record's own
145
- # country when it's a recognized code, otherwise the configured default.
239
+ # The country driving postal-/state-format selection:
240
+ # * the record's own country when it's a recognized ISO alpha-2 code
241
+ # * the configured default_country when the country column is absent or blank
242
+ # * nil ("unknown") when a country is present but unrecognized — so postal/state
243
+ # fall back to permissive checks instead of wrongly applying the default's rules
146
244
  def resolved_country
147
245
  column = self.class.addressable_fields[:country]
148
246
  value = column && self[column]
149
- return self.class.addressable_default_country unless value.is_a?(String)
247
+ return self.class.addressable_default_country if value.blank?
150
248
 
151
- code = value.strip.upcase
152
- ConcernsOnRails::Support::AddressData.valid_country?(code) ? code : self.class.addressable_default_country
249
+ code = canonical_country_code(value)
250
+ ConcernsOnRails::Support::AddressData.valid_country?(code) ? code : nil
251
+ end
252
+
253
+ # The country value as an alpha-2 candidate, applying name/alpha-3 mapping
254
+ # when normalize_country is on so postal/state checks recognize it too.
255
+ def canonical_country_code(value)
256
+ return ConcernsOnRails::Support::AddressData.normalize_country_code(value).to_s.upcase if self.class.addressable_normalize_country
257
+
258
+ value.to_s.strip.upcase
153
259
  end
154
260
 
155
261
  def normalize_part(part, country, value)
156
262
  squished = value.strip.squish
157
263
  case part
158
264
  when :postal_code then ConcernsOnRails::Support::AddressData.normalize_postal(country, value)
159
- when :country, :state then squished.length == 2 ? squished.upcase : squished
265
+ when :country then normalize_country_part(squished)
266
+ when :state then squished.length == 2 ? squished.upcase : squished
160
267
  else squished
161
268
  end
162
269
  end
163
270
 
271
+ # With normalize_country on, map a name/alpha-3 to its alpha-2 (leaving an
272
+ # unrecognized value untouched); otherwise just upcase a bare 2-letter code.
273
+ def normalize_country_part(squished)
274
+ return ConcernsOnRails::Support::AddressData.normalize_country_code(squished) if self.class.addressable_normalize_country
275
+
276
+ squished.length == 2 ? squished.upcase : squished
277
+ end
278
+
164
279
  def validate_required_parts
165
280
  fields = self.class.addressable_fields
166
281
  self.class.addressable_required.each do |part|
@@ -169,6 +284,31 @@ module ConcernsOnRails
169
284
  end
170
285
  end
171
286
 
287
+ def validate_lengths
288
+ self.class.addressable_lengths.each { |part, bounds| validate_length_of(part, bounds) }
289
+ end
290
+
291
+ # Enforce one part's [min, max] bounds. A blank value skips the check only
292
+ # when the part is in allow_blank; otherwise the minimum is enforced on blanks
293
+ # (independent of required:). Length is measured on the normalized value.
294
+ def validate_length_of(part, (min, max))
295
+ column = self.class.addressable_fields[part]
296
+ return unless column
297
+
298
+ value = self[column]
299
+ return if value.blank? && self.class.addressable_allow_blank.include?(part)
300
+
301
+ length = value.to_s.length
302
+ errors.add(column, "is too short (minimum is #{length_phrase(min)})") if length < min
303
+ errors.add(column, "is too long (maximum is #{length_phrase(max)})") if length > max
304
+ end
305
+
306
+ # "1 character" / "N characters" — mirrors Rails' pluralized length errors
307
+ # without depending on i18n. (max is only interpolated for finite bounds.)
308
+ def length_phrase(count)
309
+ "#{count} #{count == 1 ? 'character' : 'characters'}"
310
+ end
311
+
172
312
  def validate_country_code
173
313
  column = self.class.addressable_fields[:country]
174
314
  value = column && self[column]
@@ -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
@@ -10,34 +10,270 @@ module ConcernsOnRails
10
10
  module AddressData
11
11
  module_function
12
12
 
13
- # ISO 3166-1 alpha-2 country codes.
14
- ISO_COUNTRY_CODES = Set.new(%w[
15
- AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ
16
- BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ
17
- CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ
18
- DE DJ DK DM DO DZ
19
- EC EE EG EH ER ES ET
20
- FI FJ FK FM FO FR
21
- GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY
22
- HK HM HN HR HT HU
23
- ID IE IL IM IN IO IQ IR IS IT
24
- JE JM JO JP
25
- KE KG KH KI KM KN KP KR KW KY KZ
26
- LA LB LC LI LK LR LS LT LU LV LY
27
- MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ
28
- NA NC NE NF NG NI NL NO NP NR NU NZ
29
- OM
30
- PA PE PF PG PH PK PL PM PN PR PS PT PW PY
31
- QA
32
- RE RO RS RU RW
33
- SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ
34
- TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ
35
- UA UG UM US UY UZ
36
- VA VC VE VG VI VN VU
37
- WF WS
38
- YE YT
39
- ZA ZM ZW
40
- ]).freeze
13
+ # Source of truth for ISO 3166-1: alpha-2 => [English name, alpha-3].
14
+ # ISO_COUNTRY_CODES and the name/alpha-3 lookups below are all derived
15
+ # from this, so the three never drift apart.
16
+ # Each value is a [name, alpha-3] pair (a tuple), not a list of words.
17
+ # rubocop:disable Style/WordArray
18
+ COUNTRY_DATA = {
19
+ "AD" => ["Andorra", "AND"],
20
+ "AE" => ["United Arab Emirates", "ARE"],
21
+ "AF" => ["Afghanistan", "AFG"],
22
+ "AG" => ["Antigua and Barbuda", "ATG"],
23
+ "AI" => ["Anguilla", "AIA"],
24
+ "AL" => ["Albania", "ALB"],
25
+ "AM" => ["Armenia", "ARM"],
26
+ "AO" => ["Angola", "AGO"],
27
+ "AQ" => ["Antarctica", "ATA"],
28
+ "AR" => ["Argentina", "ARG"],
29
+ "AS" => ["American Samoa", "ASM"],
30
+ "AT" => ["Austria", "AUT"],
31
+ "AU" => ["Australia", "AUS"],
32
+ "AW" => ["Aruba", "ABW"],
33
+ "AX" => ["Åland Islands", "ALA"],
34
+ "AZ" => ["Azerbaijan", "AZE"],
35
+ "BA" => ["Bosnia and Herzegovina", "BIH"],
36
+ "BB" => ["Barbados", "BRB"],
37
+ "BD" => ["Bangladesh", "BGD"],
38
+ "BE" => ["Belgium", "BEL"],
39
+ "BF" => ["Burkina Faso", "BFA"],
40
+ "BG" => ["Bulgaria", "BGR"],
41
+ "BH" => ["Bahrain", "BHR"],
42
+ "BI" => ["Burundi", "BDI"],
43
+ "BJ" => ["Benin", "BEN"],
44
+ "BL" => ["Saint Barthélemy", "BLM"],
45
+ "BM" => ["Bermuda", "BMU"],
46
+ "BN" => ["Brunei", "BRN"],
47
+ "BO" => ["Bolivia", "BOL"],
48
+ "BQ" => ["Caribbean Netherlands", "BES"],
49
+ "BR" => ["Brazil", "BRA"],
50
+ "BS" => ["Bahamas", "BHS"],
51
+ "BT" => ["Bhutan", "BTN"],
52
+ "BV" => ["Bouvet Island", "BVT"],
53
+ "BW" => ["Botswana", "BWA"],
54
+ "BY" => ["Belarus", "BLR"],
55
+ "BZ" => ["Belize", "BLZ"],
56
+ "CA" => ["Canada", "CAN"],
57
+ "CC" => ["Cocos (Keeling) Islands", "CCK"],
58
+ "CD" => ["Democratic Republic of the Congo", "COD"],
59
+ "CF" => ["Central African Republic", "CAF"],
60
+ "CG" => ["Republic of the Congo", "COG"],
61
+ "CH" => ["Switzerland", "CHE"],
62
+ "CI" => ["Côte d'Ivoire", "CIV"],
63
+ "CK" => ["Cook Islands", "COK"],
64
+ "CL" => ["Chile", "CHL"],
65
+ "CM" => ["Cameroon", "CMR"],
66
+ "CN" => ["China", "CHN"],
67
+ "CO" => ["Colombia", "COL"],
68
+ "CR" => ["Costa Rica", "CRI"],
69
+ "CU" => ["Cuba", "CUB"],
70
+ "CV" => ["Cape Verde", "CPV"],
71
+ "CW" => ["Curaçao", "CUW"],
72
+ "CX" => ["Christmas Island", "CXR"],
73
+ "CY" => ["Cyprus", "CYP"],
74
+ "CZ" => ["Czechia", "CZE"],
75
+ "DE" => ["Germany", "DEU"],
76
+ "DJ" => ["Djibouti", "DJI"],
77
+ "DK" => ["Denmark", "DNK"],
78
+ "DM" => ["Dominica", "DMA"],
79
+ "DO" => ["Dominican Republic", "DOM"],
80
+ "DZ" => ["Algeria", "DZA"],
81
+ "EC" => ["Ecuador", "ECU"],
82
+ "EE" => ["Estonia", "EST"],
83
+ "EG" => ["Egypt", "EGY"],
84
+ "EH" => ["Western Sahara", "ESH"],
85
+ "ER" => ["Eritrea", "ERI"],
86
+ "ES" => ["Spain", "ESP"],
87
+ "ET" => ["Ethiopia", "ETH"],
88
+ "FI" => ["Finland", "FIN"],
89
+ "FJ" => ["Fiji", "FJI"],
90
+ "FK" => ["Falkland Islands", "FLK"],
91
+ "FM" => ["Micronesia", "FSM"],
92
+ "FO" => ["Faroe Islands", "FRO"],
93
+ "FR" => ["France", "FRA"],
94
+ "GA" => ["Gabon", "GAB"],
95
+ "GB" => ["United Kingdom", "GBR"],
96
+ "GD" => ["Grenada", "GRD"],
97
+ "GE" => ["Georgia", "GEO"],
98
+ "GF" => ["French Guiana", "GUF"],
99
+ "GG" => ["Guernsey", "GGY"],
100
+ "GH" => ["Ghana", "GHA"],
101
+ "GI" => ["Gibraltar", "GIB"],
102
+ "GL" => ["Greenland", "GRL"],
103
+ "GM" => ["Gambia", "GMB"],
104
+ "GN" => ["Guinea", "GIN"],
105
+ "GP" => ["Guadeloupe", "GLP"],
106
+ "GQ" => ["Equatorial Guinea", "GNQ"],
107
+ "GR" => ["Greece", "GRC"],
108
+ "GS" => ["South Georgia and the South Sandwich Islands", "SGS"],
109
+ "GT" => ["Guatemala", "GTM"],
110
+ "GU" => ["Guam", "GUM"],
111
+ "GW" => ["Guinea-Bissau", "GNB"],
112
+ "GY" => ["Guyana", "GUY"],
113
+ "HK" => ["Hong Kong", "HKG"],
114
+ "HM" => ["Heard Island and McDonald Islands", "HMD"],
115
+ "HN" => ["Honduras", "HND"],
116
+ "HR" => ["Croatia", "HRV"],
117
+ "HT" => ["Haiti", "HTI"],
118
+ "HU" => ["Hungary", "HUN"],
119
+ "ID" => ["Indonesia", "IDN"],
120
+ "IE" => ["Ireland", "IRL"],
121
+ "IL" => ["Israel", "ISR"],
122
+ "IM" => ["Isle of Man", "IMN"],
123
+ "IN" => ["India", "IND"],
124
+ "IO" => ["British Indian Ocean Territory", "IOT"],
125
+ "IQ" => ["Iraq", "IRQ"],
126
+ "IR" => ["Iran", "IRN"],
127
+ "IS" => ["Iceland", "ISL"],
128
+ "IT" => ["Italy", "ITA"],
129
+ "JE" => ["Jersey", "JEY"],
130
+ "JM" => ["Jamaica", "JAM"],
131
+ "JO" => ["Jordan", "JOR"],
132
+ "JP" => ["Japan", "JPN"],
133
+ "KE" => ["Kenya", "KEN"],
134
+ "KG" => ["Kyrgyzstan", "KGZ"],
135
+ "KH" => ["Cambodia", "KHM"],
136
+ "KI" => ["Kiribati", "KIR"],
137
+ "KM" => ["Comoros", "COM"],
138
+ "KN" => ["Saint Kitts and Nevis", "KNA"],
139
+ "KP" => ["North Korea", "PRK"],
140
+ "KR" => ["South Korea", "KOR"],
141
+ "KW" => ["Kuwait", "KWT"],
142
+ "KY" => ["Cayman Islands", "CYM"],
143
+ "KZ" => ["Kazakhstan", "KAZ"],
144
+ "LA" => ["Laos", "LAO"],
145
+ "LB" => ["Lebanon", "LBN"],
146
+ "LC" => ["Saint Lucia", "LCA"],
147
+ "LI" => ["Liechtenstein", "LIE"],
148
+ "LK" => ["Sri Lanka", "LKA"],
149
+ "LR" => ["Liberia", "LBR"],
150
+ "LS" => ["Lesotho", "LSO"],
151
+ "LT" => ["Lithuania", "LTU"],
152
+ "LU" => ["Luxembourg", "LUX"],
153
+ "LV" => ["Latvia", "LVA"],
154
+ "LY" => ["Libya", "LBY"],
155
+ "MA" => ["Morocco", "MAR"],
156
+ "MC" => ["Monaco", "MCO"],
157
+ "MD" => ["Moldova", "MDA"],
158
+ "ME" => ["Montenegro", "MNE"],
159
+ "MF" => ["Saint Martin", "MAF"],
160
+ "MG" => ["Madagascar", "MDG"],
161
+ "MH" => ["Marshall Islands", "MHL"],
162
+ "MK" => ["North Macedonia", "MKD"],
163
+ "ML" => ["Mali", "MLI"],
164
+ "MM" => ["Myanmar", "MMR"],
165
+ "MN" => ["Mongolia", "MNG"],
166
+ "MO" => ["Macao", "MAC"],
167
+ "MP" => ["Northern Mariana Islands", "MNP"],
168
+ "MQ" => ["Martinique", "MTQ"],
169
+ "MR" => ["Mauritania", "MRT"],
170
+ "MS" => ["Montserrat", "MSR"],
171
+ "MT" => ["Malta", "MLT"],
172
+ "MU" => ["Mauritius", "MUS"],
173
+ "MV" => ["Maldives", "MDV"],
174
+ "MW" => ["Malawi", "MWI"],
175
+ "MX" => ["Mexico", "MEX"],
176
+ "MY" => ["Malaysia", "MYS"],
177
+ "MZ" => ["Mozambique", "MOZ"],
178
+ "NA" => ["Namibia", "NAM"],
179
+ "NC" => ["New Caledonia", "NCL"],
180
+ "NE" => ["Niger", "NER"],
181
+ "NF" => ["Norfolk Island", "NFK"],
182
+ "NG" => ["Nigeria", "NGA"],
183
+ "NI" => ["Nicaragua", "NIC"],
184
+ "NL" => ["Netherlands", "NLD"],
185
+ "NO" => ["Norway", "NOR"],
186
+ "NP" => ["Nepal", "NPL"],
187
+ "NR" => ["Nauru", "NRU"],
188
+ "NU" => ["Niue", "NIU"],
189
+ "NZ" => ["New Zealand", "NZL"],
190
+ "OM" => ["Oman", "OMN"],
191
+ "PA" => ["Panama", "PAN"],
192
+ "PE" => ["Peru", "PER"],
193
+ "PF" => ["French Polynesia", "PYF"],
194
+ "PG" => ["Papua New Guinea", "PNG"],
195
+ "PH" => ["Philippines", "PHL"],
196
+ "PK" => ["Pakistan", "PAK"],
197
+ "PL" => ["Poland", "POL"],
198
+ "PM" => ["Saint Pierre and Miquelon", "SPM"],
199
+ "PN" => ["Pitcairn Islands", "PCN"],
200
+ "PR" => ["Puerto Rico", "PRI"],
201
+ "PS" => ["Palestine", "PSE"],
202
+ "PT" => ["Portugal", "PRT"],
203
+ "PW" => ["Palau", "PLW"],
204
+ "PY" => ["Paraguay", "PRY"],
205
+ "QA" => ["Qatar", "QAT"],
206
+ "RE" => ["Réunion", "REU"],
207
+ "RO" => ["Romania", "ROU"],
208
+ "RS" => ["Serbia", "SRB"],
209
+ "RU" => ["Russia", "RUS"],
210
+ "RW" => ["Rwanda", "RWA"],
211
+ "SA" => ["Saudi Arabia", "SAU"],
212
+ "SB" => ["Solomon Islands", "SLB"],
213
+ "SC" => ["Seychelles", "SYC"],
214
+ "SD" => ["Sudan", "SDN"],
215
+ "SE" => ["Sweden", "SWE"],
216
+ "SG" => ["Singapore", "SGP"],
217
+ "SH" => ["Saint Helena", "SHN"],
218
+ "SI" => ["Slovenia", "SVN"],
219
+ "SJ" => ["Svalbard and Jan Mayen", "SJM"],
220
+ "SK" => ["Slovakia", "SVK"],
221
+ "SL" => ["Sierra Leone", "SLE"],
222
+ "SM" => ["San Marino", "SMR"],
223
+ "SN" => ["Senegal", "SEN"],
224
+ "SO" => ["Somalia", "SOM"],
225
+ "SR" => ["Suriname", "SUR"],
226
+ "SS" => ["South Sudan", "SSD"],
227
+ "ST" => ["São Tomé and Príncipe", "STP"],
228
+ "SV" => ["El Salvador", "SLV"],
229
+ "SX" => ["Sint Maarten", "SXM"],
230
+ "SY" => ["Syria", "SYR"],
231
+ "SZ" => ["Eswatini", "SWZ"],
232
+ "TC" => ["Turks and Caicos Islands", "TCA"],
233
+ "TD" => ["Chad", "TCD"],
234
+ "TF" => ["French Southern Territories", "ATF"],
235
+ "TG" => ["Togo", "TGO"],
236
+ "TH" => ["Thailand", "THA"],
237
+ "TJ" => ["Tajikistan", "TJK"],
238
+ "TK" => ["Tokelau", "TKL"],
239
+ "TL" => ["Timor-Leste", "TLS"],
240
+ "TM" => ["Turkmenistan", "TKM"],
241
+ "TN" => ["Tunisia", "TUN"],
242
+ "TO" => ["Tonga", "TON"],
243
+ "TR" => ["Turkey", "TUR"],
244
+ "TT" => ["Trinidad and Tobago", "TTO"],
245
+ "TV" => ["Tuvalu", "TUV"],
246
+ "TW" => ["Taiwan", "TWN"],
247
+ "TZ" => ["Tanzania", "TZA"],
248
+ "UA" => ["Ukraine", "UKR"],
249
+ "UG" => ["Uganda", "UGA"],
250
+ "UM" => ["United States Minor Outlying Islands", "UMI"],
251
+ "US" => ["United States", "USA"],
252
+ "UY" => ["Uruguay", "URY"],
253
+ "UZ" => ["Uzbekistan", "UZB"],
254
+ "VA" => ["Vatican City", "VAT"],
255
+ "VC" => ["Saint Vincent and the Grenadines", "VCT"],
256
+ "VE" => ["Venezuela", "VEN"],
257
+ "VG" => ["British Virgin Islands", "VGB"],
258
+ "VI" => ["U.S. Virgin Islands", "VIR"],
259
+ "VN" => ["Vietnam", "VNM"],
260
+ "VU" => ["Vanuatu", "VUT"],
261
+ "WF" => ["Wallis and Futuna", "WLF"],
262
+ "WS" => ["Samoa", "WSM"],
263
+ "YE" => ["Yemen", "YEM"],
264
+ "YT" => ["Mayotte", "MYT"],
265
+ "ZA" => ["South Africa", "ZAF"],
266
+ "ZM" => ["Zambia", "ZMB"],
267
+ "ZW" => ["Zimbabwe", "ZWE"]
268
+ }.freeze
269
+ # rubocop:enable Style/WordArray
270
+
271
+ # ISO 3166-1 alpha-2 country codes (derived from COUNTRY_DATA).
272
+ ISO_COUNTRY_CODES = Set.new(COUNTRY_DATA.keys).freeze
273
+
274
+ # Downcased English country name => alpha-2, and alpha-3 => alpha-2.
275
+ NAME_TO_ALPHA2 = COUNTRY_DATA.to_h { |code, (name, _a3)| [name.downcase, code] }.freeze
276
+ ALPHA3_TO_ALPHA2 = COUNTRY_DATA.to_h { |code, (_name, a3)| [a3, code] }.freeze
41
277
 
42
278
  # Per-country postal-code patterns (matched against the *normalized*,
43
279
  # upcased value). `:default` is a permissive fallback for everything else.
@@ -97,6 +333,23 @@ module ConcernsOnRails
97
333
  compact = normalized.delete(" ")
98
334
  compact.match?(/\A[A-Z]\d[A-Z]\d[A-Z]\d\z/) ? "#{compact[0, 3]} #{compact[3, 3]}" : normalized
99
335
  end
336
+
337
+ # Canonicalize a country value to its ISO 3166-1 alpha-2 code: an existing
338
+ # alpha-2 is upcased; a 3-letter alpha-3 (e.g. "USA") and a recognized
339
+ # English name (e.g. "Canada") map to the alpha-2. Unrecognized values are
340
+ # returned unchanged, and non-strings pass through.
341
+ def normalize_country_code(value)
342
+ return value unless value.is_a?(String)
343
+
344
+ trimmed = value.strip.squish
345
+ return value if trimmed.empty?
346
+
347
+ upper = trimmed.upcase
348
+ return upper if ISO_COUNTRY_CODES.include?(upper)
349
+ return ALPHA3_TO_ALPHA2[upper] if ALPHA3_TO_ALPHA2.key?(upper)
350
+
351
+ NAME_TO_ALPHA2[trimmed.downcase] || value
352
+ end
100
353
  end
101
354
  end
102
355
  end
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.11.0".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,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.11.0
4
+ version: 1.11.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-06-03 00:00:00.000000000 Z
11
+ date: 2026-06-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -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