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 +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +141 -4
- data/lib/concerns_on_rails/legacy_aliases.rb +2 -0
- data/lib/concerns_on_rails/models/addressable.rb +212 -0
- data/lib/concerns_on_rails/models/sequenceable.rb +135 -0
- data/lib/concerns_on_rails/support/address_data.rb +102 -0
- 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 +4 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a34c6e186b582806890012bb0f8bf049c1fd0f56f74007af7669cdb5ae713eb2
|
|
4
|
+
data.tar.gz: 298f957409be23ecfd9bbd0de46161ac56af7a01a6a300d29cf3bff9ca2b94d3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
- **
|
|
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.
|
|
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.
|
|
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.
|
|
@@ -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
|
data/lib/concerns_on_rails.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
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:
|