concerns_on_rails 1.9.0 → 1.10.0
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 +8 -0
- data/README.md +73 -1
- data/lib/concerns_on_rails/legacy_aliases.rb +1 -0
- data/lib/concerns_on_rails/models/addressable.rb +212 -0
- data/lib/concerns_on_rails/support/address_data.rb +102 -0
- data/lib/concerns_on_rails/version.rb +1 -1
- data/lib/concerns_on_rails.rb +2 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 743380a3be200eda289e97edef227f8b900550ba08af52c4a06b995fc5c8297a
|
|
4
|
+
data.tar.gz: 7e8e8cb4bfd923819ec96178d75e68378829fb0f80b5a2e3cf3cd84e150fcc32
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 48e77b0bb95ee7419207289be7e1f74166f43aa4e31f4af6a6269ac2a84ccb90ac38e81a2a11a44cdafb7bd678d48153070c41d502b37f0a6baeabfd114f73f0
|
|
7
|
+
data.tar.gz: bb03d4bcfb6b26b7963f70cdaee8e591293fc8f96e34aa636aab84df6ce20647ae9df01e3d52554c8b6a9cb52cfce68c2da2c0f967ede2a3ec7960be737f4bd2
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 1.10.0 (2026-06-03)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Models::Addressable**: Declarative postal-address normalization + format validation via a single `addressable_by` macro. Maps the canonical parts (`line1` / `line2` / `city` / `state` / `postal_code` / `country`) onto real columns — any subset works, missing columns are skipped, and required parts are schema-checked. Normalizes in `before_validation` (strip + squish, postal-code upcasing with canonical CA spacing, 2-letter country/state codes upcased) and validates required-part presence, ISO 3166-1 alpha-2 country codes, per-country postal formats (US/CA/GB/AU/DE/FR + a permissive fallback), and opt-in US/CA state codes. Offline and dependency-free; layer real deliverability checks via the opt-in `verify_with:` callable. Adds helpers `full_address`, `address_lines`, `address_present?`, `address_complete?`, and `address_attributes`.
|
|
7
|
+
|
|
8
|
+
### Internal
|
|
9
|
+
- Added `ConcernsOnRails::Support::AddressData` — ISO 3166-1 alpha-2 country codes, per-country postal-format patterns, US state / CA province sets, and the case-insensitive lookups (`valid_country?`, `postal_format_for`, `valid_state?`, `normalize_postal`) backing the Addressable concern.
|
|
10
|
+
|
|
3
11
|
## 1.9.0 (2026-05-25)
|
|
4
12
|
|
|
5
13
|
### Added
|
data/README.md
CHANGED
|
@@ -37,6 +37,7 @@ Article.published.without_deleted.find("hello-world")
|
|
|
37
37
|
- [Activatable](#-activatable) — boolean active/inactive toggle
|
|
38
38
|
- [Tokenizable](#-tokenizable) — security tokens with timing-safe lookup
|
|
39
39
|
- [Stateable](#-stateable) — lightweight string-backed state machine
|
|
40
|
+
- [Addressable](#-addressable) — postal address normalization + format validation
|
|
40
41
|
- **Controller concerns**
|
|
41
42
|
- [Paginatable](#-paginatable) — offset pagination with headers
|
|
42
43
|
- [Filterable](#-filterable) — declarative URL-param filters
|
|
@@ -53,7 +54,7 @@ Article.published.without_deleted.find("hello-world")
|
|
|
53
54
|
|
|
54
55
|
## ✨ Why this gem?
|
|
55
56
|
|
|
56
|
-
- **
|
|
57
|
+
- **Thirteen model concerns + six controller concerns**, all production-ready
|
|
57
58
|
- **One include, one macro** — no boilerplate, no glue code
|
|
58
59
|
- **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
|
|
59
60
|
- **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
|
|
@@ -630,6 +631,77 @@ stateable_by :state, states: %i[open closed], prefix: true
|
|
|
630
631
|
|
|
631
632
|
---
|
|
632
633
|
|
|
634
|
+
## 🏠 Addressable
|
|
635
|
+
|
|
636
|
+
Normalize and format-validate a postal address spread across several columns — one macro for whitespace cleanup, postal-code and ISO country-code checks, required-part presence, and a `full_address` helper. No external geocoding service required.
|
|
637
|
+
|
|
638
|
+
```ruby
|
|
639
|
+
class Location < ApplicationRecord
|
|
640
|
+
include ConcernsOnRails::Addressable
|
|
641
|
+
|
|
642
|
+
addressable_by # standard columns: line1, line2, city, state, postal_code, country
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
loc = Location.create(line1: " 1 Infinite Loop ", city: "Cupertino",
|
|
646
|
+
state: "ca", postal_code: "95014", country: "us")
|
|
647
|
+
loc.line1 # => "1 Infinite Loop" (stripped + squished)
|
|
648
|
+
loc.state # => "CA" (2-letter code upcased)
|
|
649
|
+
loc.country # => "US"
|
|
650
|
+
loc.full_address # => "1 Infinite Loop, Cupertino, CA, 95014, US"
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
Map onto your own column names and tune behavior:
|
|
654
|
+
|
|
655
|
+
```ruby
|
|
656
|
+
class Place < ApplicationRecord
|
|
657
|
+
include ConcernsOnRails::Addressable
|
|
658
|
+
|
|
659
|
+
addressable_by line1: :street, postal_code: :zip, country: :country_code,
|
|
660
|
+
required: %i[line1 city postal_code country],
|
|
661
|
+
default_country: "GB", # country used when the record has none
|
|
662
|
+
validate_state: true, # opt-in US/CA state-code check
|
|
663
|
+
verify_with: ->(rec) { Usps.verify(rec) } # opt-in external verifier
|
|
664
|
+
end
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
**Options**
|
|
668
|
+
|
|
669
|
+
| Option | Default | Purpose |
|
|
670
|
+
|-------------------|--------------------------------------|---------------------------------------------------------------------|
|
|
671
|
+
| `line1:` … `country:` | same-named columns | Map each canonical part to a real column. Missing columns are skipped. |
|
|
672
|
+
| `required:` | `%i[line1 city postal_code country]` | Parts (by canonical name) that must be present. Each must map to an existing column. |
|
|
673
|
+
| `default_country:`| `"US"` | Country used to pick the postal-code format when the record has no recognized country. |
|
|
674
|
+
| `validate_state:` | `false` | When `true`, validates the state against US / CA code sets. |
|
|
675
|
+
| `verify_with:` | `nil` | A callable for real-world verification (see below). |
|
|
676
|
+
|
|
677
|
+
**What it normalizes** (in `before_validation`)
|
|
678
|
+
- Text parts: `strip` + `squish`.
|
|
679
|
+
- `postal_code`: squish + upcase, with canonical spacing for CA (`A1A1A1` → `A1A 1A1`).
|
|
680
|
+
- `country` / `state`: upcased when they look like a 2-letter code (full names left alone).
|
|
681
|
+
|
|
682
|
+
**What it validates**
|
|
683
|
+
- Presence of every `required:` part.
|
|
684
|
+
- `country`: a 2-letter value must be a real ISO 3166-1 alpha-2 code.
|
|
685
|
+
- `postal_code`: matched against a per-country pattern (US, CA, GB, AU, DE, FR) with a permissive fallback for everything else.
|
|
686
|
+
- `state`: only when `validate_state: true` and the country is US/CA.
|
|
687
|
+
|
|
688
|
+
**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:
|
|
689
|
+
|
|
690
|
+
| Return value | Effect |
|
|
691
|
+
|-------------------|-------------------------------------------------|
|
|
692
|
+
| `true` / `nil` | success |
|
|
693
|
+
| `false` | adds a generic `:base` error |
|
|
694
|
+
| `String` | added as a `:base` error |
|
|
695
|
+
| `Array` | each element added as a `:base` error |
|
|
696
|
+
|
|
697
|
+
**Notes**
|
|
698
|
+
- Scope is **format/structure only** — it checks shape, not real-world deliverability. Plug a USPS/Google/Smarty client into `verify_with:` for that.
|
|
699
|
+
- Error messages are plain English strings — no host-app i18n setup required.
|
|
700
|
+
- Partial schemas just work: a model without a `line2` (or any other) column simply omits that part.
|
|
701
|
+
- Pairs with [Normalizable](#-normalizable) when you also have non-address fields to clean up.
|
|
702
|
+
|
|
703
|
+
---
|
|
704
|
+
|
|
633
705
|
# 🎮 Controller Concerns
|
|
634
706
|
|
|
635
707
|
Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
|
|
3
|
+
module ConcernsOnRails
|
|
4
|
+
module Models
|
|
5
|
+
# Declarative normalization + format validation for a postal address spread
|
|
6
|
+
# across several columns. One macro wires up whitespace cleanup, postal-code
|
|
7
|
+
# and ISO country-code checks, required-part presence, and a `full_address`
|
|
8
|
+
# helper — no external geocoding service required.
|
|
9
|
+
#
|
|
10
|
+
# class Location < ApplicationRecord
|
|
11
|
+
# include ConcernsOnRails::Addressable
|
|
12
|
+
#
|
|
13
|
+
# addressable_by # standard line1/line2/city/state/postal_code/country columns
|
|
14
|
+
# # addressable_by line1: :street, postal_code: :zip, country: :country_code,
|
|
15
|
+
# # required: %i[line1 city postal_code], default_country: "GB",
|
|
16
|
+
# # validate_state: true, verify_with: ->(rec) { Usps.verify(rec) }
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# Scope is *format/structure* only — it checks shape, not real-world
|
|
20
|
+
# deliverability. Layer a real verifier on via `verify_with:`. Relates to
|
|
21
|
+
# the per-field Normalizable concern.
|
|
22
|
+
module Addressable
|
|
23
|
+
extend ActiveSupport::Concern
|
|
24
|
+
|
|
25
|
+
# Canonical address part => default column name.
|
|
26
|
+
DEFAULT_FIELDS = {
|
|
27
|
+
line1: :line1, line2: :line2, city: :city,
|
|
28
|
+
state: :state, postal_code: :postal_code, country: :country
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
# Parts required by default (each must map to an existing column).
|
|
32
|
+
DEFAULT_REQUIRED = %i[line1 city postal_code country].freeze
|
|
33
|
+
|
|
34
|
+
included do
|
|
35
|
+
class_attribute :addressable_fields, instance_accessor: false, default: {}
|
|
36
|
+
class_attribute :addressable_required, instance_accessor: false, default: [].freeze
|
|
37
|
+
class_attribute :addressable_default_country, instance_accessor: false, default: "US"
|
|
38
|
+
class_attribute :addressable_validate_state, instance_accessor: false, default: false
|
|
39
|
+
class_attribute :addressable_verifier, instance_accessor: false, default: nil
|
|
40
|
+
|
|
41
|
+
before_validation :normalize_address
|
|
42
|
+
validate :validate_address
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Defined as a real module (not `class_methods do`) so the public macro and
|
|
46
|
+
# its private helpers share one `private` and aren't constrained by
|
|
47
|
+
# Metrics/BlockLength. ActiveSupport::Concern auto-extends `ClassMethods`.
|
|
48
|
+
module ClassMethods
|
|
49
|
+
include ConcernsOnRails::Support::ColumnGuard
|
|
50
|
+
|
|
51
|
+
# Configure the address. Column overrides are passed as `part: :column`
|
|
52
|
+
# keyword pairs; everything else tunes behavior. See the module docs.
|
|
53
|
+
def addressable_by(required: DEFAULT_REQUIRED, default_country: "US",
|
|
54
|
+
validate_state: false, verify_with: nil, **mapping)
|
|
55
|
+
self.addressable_fields = resolve_addressable_fields(mapping)
|
|
56
|
+
self.addressable_required = Array(required).map(&:to_sym)
|
|
57
|
+
self.addressable_default_country = default_country.to_s.upcase
|
|
58
|
+
self.addressable_validate_state = validate_state
|
|
59
|
+
self.addressable_verifier = verify_with
|
|
60
|
+
ensure_required_columns!
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def resolve_addressable_fields(mapping)
|
|
66
|
+
unknown = mapping.keys.map(&:to_sym) - DEFAULT_FIELDS.keys
|
|
67
|
+
raise ArgumentError, "ConcernsOnRails::Models::Addressable: unknown address part(s): #{unknown.join(', ')}" if unknown.any?
|
|
68
|
+
|
|
69
|
+
overrides = mapping.to_h { |part, column| [part.to_sym, column.to_sym] }
|
|
70
|
+
ensure_columns!("ConcernsOnRails::Models::Addressable", overrides.values)
|
|
71
|
+
DEFAULT_FIELDS.merge(overrides).select { |_part, column| column_names.include?(column.to_s) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def ensure_required_columns!
|
|
75
|
+
missing = addressable_required.reject { |part| addressable_fields.key?(part) }
|
|
76
|
+
return if missing.empty?
|
|
77
|
+
|
|
78
|
+
raise ArgumentError,
|
|
79
|
+
"ConcernsOnRails::Models::Addressable: required address part(s) #{missing.join(', ')} " \
|
|
80
|
+
"have no matching column (table: #{table_name})"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# --- Normalization (before_validation) ------------------------------------
|
|
85
|
+
|
|
86
|
+
def normalize_address
|
|
87
|
+
country = resolved_country
|
|
88
|
+
self.class.addressable_fields.each do |part, column|
|
|
89
|
+
value = self[column]
|
|
90
|
+
next unless value.is_a?(String)
|
|
91
|
+
|
|
92
|
+
self[column] = normalize_part(part, country, value)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# --- Validation -----------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
def validate_address
|
|
99
|
+
validate_required_parts
|
|
100
|
+
validate_country_code
|
|
101
|
+
validate_postal_code
|
|
102
|
+
validate_state_code if self.class.addressable_validate_state
|
|
103
|
+
run_address_verifier if self.class.addressable_verifier && errors.empty?
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# --- Public helpers -------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
# The present parts joined into a single line, in canonical order.
|
|
109
|
+
def full_address(separator: ", ")
|
|
110
|
+
address_lines.join(separator)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# The present parts as an ordered array (handy for multi-line rendering).
|
|
114
|
+
def address_lines
|
|
115
|
+
ordered_parts.filter_map { |part| self[self.class.addressable_fields[part]].presence }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# True when any configured part has a value.
|
|
119
|
+
def address_present?
|
|
120
|
+
ordered_parts.any? { |part| self[self.class.addressable_fields[part]].present? }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# True when every required part has a value (presence only, no format check).
|
|
124
|
+
def address_complete?
|
|
125
|
+
self.class.addressable_required.all? do |part|
|
|
126
|
+
(column = self.class.addressable_fields[part]) && self[column].present?
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# `{ part => value }` for the present parts (handy for serializers / verifiers).
|
|
131
|
+
def address_attributes
|
|
132
|
+
self.class.addressable_fields.each_with_object({}) do |(part, column), acc|
|
|
133
|
+
value = self[column]
|
|
134
|
+
acc[part] = value if value.present?
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
def ordered_parts
|
|
141
|
+
DEFAULT_FIELDS.keys.select { |part| self.class.addressable_fields.key?(part) }
|
|
142
|
+
end
|
|
143
|
+
|
|
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.
|
|
146
|
+
def resolved_country
|
|
147
|
+
column = self.class.addressable_fields[:country]
|
|
148
|
+
value = column && self[column]
|
|
149
|
+
return self.class.addressable_default_country unless value.is_a?(String)
|
|
150
|
+
|
|
151
|
+
code = value.strip.upcase
|
|
152
|
+
ConcernsOnRails::Support::AddressData.valid_country?(code) ? code : self.class.addressable_default_country
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def normalize_part(part, country, value)
|
|
156
|
+
squished = value.strip.squish
|
|
157
|
+
case part
|
|
158
|
+
when :postal_code then ConcernsOnRails::Support::AddressData.normalize_postal(country, value)
|
|
159
|
+
when :country, :state then squished.length == 2 ? squished.upcase : squished
|
|
160
|
+
else squished
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def validate_required_parts
|
|
165
|
+
fields = self.class.addressable_fields
|
|
166
|
+
self.class.addressable_required.each do |part|
|
|
167
|
+
column = fields[part]
|
|
168
|
+
errors.add(column, "can't be blank") if column && self[column].blank?
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def validate_country_code
|
|
173
|
+
column = self.class.addressable_fields[:country]
|
|
174
|
+
value = column && self[column]
|
|
175
|
+
return unless value.is_a?(String) && value.length == 2
|
|
176
|
+
return if ConcernsOnRails::Support::AddressData.valid_country?(value)
|
|
177
|
+
|
|
178
|
+
errors.add(column, "is not a valid ISO 3166-1 country code")
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def validate_postal_code
|
|
182
|
+
column = self.class.addressable_fields[:postal_code]
|
|
183
|
+
value = column && self[column]
|
|
184
|
+
return if value.blank?
|
|
185
|
+
|
|
186
|
+
format = ConcernsOnRails::Support::AddressData.postal_format_for(resolved_country)
|
|
187
|
+
errors.add(column, "is not a valid postal code") unless value.to_s.match?(format)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def validate_state_code
|
|
191
|
+
column = self.class.addressable_fields[:state]
|
|
192
|
+
value = column && self[column]
|
|
193
|
+
return if value.blank?
|
|
194
|
+
return if ConcernsOnRails::Support::AddressData.valid_state?(resolved_country, value)
|
|
195
|
+
|
|
196
|
+
errors.add(column, "is not a valid state/province")
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def run_address_verifier
|
|
200
|
+
apply_verifier_result(self.class.addressable_verifier.call(self))
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def apply_verifier_result(result)
|
|
204
|
+
case result
|
|
205
|
+
when false then errors.add(:base, "address could not be verified")
|
|
206
|
+
when String then errors.add(:base, result)
|
|
207
|
+
when Array then result.each { |message| errors.add(:base, message) }
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
module ConcernsOnRails
|
|
2
|
+
module Support
|
|
3
|
+
# Reference data + lookups for Models::Addressable. Kept here (mirroring
|
|
4
|
+
# ColumnGuard / RandomValue) so the concern itself stays lean and so the
|
|
5
|
+
# large constant tables live as plain literals rather than RuboCop-flagged
|
|
6
|
+
# blocks. All lookups are case-insensitive and string-safe.
|
|
7
|
+
#
|
|
8
|
+
# Scope is *format/structure* only — this validates shape (a well-formed
|
|
9
|
+
# postal code, a real ISO country code), never real-world deliverability.
|
|
10
|
+
module AddressData
|
|
11
|
+
module_function
|
|
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
|
|
41
|
+
|
|
42
|
+
# Per-country postal-code patterns (matched against the *normalized*,
|
|
43
|
+
# upcased value). `:default` is a permissive fallback for everything else.
|
|
44
|
+
POSTAL_FORMATS = {
|
|
45
|
+
"US" => /\A\d{5}(-\d{4})?\z/,
|
|
46
|
+
"CA" => /\A[A-Z]\d[A-Z] ?\d[A-Z]\d\z/,
|
|
47
|
+
"GB" => /\A[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}\z/,
|
|
48
|
+
"AU" => /\A\d{4}\z/,
|
|
49
|
+
"DE" => /\A\d{5}\z/,
|
|
50
|
+
"FR" => /\A\d{5}\z/,
|
|
51
|
+
default: /\A[A-Z0-9][A-Z0-9 -]{1,8}[A-Z0-9]\z/
|
|
52
|
+
}.freeze
|
|
53
|
+
|
|
54
|
+
# USPS state / territory abbreviations.
|
|
55
|
+
US_STATES = Set.new(%w[
|
|
56
|
+
AL AK AZ AR CA CO CT DE FL GA HI ID IL IN IA KS KY LA ME MD MA MI MN MS
|
|
57
|
+
MO MT NE NV NH NJ NM NY NC ND OH OK OR PA RI SC SD TN TX UT VT VA WA WV
|
|
58
|
+
WI WY DC AS GU MP PR VI
|
|
59
|
+
]).freeze
|
|
60
|
+
|
|
61
|
+
# Canadian province / territory codes.
|
|
62
|
+
CA_PROVINCES = Set.new(%w[AB BC MB NB NL NS NT NU ON PE QC SK YT]).freeze
|
|
63
|
+
|
|
64
|
+
# True when `code` is a known ISO 3166-1 alpha-2 country code.
|
|
65
|
+
def valid_country?(code)
|
|
66
|
+
return false unless code.is_a?(String)
|
|
67
|
+
|
|
68
|
+
ISO_COUNTRY_CODES.include?(code.upcase)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Regexp to validate a postal code for the given country (falls back to
|
|
72
|
+
# the permissive `:default` pattern for unmapped countries).
|
|
73
|
+
def postal_format_for(country)
|
|
74
|
+
POSTAL_FORMATS[country.to_s.upcase] || POSTAL_FORMATS[:default]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Validate a state/region against US / CA sets. Returns true for any other
|
|
78
|
+
# country (we only know those two), so callers needn't special-case.
|
|
79
|
+
def valid_state?(country, code)
|
|
80
|
+
return true unless code.is_a?(String)
|
|
81
|
+
|
|
82
|
+
case country.to_s.upcase
|
|
83
|
+
when "US" then US_STATES.include?(code.upcase)
|
|
84
|
+
when "CA" then CA_PROVINCES.include?(code.upcase)
|
|
85
|
+
else true
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Squish + upcase a postal code, adding canonical spacing for CA
|
|
90
|
+
# (`A1A1A1` -> `A1A 1A1`). Non-strings pass through unchanged.
|
|
91
|
+
def normalize_postal(country, value)
|
|
92
|
+
return value unless value.is_a?(String)
|
|
93
|
+
|
|
94
|
+
normalized = value.strip.squish.upcase
|
|
95
|
+
return normalized unless country.to_s.upcase == "CA"
|
|
96
|
+
|
|
97
|
+
compact = normalized.delete(" ")
|
|
98
|
+
compact.match?(/\A[A-Z]\d[A-Z]\d[A-Z]\d\z/) ? "#{compact[0, 3]} #{compact[3, 3]}" : normalized
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
data/lib/concerns_on_rails.rb
CHANGED
|
@@ -10,6 +10,7 @@ end
|
|
|
10
10
|
# Shared internal helpers (must load before the concerns that use them)
|
|
11
11
|
require "concerns_on_rails/support/column_guard"
|
|
12
12
|
require "concerns_on_rails/support/random_value"
|
|
13
|
+
require "concerns_on_rails/support/address_data"
|
|
13
14
|
|
|
14
15
|
# Model concerns
|
|
15
16
|
require "concerns_on_rails/models/sluggable"
|
|
@@ -24,6 +25,7 @@ require "concerns_on_rails/models/searchable"
|
|
|
24
25
|
require "concerns_on_rails/models/activatable"
|
|
25
26
|
require "concerns_on_rails/models/tokenizable"
|
|
26
27
|
require "concerns_on_rails/models/stateable"
|
|
28
|
+
require "concerns_on_rails/models/addressable"
|
|
27
29
|
|
|
28
30
|
# Controller concerns
|
|
29
31
|
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.
|
|
4
|
+
version: 1.10.0
|
|
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-
|
|
11
|
+
date: 2026-06-03 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -78,6 +78,7 @@ files:
|
|
|
78
78
|
- lib/concerns_on_rails/controllers/sortable.rb
|
|
79
79
|
- lib/concerns_on_rails/legacy_aliases.rb
|
|
80
80
|
- lib/concerns_on_rails/models/activatable.rb
|
|
81
|
+
- lib/concerns_on_rails/models/addressable.rb
|
|
81
82
|
- lib/concerns_on_rails/models/expirable.rb
|
|
82
83
|
- lib/concerns_on_rails/models/hashable.rb
|
|
83
84
|
- lib/concerns_on_rails/models/normalizable.rb
|
|
@@ -89,6 +90,7 @@ files:
|
|
|
89
90
|
- lib/concerns_on_rails/models/sortable.rb
|
|
90
91
|
- lib/concerns_on_rails/models/stateable.rb
|
|
91
92
|
- lib/concerns_on_rails/models/tokenizable.rb
|
|
93
|
+
- lib/concerns_on_rails/support/address_data.rb
|
|
92
94
|
- lib/concerns_on_rails/support/column_guard.rb
|
|
93
95
|
- lib/concerns_on_rails/support/random_value.rb
|
|
94
96
|
- lib/concerns_on_rails/version.rb
|