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