concerns_on_rails 1.11.0 → 1.11.1
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 +15 -0
- data/README.md +11 -3
- data/lib/concerns_on_rails/models/addressable.rb +152 -12
- data/lib/concerns_on_rails/support/address_data.rb +281 -28
- data/lib/concerns_on_rails/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f8d7976f6ba1afbdd29545f992c77fe50f42771828cb01ae14ea4dc194d6f36a
|
|
4
|
+
data.tar.gz: d159c7e96f3e430e71aa9d3d18f5ce8d70323cb6c13cb8fd403c8b122c4a0fb8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: da311c75a34438a39c2bc2e2dc48916e205a15fee4aa40aeb609136ad2a26cba40b3c2ca919f5812e48aedde54bd22ac72ff778e86162e1f4ef29920468470b2
|
|
7
|
+
data.tar.gz: 9ad51a591b17b48c79afcb571090aa402338a87f491c0aba2f27b89ad0d28fd16f1e5a89b9f54f61790cc1c5ac15bec35e97698b3f7afefd157144cf513101c3
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 1.11.1 (2026-06-05)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Models::Addressable**: `addressable_by` gained four options:
|
|
7
|
+
- `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.
|
|
8
|
+
- `allow_blank:` — per-field opt-out (an Array of parts, or `true`) for the length check when a value is blank. Independent of `required:`.
|
|
9
|
+
- `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.
|
|
10
|
+
- `if:` / `unless:` — standard Rails validation conditions (Symbol, Proc, or Array) gating the address validations. Normalization still runs unconditionally.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **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.
|
|
14
|
+
|
|
15
|
+
### Internal
|
|
16
|
+
- **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.
|
|
17
|
+
|
|
3
18
|
## 1.10.0 (2026-06-03)
|
|
4
19
|
|
|
5
20
|
### Added
|
data/README.md
CHANGED
|
@@ -725,6 +725,9 @@ class Place < ApplicationRecord
|
|
|
725
725
|
required: %i[line1 city postal_code country],
|
|
726
726
|
default_country: "GB", # country used when the record has none
|
|
727
727
|
validate_state: true, # opt-in US/CA state-code check
|
|
728
|
+
lengths: { line1: 100, city: 50, postal_code: 5..10 }, # max (Int) or min..max (Range)
|
|
729
|
+
allow_blank: %i[state], # these parts skip the length check when blank
|
|
730
|
+
normalize_country: true, # "Canada"/"CAN" -> "CA" (off by default)
|
|
728
731
|
verify_with: ->(rec) { Usps.verify(rec) } # opt-in external verifier
|
|
729
732
|
end
|
|
730
733
|
```
|
|
@@ -735,20 +738,25 @@ end
|
|
|
735
738
|
|-------------------|--------------------------------------|---------------------------------------------------------------------|
|
|
736
739
|
| `line1:` … `country:` | same-named columns | Map each canonical part to a real column. Missing columns are skipped. |
|
|
737
740
|
| `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"` |
|
|
741
|
+
| `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
742
|
| `validate_state:` | `false` | When `true`, validates the state against US / CA code sets. |
|
|
743
|
+
| `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. |
|
|
744
|
+
| `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:`. |
|
|
745
|
+
| `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
746
|
| `verify_with:` | `nil` | A callable for real-world verification (see below). |
|
|
747
|
+
| `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
748
|
|
|
742
749
|
**What it normalizes** (in `before_validation`)
|
|
743
750
|
- Text parts: `strip` + `squish`.
|
|
744
751
|
- `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).
|
|
752
|
+
- `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
753
|
|
|
747
754
|
**What it validates**
|
|
748
755
|
- Presence of every `required:` part.
|
|
749
756
|
- `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.
|
|
757
|
+
- `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
758
|
- `state`: only when `validate_state: true` and the country is US/CA.
|
|
759
|
+
- `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
760
|
|
|
753
761
|
**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
762
|
|
|
@@ -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]
|
|
@@ -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
|
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.1
|
|
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
|