concerns_on_rails 1.10.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: 743380a3be200eda289e97edef227f8b900550ba08af52c4a06b995fc5c8297a
4
- data.tar.gz: 7e8e8cb4bfd923819ec96178d75e68378829fb0f80b5a2e3cf3cd84e150fcc32
3
+ metadata.gz: f8d7976f6ba1afbdd29545f992c77fe50f42771828cb01ae14ea4dc194d6f36a
4
+ data.tar.gz: d159c7e96f3e430e71aa9d3d18f5ce8d70323cb6c13cb8fd403c8b122c4a0fb8
5
5
  SHA512:
6
- metadata.gz: 48e77b0bb95ee7419207289be7e1f74166f43aa4e31f4af6a6269ac2a84ccb90ac38e81a2a11a44cdafb7bd678d48153070c41d502b37f0a6baeabfd114f73f0
7
- data.tar.gz: bb03d4bcfb6b26b7963f70cdaee8e591293fc8f96e34aa636aab84df6ce20647ae9df01e3d52554c8b6a9cb52cfce68c2da2c0f967ede2a3ec7960be737f4bd2
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
@@ -2,7 +2,7 @@
2
2
 
3
3
  > 🇻🇳 **Hoàng Sa and Trường Sa belong to Việt Nam.**
4
4
 
5
- A plug-and-play collection of reusable ActiveSupport concerns for Rails **models** and **controllers** — slugs, soft delete, scheduled publish, expiry, pagination, filtering, JSON envelopes, and more. One `include`, one declarative macro, done.
5
+ A plug-and-play collection of reusable ActiveSupport concerns for Rails **models** and **controllers** — slugs, soft delete, scheduled publish, expiry, sequential reference numbers, pagination, filtering, JSON envelopes, and more. One `include`, one declarative macro, done.
6
6
 
7
7
  ```ruby
8
8
  class Article < ApplicationRecord
@@ -36,6 +36,7 @@ Article.published.without_deleted.find("hello-world")
36
36
  - [Searchable](#-searchable) — LIKE/ILIKE search across configured columns
37
37
  - [Activatable](#-activatable) — boolean active/inactive toggle
38
38
  - [Tokenizable](#-tokenizable) — security tokens with timing-safe lookup
39
+ - [Sequenceable](#-sequenceable) — ordered, human-friendly reference numbers
39
40
  - [Stateable](#-stateable) — lightweight string-backed state machine
40
41
  - [Addressable](#-addressable) — postal address normalization + format validation
41
42
  - **Controller concerns**
@@ -54,7 +55,7 @@ Article.published.without_deleted.find("hello-world")
54
55
 
55
56
  ## ✨ Why this gem?
56
57
 
57
- - **Thirteen model concerns + six controller concerns**, all production-ready
58
+ - **Fourteen model concerns + six controller concerns**, all production-ready
58
59
  - **One include, one macro** — no boilerplate, no glue code
59
60
  - **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
60
61
  - **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
@@ -67,7 +68,7 @@ Article.published.without_deleted.find("hello-world")
67
68
  Add to your application's `Gemfile`:
68
69
 
69
70
  ```ruby
70
- gem "concerns_on_rails", "~> 1.9"
71
+ gem "concerns_on_rails", "~> 1.11"
71
72
  ```
72
73
 
73
74
  Or pull the latest from GitHub:
@@ -578,6 +579,70 @@ User.authenticate_by_api_token(token) # timing-safe; returns user or nil
578
579
 
579
580
  ---
580
581
 
582
+ ## 🧾 Sequenceable
583
+
584
+ Ordered, human-friendly reference numbers — invoice numbers, order numbers, ticket IDs, support cases. Unlike the *random* identifiers from [Hashable](#-hashable) / [Tokenizable](#-tokenizable), `Sequenceable` produces *sequential* ones backed by an integer column that is the source of truth.
585
+
586
+ ```ruby
587
+ class Invoice < ApplicationRecord
588
+ include ConcernsOnRails::Sequenceable
589
+
590
+ sequenceable_by :sequence, # integer column — the source of truth
591
+ into: :number, # optional string column for the formatted value
592
+ prefix: "INV-",
593
+ padding: 5,
594
+ scope: :account_id, # one independent counter per account
595
+ reset: :year # restart numbering each calendar year
596
+ end
597
+
598
+ invoice = Invoice.create!(account_id: 1)
599
+ invoice.sequence # => 1, 2, 3 ... (per account, per year)
600
+ invoice.number # => "INV-2026-00001"
601
+ invoice.formatted_sequence # => "INV-2026-00001"
602
+
603
+ Invoice.next_sequence(account_id: 1) # => 4 (peek the next value, without creating)
604
+ ```
605
+
606
+ **Options**
607
+
608
+ | Option | Default | Purpose |
609
+ |----------------------|-------------|------------------------------------------------------------------------------------------|
610
+ | `field` (positional) | `:sequence` | Integer column holding the sequence — the source of truth. |
611
+ | `into:` | `nil` | String column to persist the formatted reference into (immutable display value). |
612
+ | `prefix:` | `""` | Prepended to the formatted value. |
613
+ | `padding:` | `0` | Zero-pad width of the numeric portion (`0` = no padding). |
614
+ | `separator:` | `"-"` | Joins prefix / period token / number in the default format. |
615
+ | `start_at:` | `1` | First value when the scope/period has no rows yet. |
616
+ | `scope:` | `nil` | Column (or array of columns) the counter is scoped to — e.g. one sequence per `account_id`. |
617
+ | `reset:` | `:never` | `:never` / `:year` / `:month` / `:day` — restart numbering each period (needs `created_at`). |
618
+ | `template:` | `nil` | `->(seq, record) { ... }` full custom formatter; overrides `prefix` / `padding` / period. |
619
+
620
+ **Default format**
621
+
622
+ | `reset:` | Example | Shape |
623
+ |-----------|-----------------------|----------------------------------|
624
+ | `:never` | `INV-00001` | `prefix + padded` |
625
+ | `:year` | `INV-2026-00001` | `prefix + YYYY + sep + padded` |
626
+ | `:month` | `INV-202606-00001` | `prefix + YYYYMM + sep + padded` |
627
+ | `:day` | `INV-20260604-00001` | `prefix + YYYYMMDD + sep + padded` |
628
+
629
+ **Generated API**
630
+
631
+ | Method | What it does |
632
+ |-----------------------------------|---------------------------------------------------------------------------------------|
633
+ | `formatted_<field>` | The formatted string — the persisted `into:` value when set, otherwise computed. |
634
+ | `Model.next_<field>(scope_attrs)` | Peek the next integer for a scope without creating a record. |
635
+
636
+ **Notes**
637
+ - The next value is `MAX(<field>) + 1` within the scope (and period), so numbering is dense and ordered — not random.
638
+ - Caller-supplied values are respected: `Invoice.create!(sequence: 100)` is not overwritten (and its `into:` string is still formatted from `100`).
639
+ - Generation reads `MAX` then inserts, so two concurrent inserts can race. It's **best-effort** — add a **scoped unique index** on `<field>` (and on `into:`) for a real guarantee, the same way you would for any `MAX`-based numbering.
640
+ - `reset:` requires a `created_at` column; the period is taken from each row's creation time.
641
+ - For fixed-width display (`00042`), make the `into:` column a **string** — integer columns drop leading zeros.
642
+ - Distinct from `Hashable` / `Tokenizable`, which generate *random* values; reach for those when the identifier must be unguessable.
643
+
644
+ ---
645
+
581
646
  ## 🔄 Stateable
582
647
 
583
648
  Lightweight string-backed state machine — the 80% of AASM without the dependency.
@@ -660,6 +725,9 @@ class Place < ApplicationRecord
660
725
  required: %i[line1 city postal_code country],
661
726
  default_country: "GB", # country used when the record has none
662
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)
663
731
  verify_with: ->(rec) { Usps.verify(rec) } # opt-in external verifier
664
732
  end
665
733
  ```
@@ -670,20 +738,25 @@ end
670
738
  |-------------------|--------------------------------------|---------------------------------------------------------------------|
671
739
  | `line1:` … `country:` | same-named columns | Map each canonical part to a real column. Missing columns are skipped. |
672
740
  | `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. |
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. |
674
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. |
675
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. |
676
748
 
677
749
  **What it normalizes** (in `before_validation`)
678
750
  - Text parts: `strip` + `squish`.
679
751
  - `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).
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.
681
753
 
682
754
  **What it validates**
683
755
  - Presence of every `required:` part.
684
756
  - `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.
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.
686
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.
687
760
 
688
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:
689
762
 
@@ -956,7 +1029,7 @@ Both forms reference the same module, so you can freely mix them.
956
1029
  bundle install # install dev dependencies
957
1030
  bundle exec rspec # run the test suite
958
1031
  gem build concerns_on_rails.gemspec # build the gem
959
- gem install ./concerns_on_rails-1.9.0.gem # install locally
1032
+ gem install ./concerns_on_rails-1.11.0.gem # install locally
960
1033
  ```
961
1034
 
962
1035
  The test suite uses an in-memory SQLite database and a lightweight `FakeController` harness for controller-concern specs — no Rails routes or boot required.
@@ -15,4 +15,5 @@ module ConcernsOnRails
15
15
  Tokenizable = Models::Tokenizable
16
16
  Stateable = Models::Stateable
17
17
  Addressable = Models::Addressable
18
+ Sequenceable = Models::Sequenceable
18
19
  end
@@ -13,7 +13,10 @@ module ConcernsOnRails
13
13
  # addressable_by # standard line1/line2/city/state/postal_code/country columns
14
14
  # # addressable_by line1: :street, postal_code: :zip, country: :country_code,
15
15
  # # required: %i[line1 city postal_code], default_country: "GB",
16
- # # validate_state: true, verify_with: ->(rec) { Usps.verify(rec) }
16
+ # # validate_state: true, verify_with: ->(rec) { Usps.verify(rec) },
17
+ # # lengths: { line1: 100, postal_code: 5..10 }, allow_blank: %i[state],
18
+ # # normalize_country: true, # "Canada"/"CAN" -> "CA"
19
+ # # if: :on_addresses? # Rails-style condition gating the validations
17
20
  # end
18
21
  #
19
22
  # Scope is *format/structure* only — it checks shape, not real-world
@@ -31,15 +34,23 @@ module ConcernsOnRails
31
34
  # Parts required by default (each must map to an existing column).
32
35
  DEFAULT_REQUIRED = %i[line1 city postal_code country].freeze
33
36
 
37
+ # Prefix for the ArgumentErrors raised during configuration.
38
+ LABEL = "ConcernsOnRails::Models::Addressable".freeze
39
+
34
40
  included do
35
41
  class_attribute :addressable_fields, instance_accessor: false, default: {}
36
42
  class_attribute :addressable_required, instance_accessor: false, default: [].freeze
37
43
  class_attribute :addressable_default_country, instance_accessor: false, default: "US"
38
44
  class_attribute :addressable_validate_state, instance_accessor: false, default: false
39
45
  class_attribute :addressable_verifier, instance_accessor: false, default: nil
46
+ class_attribute :addressable_lengths, instance_accessor: false, default: {}
47
+ class_attribute :addressable_allow_blank, instance_accessor: false, default: [].freeze
48
+ class_attribute :addressable_normalize_country, instance_accessor: false, default: false
49
+ class_attribute :addressable_validation_registered, instance_accessor: false, default: false
40
50
 
51
+ # `validate :validate_address` is registered by `addressable_by` (not here) so it can
52
+ # carry the optional if:/unless: condition. Normalization always runs.
41
53
  before_validation :normalize_address
42
- validate :validate_address
43
54
  end
44
55
 
45
56
  # Defined as a real module (not `class_methods do`) so the public macro and
@@ -51,23 +62,29 @@ module ConcernsOnRails
51
62
  # Configure the address. Column overrides are passed as `part: :column`
52
63
  # keyword pairs; everything else tunes behavior. See the module docs.
53
64
  def addressable_by(required: DEFAULT_REQUIRED, default_country: "US",
54
- validate_state: false, verify_with: nil, **mapping)
65
+ validate_state: false, verify_with: nil,
66
+ lengths: {}, allow_blank: false, normalize_country: false, **mapping)
67
+ condition = extract_validation_condition!(mapping)
55
68
  self.addressable_fields = resolve_addressable_fields(mapping)
56
69
  self.addressable_required = Array(required).map(&:to_sym)
57
70
  self.addressable_default_country = default_country.to_s.upcase
58
71
  self.addressable_validate_state = validate_state
59
72
  self.addressable_verifier = verify_with
73
+ self.addressable_lengths = resolve_lengths(lengths)
74
+ self.addressable_allow_blank = resolve_allow_blank(allow_blank)
75
+ self.addressable_normalize_country = normalize_country
60
76
  ensure_required_columns!
77
+ register_address_validation(condition)
61
78
  end
62
79
 
63
80
  private
64
81
 
65
82
  def resolve_addressable_fields(mapping)
66
83
  unknown = mapping.keys.map(&:to_sym) - DEFAULT_FIELDS.keys
67
- raise ArgumentError, "ConcernsOnRails::Models::Addressable: unknown address part(s): #{unknown.join(', ')}" if unknown.any?
84
+ raise ArgumentError, "#{LABEL}: unknown address part(s): #{unknown.join(', ')}" if unknown.any?
68
85
 
69
86
  overrides = mapping.to_h { |part, column| [part.to_sym, column.to_sym] }
70
- ensure_columns!("ConcernsOnRails::Models::Addressable", overrides.values)
87
+ ensure_columns!(LABEL, overrides.values)
71
88
  DEFAULT_FIELDS.merge(overrides).select { |_part, column| column_names.include?(column.to_s) }
72
89
  end
73
90
 
@@ -76,9 +93,86 @@ module ConcernsOnRails
76
93
  return if missing.empty?
77
94
 
78
95
  raise ArgumentError,
79
- "ConcernsOnRails::Models::Addressable: required address part(s) #{missing.join(', ')} " \
96
+ "#{LABEL}: required address part(s) #{missing.join(', ')} " \
80
97
  "have no matching column (table: #{table_name})"
81
98
  end
99
+
100
+ # Normalize the lengths: option into { part => [min, max] }, where min
101
+ # defaults to 0 and max to Infinity so the validator needs no nil guards.
102
+ def resolve_lengths(lengths)
103
+ raise ArgumentError, "#{LABEL}: lengths: must be a Hash (part => Integer or Range)" unless lengths.is_a?(Hash)
104
+
105
+ lengths.to_h do |part, bound|
106
+ sym = part.to_sym
107
+ raise ArgumentError, "#{LABEL}: unknown address part in lengths: #{sym}" unless DEFAULT_FIELDS.key?(sym)
108
+
109
+ [sym, normalize_length_bound(sym, bound)]
110
+ end
111
+ end
112
+
113
+ def normalize_length_bound(part, bound)
114
+ case bound
115
+ when Integer
116
+ raise ArgumentError, "#{LABEL}: lengths[#{part}] must be a positive Integer" unless bound.positive?
117
+
118
+ [0, bound]
119
+ when Range then range_bounds(part, bound)
120
+ else
121
+ raise ArgumentError, "#{LABEL}: lengths[#{part}] must be an Integer or Range, got #{bound.class}"
122
+ end
123
+ end
124
+
125
+ # A Range becomes [min, max] (min defaults to 0, an open end becomes
126
+ # Infinity). Bounds must be non-negative Integers and the range must be
127
+ # satisfiable (min <= max) — otherwise a typo would silently brick a column.
128
+ def range_bounds(part, range)
129
+ min = range.begin || 0
130
+ max = range.end
131
+ validate_range_endpoints!(part, min, max)
132
+ max -= 1 if max && range.exclude_end?
133
+ max ||= Float::INFINITY
134
+ raise ArgumentError, "#{LABEL}: lengths[#{part}] range is empty or inverted (#{range})" if min > max
135
+
136
+ [min, max]
137
+ end
138
+
139
+ def validate_range_endpoints!(part, min, max)
140
+ return if min.is_a?(Integer) && !min.negative? && (max.nil? || (max.is_a?(Integer) && !max.negative?))
141
+
142
+ raise ArgumentError, "#{LABEL}: lengths[#{part}] range must have non-negative Integer bounds"
143
+ end
144
+
145
+ # Normalize allow_blank: into the list of parts whose length check is skipped when blank.
146
+ def resolve_allow_blank(allow_blank)
147
+ case allow_blank
148
+ when true then DEFAULT_FIELDS.keys
149
+ when false, nil then []
150
+ when Array
151
+ parts = allow_blank.map(&:to_sym)
152
+ unknown = parts - DEFAULT_FIELDS.keys
153
+ raise ArgumentError, "#{LABEL}: unknown address part(s) in allow_blank: #{unknown.join(', ')}" if unknown.any?
154
+
155
+ parts
156
+ else
157
+ raise ArgumentError, "#{LABEL}: allow_blank: must be true, false, or an Array of parts"
158
+ end
159
+ end
160
+
161
+ # Pull Rails-style `if:` / `unless:` out of the keyword args (they ride in via
162
+ # **mapping) so the remaining keys are treated as column overrides.
163
+ def extract_validation_condition!(mapping)
164
+ { if: mapping.delete(:if), unless: mapping.delete(:unless) }.compact
165
+ end
166
+
167
+ # Register `validate :validate_address` once, forwarding any if:/unless: condition
168
+ # straight to Rails so it behaves like a normal conditional validation. Normalization
169
+ # (before_validation) is unconditional; the condition only gates the validations.
170
+ def register_address_validation(condition)
171
+ return if addressable_validation_registered
172
+
173
+ self.addressable_validation_registered = true
174
+ validate :validate_address, **condition
175
+ end
82
176
  end
83
177
 
84
178
  # --- Normalization (before_validation) ------------------------------------
@@ -97,6 +191,7 @@ module ConcernsOnRails
97
191
 
98
192
  def validate_address
99
193
  validate_required_parts
194
+ validate_lengths
100
195
  validate_country_code
101
196
  validate_postal_code
102
197
  validate_state_code if self.class.addressable_validate_state
@@ -141,26 +236,46 @@ module ConcernsOnRails
141
236
  DEFAULT_FIELDS.keys.select { |part| self.class.addressable_fields.key?(part) }
142
237
  end
143
238
 
144
- # The 2-letter country code driving postal/state checks: the record's own
145
- # country when it's a recognized code, otherwise the configured default.
239
+ # The country driving postal-/state-format selection:
240
+ # * the record's own country when it's a recognized ISO alpha-2 code
241
+ # * the configured default_country when the country column is absent or blank
242
+ # * nil ("unknown") when a country is present but unrecognized — so postal/state
243
+ # fall back to permissive checks instead of wrongly applying the default's rules
146
244
  def resolved_country
147
245
  column = self.class.addressable_fields[:country]
148
246
  value = column && self[column]
149
- return self.class.addressable_default_country unless value.is_a?(String)
247
+ return self.class.addressable_default_country if value.blank?
150
248
 
151
- code = value.strip.upcase
152
- ConcernsOnRails::Support::AddressData.valid_country?(code) ? code : self.class.addressable_default_country
249
+ code = canonical_country_code(value)
250
+ ConcernsOnRails::Support::AddressData.valid_country?(code) ? code : nil
251
+ end
252
+
253
+ # The country value as an alpha-2 candidate, applying name/alpha-3 mapping
254
+ # when normalize_country is on so postal/state checks recognize it too.
255
+ def canonical_country_code(value)
256
+ return ConcernsOnRails::Support::AddressData.normalize_country_code(value).to_s.upcase if self.class.addressable_normalize_country
257
+
258
+ value.to_s.strip.upcase
153
259
  end
154
260
 
155
261
  def normalize_part(part, country, value)
156
262
  squished = value.strip.squish
157
263
  case part
158
264
  when :postal_code then ConcernsOnRails::Support::AddressData.normalize_postal(country, value)
159
- when :country, :state then squished.length == 2 ? squished.upcase : squished
265
+ when :country then normalize_country_part(squished)
266
+ when :state then squished.length == 2 ? squished.upcase : squished
160
267
  else squished
161
268
  end
162
269
  end
163
270
 
271
+ # With normalize_country on, map a name/alpha-3 to its alpha-2 (leaving an
272
+ # unrecognized value untouched); otherwise just upcase a bare 2-letter code.
273
+ def normalize_country_part(squished)
274
+ return ConcernsOnRails::Support::AddressData.normalize_country_code(squished) if self.class.addressable_normalize_country
275
+
276
+ squished.length == 2 ? squished.upcase : squished
277
+ end
278
+
164
279
  def validate_required_parts
165
280
  fields = self.class.addressable_fields
166
281
  self.class.addressable_required.each do |part|
@@ -169,6 +284,31 @@ module ConcernsOnRails
169
284
  end
170
285
  end
171
286
 
287
+ def validate_lengths
288
+ self.class.addressable_lengths.each { |part, bounds| validate_length_of(part, bounds) }
289
+ end
290
+
291
+ # Enforce one part's [min, max] bounds. A blank value skips the check only
292
+ # when the part is in allow_blank; otherwise the minimum is enforced on blanks
293
+ # (independent of required:). Length is measured on the normalized value.
294
+ def validate_length_of(part, (min, max))
295
+ column = self.class.addressable_fields[part]
296
+ return unless column
297
+
298
+ value = self[column]
299
+ return if value.blank? && self.class.addressable_allow_blank.include?(part)
300
+
301
+ length = value.to_s.length
302
+ errors.add(column, "is too short (minimum is #{length_phrase(min)})") if length < min
303
+ errors.add(column, "is too long (maximum is #{length_phrase(max)})") if length > max
304
+ end
305
+
306
+ # "1 character" / "N characters" — mirrors Rails' pluralized length errors
307
+ # without depending on i18n. (max is only interpolated for finite bounds.)
308
+ def length_phrase(count)
309
+ "#{count} #{count == 1 ? 'character' : 'characters'}"
310
+ end
311
+
172
312
  def validate_country_code
173
313
  column = self.class.addressable_fields[:country]
174
314
  value = column && self[column]
@@ -0,0 +1,135 @@
1
+ require "active_support/concern"
2
+
3
+ module ConcernsOnRails
4
+ module Models
5
+ # Generates ordered, human-friendly sequential reference numbers — invoice
6
+ # numbers, order numbers, ticket numbers, support cases. Unlike Hashable /
7
+ # Tokenizable (which produce *random* identifiers), Sequenceable produces
8
+ # *ordered* ones backed by an integer column that is the source of truth.
9
+ #
10
+ # class Invoice < ApplicationRecord
11
+ # include ConcernsOnRails::Sequenceable
12
+ #
13
+ # sequenceable_by :sequence, # integer column — source of truth
14
+ # into: :number, # optional string column for the formatted value
15
+ # prefix: "INV-",
16
+ # padding: 5,
17
+ # scope: :account_id, # one counter per account
18
+ # reset: :year # restart numbering each calendar year
19
+ # end
20
+ #
21
+ # invoice = Invoice.create!(account_id: 1)
22
+ # invoice.sequence # => 1, 2, 3 ... (per account, per year)
23
+ # invoice.number # => "INV-2026-00001"
24
+ # invoice.formatted_sequence # => "INV-2026-00001"
25
+ # Invoice.next_sequence(account_id: 1) # peek the next value without creating
26
+ #
27
+ # The integer is computed as MAX(field) within the scope (+ period) + 1, so
28
+ # numbering is dense and ordered. Generation is best-effort under concurrency
29
+ # — pair the column(s) with a scoped unique DB index for a real guarantee.
30
+ module Sequenceable
31
+ extend ActiveSupport::Concern
32
+
33
+ RESET_PERIODS = %i[never year month day].freeze
34
+ MAX_GENERATION_ATTEMPTS = 10
35
+ NAME = "ConcernsOnRails::Models::Sequenceable".freeze
36
+
37
+ included do
38
+ class_attribute :sequenceable_config, instance_accessor: false, default: {}
39
+ end
40
+
41
+ class_methods do
42
+ include ConcernsOnRails::Support::ColumnGuard
43
+ include ConcernsOnRails::Support::SequenceCalculator
44
+
45
+ # Configure a sequenceable field.
46
+ #
47
+ # Options:
48
+ # into: string column to persist the formatted value into (default nil)
49
+ # prefix: string prepended to the formatted value (default "")
50
+ # padding: zero-pad width of the numeric portion (default 0 = no padding)
51
+ # separator: joins prefix / period token / number in the default format (default "-")
52
+ # start_at: first value per scope/period when no rows exist yet (default 1)
53
+ # scope: column or array of columns the counter is scoped to (default nil)
54
+ # reset: :never (default) | :year | :month | :day — restart per period (needs created_at)
55
+ # template: ->(seq, record) { ... } full custom formatter; overrides prefix/padding/period
56
+ def sequenceable_by(field = :sequence, into: nil, prefix: "", padding: 0,
57
+ separator: "-", start_at: 1, scope: nil, reset: :never, template: nil)
58
+ field = field.to_sym
59
+ into = into&.to_sym
60
+ reset = reset.to_sym
61
+ scope_cols = Array(scope).map(&:to_sym)
62
+
63
+ ensure_columns!(NAME, field)
64
+ ensure_columns!(NAME, into) if into
65
+ ensure_columns!(NAME, *scope_cols) unless scope_cols.empty?
66
+ ensure_columns!(NAME, :created_at) unless reset == :never
67
+ validate_sequenceable_options!(reset, template)
68
+
69
+ self.sequenceable_config = sequenceable_config.merge(
70
+ field => { into: into, prefix: prefix.to_s, padding: padding.to_i,
71
+ separator: separator.to_s, start_at: start_at.to_i,
72
+ scope: scope_cols, reset: reset, template: template }
73
+ )
74
+
75
+ before_create -> { assign_sequenceable_value(field) }
76
+ define_sequenceable_methods(field)
77
+ end
78
+ end
79
+
80
+ class_methods do
81
+ private
82
+
83
+ def define_sequenceable_methods(field)
84
+ define_method("formatted_#{field}") do
85
+ cfg = self.class.sequenceable_config.fetch(field)
86
+ return self[cfg[:into]] if cfg[:into] && self[cfg[:into]].present?
87
+ return nil if self[field].blank?
88
+
89
+ self.class.send(:format_sequence, field, self[field], self)
90
+ end
91
+
92
+ define_singleton_method("next_#{field}") do |scope_attrs = {}|
93
+ sequence_base_value(field, nil, scope_attrs)
94
+ end
95
+ end
96
+
97
+ def validate_sequenceable_options!(reset, template)
98
+ unless RESET_PERIODS.include?(reset)
99
+ raise ArgumentError, "#{NAME}: unknown reset '#{reset}'. Valid values: #{RESET_PERIODS.join(', ')}"
100
+ end
101
+
102
+ return if template.nil? || template.respond_to?(:call)
103
+
104
+ raise ArgumentError, "#{NAME}: template must be callable (respond to #call)"
105
+ end
106
+ end
107
+
108
+ # Assigns the sequence (and, when configured, the formatted string) only when
109
+ # the integer column is blank, so callers can pass an explicit value. The
110
+ # increment-until-free loop is a best-effort guard against pre-taken values;
111
+ # a scoped unique index is the real concurrency guarantee.
112
+ def assign_sequenceable_value(field)
113
+ cfg = self.class.sequenceable_config.fetch(field)
114
+
115
+ if self[field].blank?
116
+ candidate = self.class.send(:sequence_base_value, field, self, {})
117
+ attempts = 0
118
+ while self.class.send(:sequence_value_taken?, field, candidate, self, {})
119
+ attempts += 1
120
+ if attempts >= MAX_GENERATION_ATTEMPTS
121
+ raise "#{NAME}: could not find a free value for '#{field}' after " \
122
+ "#{MAX_GENERATION_ATTEMPTS} attempts — add a scoped unique index"
123
+ end
124
+ candidate += 1
125
+ end
126
+ self[field] = candidate
127
+ end
128
+
129
+ return unless cfg[:into] && self[cfg[:into]].blank?
130
+
131
+ self[cfg[:into]] = self.class.send(:format_sequence, field, self[field], self)
132
+ end
133
+ end
134
+ end
135
+ end
@@ -10,34 +10,270 @@ module ConcernsOnRails
10
10
  module AddressData
11
11
  module_function
12
12
 
13
- # ISO 3166-1 alpha-2 country codes.
14
- ISO_COUNTRY_CODES = Set.new(%w[
15
- AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ
16
- BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ
17
- CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ
18
- DE DJ DK DM DO DZ
19
- EC EE EG EH ER ES ET
20
- FI FJ FK FM FO FR
21
- GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY
22
- HK HM HN HR HT HU
23
- ID IE IL IM IN IO IQ IR IS IT
24
- JE JM JO JP
25
- KE KG KH KI KM KN KP KR KW KY KZ
26
- LA LB LC LI LK LR LS LT LU LV LY
27
- MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ
28
- NA NC NE NF NG NI NL NO NP NR NU NZ
29
- OM
30
- PA PE PF PG PH PK PL PM PN PR PS PT PW PY
31
- QA
32
- RE RO RS RU RW
33
- SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ
34
- TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ
35
- UA UG UM US UY UZ
36
- VA VC VE VG VI VN VU
37
- WF WS
38
- YE YT
39
- ZA ZM ZW
40
- ]).freeze
13
+ # Source of truth for ISO 3166-1: alpha-2 => [English name, alpha-3].
14
+ # ISO_COUNTRY_CODES and the name/alpha-3 lookups below are all derived
15
+ # from this, so the three never drift apart.
16
+ # Each value is a [name, alpha-3] pair (a tuple), not a list of words.
17
+ # rubocop:disable Style/WordArray
18
+ COUNTRY_DATA = {
19
+ "AD" => ["Andorra", "AND"],
20
+ "AE" => ["United Arab Emirates", "ARE"],
21
+ "AF" => ["Afghanistan", "AFG"],
22
+ "AG" => ["Antigua and Barbuda", "ATG"],
23
+ "AI" => ["Anguilla", "AIA"],
24
+ "AL" => ["Albania", "ALB"],
25
+ "AM" => ["Armenia", "ARM"],
26
+ "AO" => ["Angola", "AGO"],
27
+ "AQ" => ["Antarctica", "ATA"],
28
+ "AR" => ["Argentina", "ARG"],
29
+ "AS" => ["American Samoa", "ASM"],
30
+ "AT" => ["Austria", "AUT"],
31
+ "AU" => ["Australia", "AUS"],
32
+ "AW" => ["Aruba", "ABW"],
33
+ "AX" => ["Åland Islands", "ALA"],
34
+ "AZ" => ["Azerbaijan", "AZE"],
35
+ "BA" => ["Bosnia and Herzegovina", "BIH"],
36
+ "BB" => ["Barbados", "BRB"],
37
+ "BD" => ["Bangladesh", "BGD"],
38
+ "BE" => ["Belgium", "BEL"],
39
+ "BF" => ["Burkina Faso", "BFA"],
40
+ "BG" => ["Bulgaria", "BGR"],
41
+ "BH" => ["Bahrain", "BHR"],
42
+ "BI" => ["Burundi", "BDI"],
43
+ "BJ" => ["Benin", "BEN"],
44
+ "BL" => ["Saint Barthélemy", "BLM"],
45
+ "BM" => ["Bermuda", "BMU"],
46
+ "BN" => ["Brunei", "BRN"],
47
+ "BO" => ["Bolivia", "BOL"],
48
+ "BQ" => ["Caribbean Netherlands", "BES"],
49
+ "BR" => ["Brazil", "BRA"],
50
+ "BS" => ["Bahamas", "BHS"],
51
+ "BT" => ["Bhutan", "BTN"],
52
+ "BV" => ["Bouvet Island", "BVT"],
53
+ "BW" => ["Botswana", "BWA"],
54
+ "BY" => ["Belarus", "BLR"],
55
+ "BZ" => ["Belize", "BLZ"],
56
+ "CA" => ["Canada", "CAN"],
57
+ "CC" => ["Cocos (Keeling) Islands", "CCK"],
58
+ "CD" => ["Democratic Republic of the Congo", "COD"],
59
+ "CF" => ["Central African Republic", "CAF"],
60
+ "CG" => ["Republic of the Congo", "COG"],
61
+ "CH" => ["Switzerland", "CHE"],
62
+ "CI" => ["Côte d'Ivoire", "CIV"],
63
+ "CK" => ["Cook Islands", "COK"],
64
+ "CL" => ["Chile", "CHL"],
65
+ "CM" => ["Cameroon", "CMR"],
66
+ "CN" => ["China", "CHN"],
67
+ "CO" => ["Colombia", "COL"],
68
+ "CR" => ["Costa Rica", "CRI"],
69
+ "CU" => ["Cuba", "CUB"],
70
+ "CV" => ["Cape Verde", "CPV"],
71
+ "CW" => ["Curaçao", "CUW"],
72
+ "CX" => ["Christmas Island", "CXR"],
73
+ "CY" => ["Cyprus", "CYP"],
74
+ "CZ" => ["Czechia", "CZE"],
75
+ "DE" => ["Germany", "DEU"],
76
+ "DJ" => ["Djibouti", "DJI"],
77
+ "DK" => ["Denmark", "DNK"],
78
+ "DM" => ["Dominica", "DMA"],
79
+ "DO" => ["Dominican Republic", "DOM"],
80
+ "DZ" => ["Algeria", "DZA"],
81
+ "EC" => ["Ecuador", "ECU"],
82
+ "EE" => ["Estonia", "EST"],
83
+ "EG" => ["Egypt", "EGY"],
84
+ "EH" => ["Western Sahara", "ESH"],
85
+ "ER" => ["Eritrea", "ERI"],
86
+ "ES" => ["Spain", "ESP"],
87
+ "ET" => ["Ethiopia", "ETH"],
88
+ "FI" => ["Finland", "FIN"],
89
+ "FJ" => ["Fiji", "FJI"],
90
+ "FK" => ["Falkland Islands", "FLK"],
91
+ "FM" => ["Micronesia", "FSM"],
92
+ "FO" => ["Faroe Islands", "FRO"],
93
+ "FR" => ["France", "FRA"],
94
+ "GA" => ["Gabon", "GAB"],
95
+ "GB" => ["United Kingdom", "GBR"],
96
+ "GD" => ["Grenada", "GRD"],
97
+ "GE" => ["Georgia", "GEO"],
98
+ "GF" => ["French Guiana", "GUF"],
99
+ "GG" => ["Guernsey", "GGY"],
100
+ "GH" => ["Ghana", "GHA"],
101
+ "GI" => ["Gibraltar", "GIB"],
102
+ "GL" => ["Greenland", "GRL"],
103
+ "GM" => ["Gambia", "GMB"],
104
+ "GN" => ["Guinea", "GIN"],
105
+ "GP" => ["Guadeloupe", "GLP"],
106
+ "GQ" => ["Equatorial Guinea", "GNQ"],
107
+ "GR" => ["Greece", "GRC"],
108
+ "GS" => ["South Georgia and the South Sandwich Islands", "SGS"],
109
+ "GT" => ["Guatemala", "GTM"],
110
+ "GU" => ["Guam", "GUM"],
111
+ "GW" => ["Guinea-Bissau", "GNB"],
112
+ "GY" => ["Guyana", "GUY"],
113
+ "HK" => ["Hong Kong", "HKG"],
114
+ "HM" => ["Heard Island and McDonald Islands", "HMD"],
115
+ "HN" => ["Honduras", "HND"],
116
+ "HR" => ["Croatia", "HRV"],
117
+ "HT" => ["Haiti", "HTI"],
118
+ "HU" => ["Hungary", "HUN"],
119
+ "ID" => ["Indonesia", "IDN"],
120
+ "IE" => ["Ireland", "IRL"],
121
+ "IL" => ["Israel", "ISR"],
122
+ "IM" => ["Isle of Man", "IMN"],
123
+ "IN" => ["India", "IND"],
124
+ "IO" => ["British Indian Ocean Territory", "IOT"],
125
+ "IQ" => ["Iraq", "IRQ"],
126
+ "IR" => ["Iran", "IRN"],
127
+ "IS" => ["Iceland", "ISL"],
128
+ "IT" => ["Italy", "ITA"],
129
+ "JE" => ["Jersey", "JEY"],
130
+ "JM" => ["Jamaica", "JAM"],
131
+ "JO" => ["Jordan", "JOR"],
132
+ "JP" => ["Japan", "JPN"],
133
+ "KE" => ["Kenya", "KEN"],
134
+ "KG" => ["Kyrgyzstan", "KGZ"],
135
+ "KH" => ["Cambodia", "KHM"],
136
+ "KI" => ["Kiribati", "KIR"],
137
+ "KM" => ["Comoros", "COM"],
138
+ "KN" => ["Saint Kitts and Nevis", "KNA"],
139
+ "KP" => ["North Korea", "PRK"],
140
+ "KR" => ["South Korea", "KOR"],
141
+ "KW" => ["Kuwait", "KWT"],
142
+ "KY" => ["Cayman Islands", "CYM"],
143
+ "KZ" => ["Kazakhstan", "KAZ"],
144
+ "LA" => ["Laos", "LAO"],
145
+ "LB" => ["Lebanon", "LBN"],
146
+ "LC" => ["Saint Lucia", "LCA"],
147
+ "LI" => ["Liechtenstein", "LIE"],
148
+ "LK" => ["Sri Lanka", "LKA"],
149
+ "LR" => ["Liberia", "LBR"],
150
+ "LS" => ["Lesotho", "LSO"],
151
+ "LT" => ["Lithuania", "LTU"],
152
+ "LU" => ["Luxembourg", "LUX"],
153
+ "LV" => ["Latvia", "LVA"],
154
+ "LY" => ["Libya", "LBY"],
155
+ "MA" => ["Morocco", "MAR"],
156
+ "MC" => ["Monaco", "MCO"],
157
+ "MD" => ["Moldova", "MDA"],
158
+ "ME" => ["Montenegro", "MNE"],
159
+ "MF" => ["Saint Martin", "MAF"],
160
+ "MG" => ["Madagascar", "MDG"],
161
+ "MH" => ["Marshall Islands", "MHL"],
162
+ "MK" => ["North Macedonia", "MKD"],
163
+ "ML" => ["Mali", "MLI"],
164
+ "MM" => ["Myanmar", "MMR"],
165
+ "MN" => ["Mongolia", "MNG"],
166
+ "MO" => ["Macao", "MAC"],
167
+ "MP" => ["Northern Mariana Islands", "MNP"],
168
+ "MQ" => ["Martinique", "MTQ"],
169
+ "MR" => ["Mauritania", "MRT"],
170
+ "MS" => ["Montserrat", "MSR"],
171
+ "MT" => ["Malta", "MLT"],
172
+ "MU" => ["Mauritius", "MUS"],
173
+ "MV" => ["Maldives", "MDV"],
174
+ "MW" => ["Malawi", "MWI"],
175
+ "MX" => ["Mexico", "MEX"],
176
+ "MY" => ["Malaysia", "MYS"],
177
+ "MZ" => ["Mozambique", "MOZ"],
178
+ "NA" => ["Namibia", "NAM"],
179
+ "NC" => ["New Caledonia", "NCL"],
180
+ "NE" => ["Niger", "NER"],
181
+ "NF" => ["Norfolk Island", "NFK"],
182
+ "NG" => ["Nigeria", "NGA"],
183
+ "NI" => ["Nicaragua", "NIC"],
184
+ "NL" => ["Netherlands", "NLD"],
185
+ "NO" => ["Norway", "NOR"],
186
+ "NP" => ["Nepal", "NPL"],
187
+ "NR" => ["Nauru", "NRU"],
188
+ "NU" => ["Niue", "NIU"],
189
+ "NZ" => ["New Zealand", "NZL"],
190
+ "OM" => ["Oman", "OMN"],
191
+ "PA" => ["Panama", "PAN"],
192
+ "PE" => ["Peru", "PER"],
193
+ "PF" => ["French Polynesia", "PYF"],
194
+ "PG" => ["Papua New Guinea", "PNG"],
195
+ "PH" => ["Philippines", "PHL"],
196
+ "PK" => ["Pakistan", "PAK"],
197
+ "PL" => ["Poland", "POL"],
198
+ "PM" => ["Saint Pierre and Miquelon", "SPM"],
199
+ "PN" => ["Pitcairn Islands", "PCN"],
200
+ "PR" => ["Puerto Rico", "PRI"],
201
+ "PS" => ["Palestine", "PSE"],
202
+ "PT" => ["Portugal", "PRT"],
203
+ "PW" => ["Palau", "PLW"],
204
+ "PY" => ["Paraguay", "PRY"],
205
+ "QA" => ["Qatar", "QAT"],
206
+ "RE" => ["Réunion", "REU"],
207
+ "RO" => ["Romania", "ROU"],
208
+ "RS" => ["Serbia", "SRB"],
209
+ "RU" => ["Russia", "RUS"],
210
+ "RW" => ["Rwanda", "RWA"],
211
+ "SA" => ["Saudi Arabia", "SAU"],
212
+ "SB" => ["Solomon Islands", "SLB"],
213
+ "SC" => ["Seychelles", "SYC"],
214
+ "SD" => ["Sudan", "SDN"],
215
+ "SE" => ["Sweden", "SWE"],
216
+ "SG" => ["Singapore", "SGP"],
217
+ "SH" => ["Saint Helena", "SHN"],
218
+ "SI" => ["Slovenia", "SVN"],
219
+ "SJ" => ["Svalbard and Jan Mayen", "SJM"],
220
+ "SK" => ["Slovakia", "SVK"],
221
+ "SL" => ["Sierra Leone", "SLE"],
222
+ "SM" => ["San Marino", "SMR"],
223
+ "SN" => ["Senegal", "SEN"],
224
+ "SO" => ["Somalia", "SOM"],
225
+ "SR" => ["Suriname", "SUR"],
226
+ "SS" => ["South Sudan", "SSD"],
227
+ "ST" => ["São Tomé and Príncipe", "STP"],
228
+ "SV" => ["El Salvador", "SLV"],
229
+ "SX" => ["Sint Maarten", "SXM"],
230
+ "SY" => ["Syria", "SYR"],
231
+ "SZ" => ["Eswatini", "SWZ"],
232
+ "TC" => ["Turks and Caicos Islands", "TCA"],
233
+ "TD" => ["Chad", "TCD"],
234
+ "TF" => ["French Southern Territories", "ATF"],
235
+ "TG" => ["Togo", "TGO"],
236
+ "TH" => ["Thailand", "THA"],
237
+ "TJ" => ["Tajikistan", "TJK"],
238
+ "TK" => ["Tokelau", "TKL"],
239
+ "TL" => ["Timor-Leste", "TLS"],
240
+ "TM" => ["Turkmenistan", "TKM"],
241
+ "TN" => ["Tunisia", "TUN"],
242
+ "TO" => ["Tonga", "TON"],
243
+ "TR" => ["Turkey", "TUR"],
244
+ "TT" => ["Trinidad and Tobago", "TTO"],
245
+ "TV" => ["Tuvalu", "TUV"],
246
+ "TW" => ["Taiwan", "TWN"],
247
+ "TZ" => ["Tanzania", "TZA"],
248
+ "UA" => ["Ukraine", "UKR"],
249
+ "UG" => ["Uganda", "UGA"],
250
+ "UM" => ["United States Minor Outlying Islands", "UMI"],
251
+ "US" => ["United States", "USA"],
252
+ "UY" => ["Uruguay", "URY"],
253
+ "UZ" => ["Uzbekistan", "UZB"],
254
+ "VA" => ["Vatican City", "VAT"],
255
+ "VC" => ["Saint Vincent and the Grenadines", "VCT"],
256
+ "VE" => ["Venezuela", "VEN"],
257
+ "VG" => ["British Virgin Islands", "VGB"],
258
+ "VI" => ["U.S. Virgin Islands", "VIR"],
259
+ "VN" => ["Vietnam", "VNM"],
260
+ "VU" => ["Vanuatu", "VUT"],
261
+ "WF" => ["Wallis and Futuna", "WLF"],
262
+ "WS" => ["Samoa", "WSM"],
263
+ "YE" => ["Yemen", "YEM"],
264
+ "YT" => ["Mayotte", "MYT"],
265
+ "ZA" => ["South Africa", "ZAF"],
266
+ "ZM" => ["Zambia", "ZMB"],
267
+ "ZW" => ["Zimbabwe", "ZWE"]
268
+ }.freeze
269
+ # rubocop:enable Style/WordArray
270
+
271
+ # ISO 3166-1 alpha-2 country codes (derived from COUNTRY_DATA).
272
+ ISO_COUNTRY_CODES = Set.new(COUNTRY_DATA.keys).freeze
273
+
274
+ # Downcased English country name => alpha-2, and alpha-3 => alpha-2.
275
+ NAME_TO_ALPHA2 = COUNTRY_DATA.to_h { |code, (name, _a3)| [name.downcase, code] }.freeze
276
+ ALPHA3_TO_ALPHA2 = COUNTRY_DATA.to_h { |code, (_name, a3)| [a3, code] }.freeze
41
277
 
42
278
  # Per-country postal-code patterns (matched against the *normalized*,
43
279
  # upcased value). `:default` is a permissive fallback for everything else.
@@ -97,6 +333,23 @@ module ConcernsOnRails
97
333
  compact = normalized.delete(" ")
98
334
  compact.match?(/\A[A-Z]\d[A-Z]\d[A-Z]\d\z/) ? "#{compact[0, 3]} #{compact[3, 3]}" : normalized
99
335
  end
336
+
337
+ # Canonicalize a country value to its ISO 3166-1 alpha-2 code: an existing
338
+ # alpha-2 is upcased; a 3-letter alpha-3 (e.g. "USA") and a recognized
339
+ # English name (e.g. "Canada") map to the alpha-2. Unrecognized values are
340
+ # returned unchanged, and non-strings pass through.
341
+ def normalize_country_code(value)
342
+ return value unless value.is_a?(String)
343
+
344
+ trimmed = value.strip.squish
345
+ return value if trimmed.empty?
346
+
347
+ upper = trimmed.upcase
348
+ return upper if ISO_COUNTRY_CODES.include?(upper)
349
+ return ALPHA3_TO_ALPHA2[upper] if ALPHA3_TO_ALPHA2.key?(upper)
350
+
351
+ NAME_TO_ALPHA2[trimmed.downcase] || value
352
+ end
100
353
  end
101
354
  end
102
355
  end
@@ -0,0 +1,74 @@
1
+ module ConcernsOnRails
2
+ module Support
3
+ # Internal helpers for Models::Sequenceable: computing the next value within a
4
+ # scope (+ period) and formatting it. Mixed into the model's class methods, so
5
+ # `self` is the model class and `unscoped` / `sequenceable_config` resolve
6
+ # against it. Kept here to keep the concern itself focused on configuration.
7
+ module SequenceCalculator
8
+ private
9
+
10
+ # Next integer that would be assigned for the given scope: MAX within the
11
+ # scope (+ period) + 1, or start_at when the scope/period is still empty.
12
+ def sequence_base_value(field, record, scope_attrs)
13
+ cfg = sequenceable_config.fetch(field)
14
+ max = sequence_relation(field, record, scope_attrs).maximum(field)
15
+ max ? max + 1 : cfg[:start_at]
16
+ end
17
+
18
+ def sequence_value_taken?(field, candidate, record, scope_attrs)
19
+ sequence_relation(field, record, scope_attrs).exists?(field => candidate)
20
+ end
21
+
22
+ # Relation of existing rows that share this record's scope (and period, when
23
+ # reset is enabled). Reads from `unscoped` so a model's default_scope never
24
+ # hides rows the counter must account for.
25
+ def sequence_relation(field, record, scope_attrs)
26
+ cfg = sequenceable_config.fetch(field)
27
+ rel = unscoped
28
+
29
+ cfg[:scope].each do |col|
30
+ value = record ? record[col] : (scope_attrs[col] || scope_attrs[col.to_s])
31
+ rel = rel.where(col => value)
32
+ end
33
+
34
+ return rel if cfg[:reset] == :never
35
+
36
+ rel.where(created_at: period_range(cfg[:reset], base_time(record)))
37
+ end
38
+
39
+ def format_sequence(field, seq, record)
40
+ cfg = sequenceable_config.fetch(field)
41
+ return cfg[:template].call(seq, record) if cfg[:template]
42
+
43
+ padded = cfg[:padding].positive? ? seq.to_s.rjust(cfg[:padding], "0") : seq.to_s
44
+ return "#{cfg[:prefix]}#{padded}" if cfg[:reset] == :never
45
+
46
+ token = period_token(cfg[:reset], base_time(record))
47
+ "#{cfg[:prefix]}#{token}#{cfg[:separator]}#{padded}"
48
+ end
49
+
50
+ def period_range(reset, time)
51
+ case reset
52
+ when :year then time.beginning_of_year..time.end_of_year
53
+ when :month then time.beginning_of_month..time.end_of_month
54
+ when :day then time.beginning_of_day..time.end_of_day
55
+ end
56
+ end
57
+
58
+ def period_token(reset, time)
59
+ case reset
60
+ when :year then time.year.to_s
61
+ when :month then time.strftime("%Y%m")
62
+ when :day then time.strftime("%Y%m%d")
63
+ end
64
+ end
65
+
66
+ # created_at is the natural anchor for the period, but it may not be set yet
67
+ # during before_create — fall back to the current time, which is what the
68
+ # timestamp will resolve to anyway.
69
+ def base_time(record)
70
+ record&.created_at || Time.current
71
+ end
72
+ end
73
+ end
74
+ end
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.10.0".freeze
2
+ VERSION = "1.11.1".freeze
3
3
  end
@@ -11,6 +11,7 @@ end
11
11
  require "concerns_on_rails/support/column_guard"
12
12
  require "concerns_on_rails/support/random_value"
13
13
  require "concerns_on_rails/support/address_data"
14
+ require "concerns_on_rails/support/sequence_calculator"
14
15
 
15
16
  # Model concerns
16
17
  require "concerns_on_rails/models/sluggable"
@@ -26,6 +27,7 @@ require "concerns_on_rails/models/activatable"
26
27
  require "concerns_on_rails/models/tokenizable"
27
28
  require "concerns_on_rails/models/stateable"
28
29
  require "concerns_on_rails/models/addressable"
30
+ require "concerns_on_rails/models/sequenceable"
29
31
 
30
32
  # Controller concerns
31
33
  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.10.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
@@ -85,6 +85,7 @@ files:
85
85
  - lib/concerns_on_rails/models/publishable.rb
86
86
  - lib/concerns_on_rails/models/schedulable.rb
87
87
  - lib/concerns_on_rails/models/searchable.rb
88
+ - lib/concerns_on_rails/models/sequenceable.rb
88
89
  - lib/concerns_on_rails/models/sluggable.rb
89
90
  - lib/concerns_on_rails/models/soft_deletable.rb
90
91
  - lib/concerns_on_rails/models/sortable.rb
@@ -93,6 +94,7 @@ files:
93
94
  - lib/concerns_on_rails/support/address_data.rb
94
95
  - lib/concerns_on_rails/support/column_guard.rb
95
96
  - lib/concerns_on_rails/support/random_value.rb
97
+ - lib/concerns_on_rails/support/sequence_calculator.rb
96
98
  - lib/concerns_on_rails/version.rb
97
99
  homepage: https://github.com/VSN2015/concerns_on_rails
98
100
  licenses: