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