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 +4 -4
- data/CHANGELOG.md +29 -0
- data/README.md +83 -7
- data/lib/concerns_on_rails/legacy_aliases.rb +1 -0
- data/lib/concerns_on_rails/models/addressable.rb +152 -12
- data/lib/concerns_on_rails/models/sluggable.rb +22 -9
- data/lib/concerns_on_rails/models/soft_deletable.rb +45 -22
- data/lib/concerns_on_rails/models/sortable.rb +12 -3
- data/lib/concerns_on_rails/models/taggable.rb +160 -0
- data/lib/concerns_on_rails/support/address_data.rb +281 -28
- data/lib/concerns_on_rails/version.rb +1 -1
- data/lib/concerns_on_rails.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9eb1080168652222770c2ea933745175091c64599ea39e309220a9be8b241b1c
|
|
4
|
+
data.tar.gz: f39962ffb8a0e359d2137f1df9440be0a58a17d8c05d78181ba17d2ae1d0433b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
- **
|
|
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`).
|
|
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.
|
|
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"` |
|
|
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.
|
|
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.
|
|
@@ -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,
|
|
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, "
|
|
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!(
|
|
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
|
-
"
|
|
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
|
|
145
|
-
# country when it's a recognized
|
|
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
|
|
247
|
+
return self.class.addressable_default_country if value.blank?
|
|
150
248
|
|
|
151
|
-
code = value
|
|
152
|
-
ConcernsOnRails::Support::AddressData.valid_country?(code) ? code :
|
|
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
|
|
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
|
|
38
|
-
# sluggable_by :title, scope: :account_id
|
|
39
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
54
|
-
|
|
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,
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
14
|
-
ISO_COUNTRY_CODES
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
data/lib/concerns_on_rails.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
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
|