concerns_on_rails 1.9.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d01db047d91e470c1c2bb03f6fbe95be1eea7592199d9a548b6ac5dbacc84452
4
- data.tar.gz: f47e6149addbee52badc7ae4d56fbc66c636adc75d5a32f7742a57d18eda1cd8
3
+ metadata.gz: a34c6e186b582806890012bb0f8bf049c1fd0f56f74007af7669cdb5ae713eb2
4
+ data.tar.gz: 298f957409be23ecfd9bbd0de46161ac56af7a01a6a300d29cf3bff9ca2b94d3
5
5
  SHA512:
6
- metadata.gz: 1a856b89dd65fc126612d33aa276cfe49949484594a6d9c1cca2af01e91474883b1eb170e3f36647a125375ed645914cff6a1564cac3f955e2d7066a7c3c0e37
7
- data.tar.gz: 2056b850ffddb607084f672018498b4f366a0ab7c0f97bf22e806355031eae146c256f2f62c4bc3ef5e769ee75a3bd05d2bfc56864a5994ea9b204a1a9b730ab
6
+ metadata.gz: 6a96e094791d487cd9a534e4263de4bd902af9b533a8607ceb20906d4b38827c82ee0b370cb885d8a5800aef8a03a9b84cc718dc00074da1c0b1f35ade4fc2b3
7
+ data.tar.gz: e803d31da5694c6ad1a0ea28d928f9d552e75ded84420aa94cb816538e4cbe9b4a228f2b9dfa9428a8091b369444909d750523d2c5a0e9edae1f2df6fec95a78
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.10.0 (2026-06-03)
4
+
5
+ ### Added
6
+ - **Models::Addressable**: Declarative postal-address normalization + format validation via a single `addressable_by` macro. Maps the canonical parts (`line1` / `line2` / `city` / `state` / `postal_code` / `country`) onto real columns — any subset works, missing columns are skipped, and required parts are schema-checked. Normalizes in `before_validation` (strip + squish, postal-code upcasing with canonical CA spacing, 2-letter country/state codes upcased) and validates required-part presence, ISO 3166-1 alpha-2 country codes, per-country postal formats (US/CA/GB/AU/DE/FR + a permissive fallback), and opt-in US/CA state codes. Offline and dependency-free; layer real deliverability checks via the opt-in `verify_with:` callable. Adds helpers `full_address`, `address_lines`, `address_present?`, `address_complete?`, and `address_attributes`.
7
+
8
+ ### Internal
9
+ - Added `ConcernsOnRails::Support::AddressData` — ISO 3166-1 alpha-2 country codes, per-country postal-format patterns, US state / CA province sets, and the case-insensitive lookups (`valid_country?`, `postal_format_for`, `valid_state?`, `normalize_postal`) backing the Addressable concern.
10
+
3
11
  ## 1.9.0 (2026-05-25)
4
12
 
5
13
  ### Added
data/README.md CHANGED
@@ -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,7 +36,9 @@ 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
41
+ - [Addressable](#-addressable) — postal address normalization + format validation
40
42
  - **Controller concerns**
41
43
  - [Paginatable](#-paginatable) — offset pagination with headers
42
44
  - [Filterable](#-filterable) — declarative URL-param filters
@@ -53,7 +55,7 @@ Article.published.without_deleted.find("hello-world")
53
55
 
54
56
  ## ✨ Why this gem?
55
57
 
56
- - **Twelve model concerns + six controller concerns**, all production-ready
58
+ - **Fourteen model concerns + six controller concerns**, all production-ready
57
59
  - **One include, one macro** — no boilerplate, no glue code
58
60
  - **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
59
61
  - **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
@@ -66,7 +68,7 @@ Article.published.without_deleted.find("hello-world")
66
68
  Add to your application's `Gemfile`:
67
69
 
68
70
  ```ruby
69
- gem "concerns_on_rails", "~> 1.9"
71
+ gem "concerns_on_rails", "~> 1.11"
70
72
  ```
71
73
 
72
74
  Or pull the latest from GitHub:
@@ -577,6 +579,70 @@ User.authenticate_by_api_token(token) # timing-safe; returns user or nil
577
579
 
578
580
  ---
579
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
+
580
646
  ## 🔄 Stateable
581
647
 
582
648
  Lightweight string-backed state machine — the 80% of AASM without the dependency.
@@ -630,6 +696,77 @@ stateable_by :state, states: %i[open closed], prefix: true
630
696
 
631
697
  ---
632
698
 
699
+ ## 🏠 Addressable
700
+
701
+ Normalize and format-validate a postal address spread across several columns — one macro for whitespace cleanup, postal-code and ISO country-code checks, required-part presence, and a `full_address` helper. No external geocoding service required.
702
+
703
+ ```ruby
704
+ class Location < ApplicationRecord
705
+ include ConcernsOnRails::Addressable
706
+
707
+ addressable_by # standard columns: line1, line2, city, state, postal_code, country
708
+ end
709
+
710
+ loc = Location.create(line1: " 1 Infinite Loop ", city: "Cupertino",
711
+ state: "ca", postal_code: "95014", country: "us")
712
+ loc.line1 # => "1 Infinite Loop" (stripped + squished)
713
+ loc.state # => "CA" (2-letter code upcased)
714
+ loc.country # => "US"
715
+ loc.full_address # => "1 Infinite Loop, Cupertino, CA, 95014, US"
716
+ ```
717
+
718
+ Map onto your own column names and tune behavior:
719
+
720
+ ```ruby
721
+ class Place < ApplicationRecord
722
+ include ConcernsOnRails::Addressable
723
+
724
+ addressable_by line1: :street, postal_code: :zip, country: :country_code,
725
+ required: %i[line1 city postal_code country],
726
+ default_country: "GB", # country used when the record has none
727
+ validate_state: true, # opt-in US/CA state-code check
728
+ verify_with: ->(rec) { Usps.verify(rec) } # opt-in external verifier
729
+ end
730
+ ```
731
+
732
+ **Options**
733
+
734
+ | Option | Default | Purpose |
735
+ |-------------------|--------------------------------------|---------------------------------------------------------------------|
736
+ | `line1:` … `country:` | same-named columns | Map each canonical part to a real column. Missing columns are skipped. |
737
+ | `required:` | `%i[line1 city postal_code country]` | Parts (by canonical name) that must be present. Each must map to an existing column. |
738
+ | `default_country:`| `"US"` | Country used to pick the postal-code format when the record has no recognized country. |
739
+ | `validate_state:` | `false` | When `true`, validates the state against US / CA code sets. |
740
+ | `verify_with:` | `nil` | A callable for real-world verification (see below). |
741
+
742
+ **What it normalizes** (in `before_validation`)
743
+ - Text parts: `strip` + `squish`.
744
+ - `postal_code`: squish + upcase, with canonical spacing for CA (`A1A1A1` → `A1A 1A1`).
745
+ - `country` / `state`: upcased when they look like a 2-letter code (full names left alone).
746
+
747
+ **What it validates**
748
+ - Presence of every `required:` part.
749
+ - `country`: a 2-letter value must be a real ISO 3166-1 alpha-2 code.
750
+ - `postal_code`: matched against a per-country pattern (US, CA, GB, AU, DE, FR) with a permissive fallback for everything else.
751
+ - `state`: only when `validate_state: true` and the country is US/CA.
752
+
753
+ **External verification (`verify_with:`)** — runs **only after** structural validation passes, so you never spend an API call on an obviously-broken address. The callable receives the record and may either add to `record.errors` itself, or return:
754
+
755
+ | Return value | Effect |
756
+ |-------------------|-------------------------------------------------|
757
+ | `true` / `nil` | success |
758
+ | `false` | adds a generic `:base` error |
759
+ | `String` | added as a `:base` error |
760
+ | `Array` | each element added as a `:base` error |
761
+
762
+ **Notes**
763
+ - Scope is **format/structure only** — it checks shape, not real-world deliverability. Plug a USPS/Google/Smarty client into `verify_with:` for that.
764
+ - Error messages are plain English strings — no host-app i18n setup required.
765
+ - Partial schemas just work: a model without a `line2` (or any other) column simply omits that part.
766
+ - Pairs with [Normalizable](#-normalizable) when you also have non-address fields to clean up.
767
+
768
+ ---
769
+
633
770
  # 🎮 Controller Concerns
634
771
 
635
772
  Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
@@ -884,7 +1021,7 @@ Both forms reference the same module, so you can freely mix them.
884
1021
  bundle install # install dev dependencies
885
1022
  bundle exec rspec # run the test suite
886
1023
  gem build concerns_on_rails.gemspec # build the gem
887
- gem install ./concerns_on_rails-1.9.0.gem # install locally
1024
+ gem install ./concerns_on_rails-1.11.0.gem # install locally
888
1025
  ```
889
1026
 
890
1027
  The test suite uses an in-memory SQLite database and a lightweight `FakeController` harness for controller-concern specs — no Rails routes or boot required.
@@ -14,4 +14,6 @@ module ConcernsOnRails
14
14
  Activatable = Models::Activatable
15
15
  Tokenizable = Models::Tokenizable
16
16
  Stateable = Models::Stateable
17
+ Addressable = Models::Addressable
18
+ Sequenceable = Models::Sequenceable
17
19
  end
@@ -0,0 +1,212 @@
1
+ require "active_support/concern"
2
+
3
+ module ConcernsOnRails
4
+ module Models
5
+ # Declarative normalization + format validation for a postal address spread
6
+ # across several columns. One macro wires up whitespace cleanup, postal-code
7
+ # and ISO country-code checks, required-part presence, and a `full_address`
8
+ # helper — no external geocoding service required.
9
+ #
10
+ # class Location < ApplicationRecord
11
+ # include ConcernsOnRails::Addressable
12
+ #
13
+ # addressable_by # standard line1/line2/city/state/postal_code/country columns
14
+ # # addressable_by line1: :street, postal_code: :zip, country: :country_code,
15
+ # # required: %i[line1 city postal_code], default_country: "GB",
16
+ # # validate_state: true, verify_with: ->(rec) { Usps.verify(rec) }
17
+ # end
18
+ #
19
+ # Scope is *format/structure* only — it checks shape, not real-world
20
+ # deliverability. Layer a real verifier on via `verify_with:`. Relates to
21
+ # the per-field Normalizable concern.
22
+ module Addressable
23
+ extend ActiveSupport::Concern
24
+
25
+ # Canonical address part => default column name.
26
+ DEFAULT_FIELDS = {
27
+ line1: :line1, line2: :line2, city: :city,
28
+ state: :state, postal_code: :postal_code, country: :country
29
+ }.freeze
30
+
31
+ # Parts required by default (each must map to an existing column).
32
+ DEFAULT_REQUIRED = %i[line1 city postal_code country].freeze
33
+
34
+ included do
35
+ class_attribute :addressable_fields, instance_accessor: false, default: {}
36
+ class_attribute :addressable_required, instance_accessor: false, default: [].freeze
37
+ class_attribute :addressable_default_country, instance_accessor: false, default: "US"
38
+ class_attribute :addressable_validate_state, instance_accessor: false, default: false
39
+ class_attribute :addressable_verifier, instance_accessor: false, default: nil
40
+
41
+ before_validation :normalize_address
42
+ validate :validate_address
43
+ end
44
+
45
+ # Defined as a real module (not `class_methods do`) so the public macro and
46
+ # its private helpers share one `private` and aren't constrained by
47
+ # Metrics/BlockLength. ActiveSupport::Concern auto-extends `ClassMethods`.
48
+ module ClassMethods
49
+ include ConcernsOnRails::Support::ColumnGuard
50
+
51
+ # Configure the address. Column overrides are passed as `part: :column`
52
+ # keyword pairs; everything else tunes behavior. See the module docs.
53
+ def addressable_by(required: DEFAULT_REQUIRED, default_country: "US",
54
+ validate_state: false, verify_with: nil, **mapping)
55
+ self.addressable_fields = resolve_addressable_fields(mapping)
56
+ self.addressable_required = Array(required).map(&:to_sym)
57
+ self.addressable_default_country = default_country.to_s.upcase
58
+ self.addressable_validate_state = validate_state
59
+ self.addressable_verifier = verify_with
60
+ ensure_required_columns!
61
+ end
62
+
63
+ private
64
+
65
+ def resolve_addressable_fields(mapping)
66
+ unknown = mapping.keys.map(&:to_sym) - DEFAULT_FIELDS.keys
67
+ raise ArgumentError, "ConcernsOnRails::Models::Addressable: unknown address part(s): #{unknown.join(', ')}" if unknown.any?
68
+
69
+ overrides = mapping.to_h { |part, column| [part.to_sym, column.to_sym] }
70
+ ensure_columns!("ConcernsOnRails::Models::Addressable", overrides.values)
71
+ DEFAULT_FIELDS.merge(overrides).select { |_part, column| column_names.include?(column.to_s) }
72
+ end
73
+
74
+ def ensure_required_columns!
75
+ missing = addressable_required.reject { |part| addressable_fields.key?(part) }
76
+ return if missing.empty?
77
+
78
+ raise ArgumentError,
79
+ "ConcernsOnRails::Models::Addressable: required address part(s) #{missing.join(', ')} " \
80
+ "have no matching column (table: #{table_name})"
81
+ end
82
+ end
83
+
84
+ # --- Normalization (before_validation) ------------------------------------
85
+
86
+ def normalize_address
87
+ country = resolved_country
88
+ self.class.addressable_fields.each do |part, column|
89
+ value = self[column]
90
+ next unless value.is_a?(String)
91
+
92
+ self[column] = normalize_part(part, country, value)
93
+ end
94
+ end
95
+
96
+ # --- Validation -----------------------------------------------------------
97
+
98
+ def validate_address
99
+ validate_required_parts
100
+ validate_country_code
101
+ validate_postal_code
102
+ validate_state_code if self.class.addressable_validate_state
103
+ run_address_verifier if self.class.addressable_verifier && errors.empty?
104
+ end
105
+
106
+ # --- Public helpers -------------------------------------------------------
107
+
108
+ # The present parts joined into a single line, in canonical order.
109
+ def full_address(separator: ", ")
110
+ address_lines.join(separator)
111
+ end
112
+
113
+ # The present parts as an ordered array (handy for multi-line rendering).
114
+ def address_lines
115
+ ordered_parts.filter_map { |part| self[self.class.addressable_fields[part]].presence }
116
+ end
117
+
118
+ # True when any configured part has a value.
119
+ def address_present?
120
+ ordered_parts.any? { |part| self[self.class.addressable_fields[part]].present? }
121
+ end
122
+
123
+ # True when every required part has a value (presence only, no format check).
124
+ def address_complete?
125
+ self.class.addressable_required.all? do |part|
126
+ (column = self.class.addressable_fields[part]) && self[column].present?
127
+ end
128
+ end
129
+
130
+ # `{ part => value }` for the present parts (handy for serializers / verifiers).
131
+ def address_attributes
132
+ self.class.addressable_fields.each_with_object({}) do |(part, column), acc|
133
+ value = self[column]
134
+ acc[part] = value if value.present?
135
+ end
136
+ end
137
+
138
+ private
139
+
140
+ def ordered_parts
141
+ DEFAULT_FIELDS.keys.select { |part| self.class.addressable_fields.key?(part) }
142
+ end
143
+
144
+ # The 2-letter country code driving postal/state checks: the record's own
145
+ # country when it's a recognized code, otherwise the configured default.
146
+ def resolved_country
147
+ column = self.class.addressable_fields[:country]
148
+ value = column && self[column]
149
+ return self.class.addressable_default_country unless value.is_a?(String)
150
+
151
+ code = value.strip.upcase
152
+ ConcernsOnRails::Support::AddressData.valid_country?(code) ? code : self.class.addressable_default_country
153
+ end
154
+
155
+ def normalize_part(part, country, value)
156
+ squished = value.strip.squish
157
+ case part
158
+ when :postal_code then ConcernsOnRails::Support::AddressData.normalize_postal(country, value)
159
+ when :country, :state then squished.length == 2 ? squished.upcase : squished
160
+ else squished
161
+ end
162
+ end
163
+
164
+ def validate_required_parts
165
+ fields = self.class.addressable_fields
166
+ self.class.addressable_required.each do |part|
167
+ column = fields[part]
168
+ errors.add(column, "can't be blank") if column && self[column].blank?
169
+ end
170
+ end
171
+
172
+ def validate_country_code
173
+ column = self.class.addressable_fields[:country]
174
+ value = column && self[column]
175
+ return unless value.is_a?(String) && value.length == 2
176
+ return if ConcernsOnRails::Support::AddressData.valid_country?(value)
177
+
178
+ errors.add(column, "is not a valid ISO 3166-1 country code")
179
+ end
180
+
181
+ def validate_postal_code
182
+ column = self.class.addressable_fields[:postal_code]
183
+ value = column && self[column]
184
+ return if value.blank?
185
+
186
+ format = ConcernsOnRails::Support::AddressData.postal_format_for(resolved_country)
187
+ errors.add(column, "is not a valid postal code") unless value.to_s.match?(format)
188
+ end
189
+
190
+ def validate_state_code
191
+ column = self.class.addressable_fields[:state]
192
+ value = column && self[column]
193
+ return if value.blank?
194
+ return if ConcernsOnRails::Support::AddressData.valid_state?(resolved_country, value)
195
+
196
+ errors.add(column, "is not a valid state/province")
197
+ end
198
+
199
+ def run_address_verifier
200
+ apply_verifier_result(self.class.addressable_verifier.call(self))
201
+ end
202
+
203
+ def apply_verifier_result(result)
204
+ case result
205
+ when false then errors.add(:base, "address could not be verified")
206
+ when String then errors.add(:base, result)
207
+ when Array then result.each { |message| errors.add(:base, message) }
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,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
@@ -0,0 +1,102 @@
1
+ module ConcernsOnRails
2
+ module Support
3
+ # Reference data + lookups for Models::Addressable. Kept here (mirroring
4
+ # ColumnGuard / RandomValue) so the concern itself stays lean and so the
5
+ # large constant tables live as plain literals rather than RuboCop-flagged
6
+ # blocks. All lookups are case-insensitive and string-safe.
7
+ #
8
+ # Scope is *format/structure* only — this validates shape (a well-formed
9
+ # postal code, a real ISO country code), never real-world deliverability.
10
+ module AddressData
11
+ module_function
12
+
13
+ # ISO 3166-1 alpha-2 country codes.
14
+ ISO_COUNTRY_CODES = Set.new(%w[
15
+ AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ
16
+ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ
17
+ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ
18
+ DE DJ DK DM DO DZ
19
+ EC EE EG EH ER ES ET
20
+ FI FJ FK FM FO FR
21
+ GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY
22
+ HK HM HN HR HT HU
23
+ ID IE IL IM IN IO IQ IR IS IT
24
+ JE JM JO JP
25
+ KE KG KH KI KM KN KP KR KW KY KZ
26
+ LA LB LC LI LK LR LS LT LU LV LY
27
+ MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ
28
+ NA NC NE NF NG NI NL NO NP NR NU NZ
29
+ OM
30
+ PA PE PF PG PH PK PL PM PN PR PS PT PW PY
31
+ QA
32
+ RE RO RS RU RW
33
+ SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ
34
+ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ
35
+ UA UG UM US UY UZ
36
+ VA VC VE VG VI VN VU
37
+ WF WS
38
+ YE YT
39
+ ZA ZM ZW
40
+ ]).freeze
41
+
42
+ # Per-country postal-code patterns (matched against the *normalized*,
43
+ # upcased value). `:default` is a permissive fallback for everything else.
44
+ POSTAL_FORMATS = {
45
+ "US" => /\A\d{5}(-\d{4})?\z/,
46
+ "CA" => /\A[A-Z]\d[A-Z] ?\d[A-Z]\d\z/,
47
+ "GB" => /\A[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}\z/,
48
+ "AU" => /\A\d{4}\z/,
49
+ "DE" => /\A\d{5}\z/,
50
+ "FR" => /\A\d{5}\z/,
51
+ default: /\A[A-Z0-9][A-Z0-9 -]{1,8}[A-Z0-9]\z/
52
+ }.freeze
53
+
54
+ # USPS state / territory abbreviations.
55
+ US_STATES = Set.new(%w[
56
+ AL AK AZ AR CA CO CT DE FL GA HI ID IL IN IA KS KY LA ME MD MA MI MN MS
57
+ MO MT NE NV NH NJ NM NY NC ND OH OK OR PA RI SC SD TN TX UT VT VA WA WV
58
+ WI WY DC AS GU MP PR VI
59
+ ]).freeze
60
+
61
+ # Canadian province / territory codes.
62
+ CA_PROVINCES = Set.new(%w[AB BC MB NB NL NS NT NU ON PE QC SK YT]).freeze
63
+
64
+ # True when `code` is a known ISO 3166-1 alpha-2 country code.
65
+ def valid_country?(code)
66
+ return false unless code.is_a?(String)
67
+
68
+ ISO_COUNTRY_CODES.include?(code.upcase)
69
+ end
70
+
71
+ # Regexp to validate a postal code for the given country (falls back to
72
+ # the permissive `:default` pattern for unmapped countries).
73
+ def postal_format_for(country)
74
+ POSTAL_FORMATS[country.to_s.upcase] || POSTAL_FORMATS[:default]
75
+ end
76
+
77
+ # Validate a state/region against US / CA sets. Returns true for any other
78
+ # country (we only know those two), so callers needn't special-case.
79
+ def valid_state?(country, code)
80
+ return true unless code.is_a?(String)
81
+
82
+ case country.to_s.upcase
83
+ when "US" then US_STATES.include?(code.upcase)
84
+ when "CA" then CA_PROVINCES.include?(code.upcase)
85
+ else true
86
+ end
87
+ end
88
+
89
+ # Squish + upcase a postal code, adding canonical spacing for CA
90
+ # (`A1A1A1` -> `A1A 1A1`). Non-strings pass through unchanged.
91
+ def normalize_postal(country, value)
92
+ return value unless value.is_a?(String)
93
+
94
+ normalized = value.strip.squish.upcase
95
+ return normalized unless country.to_s.upcase == "CA"
96
+
97
+ compact = normalized.delete(" ")
98
+ compact.match?(/\A[A-Z]\d[A-Z]\d[A-Z]\d\z/) ? "#{compact[0, 3]} #{compact[3, 3]}" : normalized
99
+ end
100
+ end
101
+ end
102
+ end
@@ -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.9.0".freeze
2
+ VERSION = "1.11.0".freeze
3
3
  end
@@ -10,6 +10,8 @@ end
10
10
  # Shared internal helpers (must load before the concerns that use them)
11
11
  require "concerns_on_rails/support/column_guard"
12
12
  require "concerns_on_rails/support/random_value"
13
+ require "concerns_on_rails/support/address_data"
14
+ require "concerns_on_rails/support/sequence_calculator"
13
15
 
14
16
  # Model concerns
15
17
  require "concerns_on_rails/models/sluggable"
@@ -24,6 +26,8 @@ require "concerns_on_rails/models/searchable"
24
26
  require "concerns_on_rails/models/activatable"
25
27
  require "concerns_on_rails/models/tokenizable"
26
28
  require "concerns_on_rails/models/stateable"
29
+ require "concerns_on_rails/models/addressable"
30
+ require "concerns_on_rails/models/sequenceable"
27
31
 
28
32
  # Controller concerns
29
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.9.0
4
+ version: 1.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Nguyen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-25 00:00:00.000000000 Z
11
+ date: 2026-06-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -78,19 +78,23 @@ files:
78
78
  - lib/concerns_on_rails/controllers/sortable.rb
79
79
  - lib/concerns_on_rails/legacy_aliases.rb
80
80
  - lib/concerns_on_rails/models/activatable.rb
81
+ - lib/concerns_on_rails/models/addressable.rb
81
82
  - lib/concerns_on_rails/models/expirable.rb
82
83
  - lib/concerns_on_rails/models/hashable.rb
83
84
  - lib/concerns_on_rails/models/normalizable.rb
84
85
  - lib/concerns_on_rails/models/publishable.rb
85
86
  - lib/concerns_on_rails/models/schedulable.rb
86
87
  - lib/concerns_on_rails/models/searchable.rb
88
+ - lib/concerns_on_rails/models/sequenceable.rb
87
89
  - lib/concerns_on_rails/models/sluggable.rb
88
90
  - lib/concerns_on_rails/models/soft_deletable.rb
89
91
  - lib/concerns_on_rails/models/sortable.rb
90
92
  - lib/concerns_on_rails/models/stateable.rb
91
93
  - lib/concerns_on_rails/models/tokenizable.rb
94
+ - lib/concerns_on_rails/support/address_data.rb
92
95
  - lib/concerns_on_rails/support/column_guard.rb
93
96
  - lib/concerns_on_rails/support/random_value.rb
97
+ - lib/concerns_on_rails/support/sequence_calculator.rb
94
98
  - lib/concerns_on_rails/version.rb
95
99
  homepage: https://github.com/VSN2015/concerns_on_rails
96
100
  licenses: