concerns_on_rails 1.10.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: 743380a3be200eda289e97edef227f8b900550ba08af52c4a06b995fc5c8297a
4
- data.tar.gz: 7e8e8cb4bfd923819ec96178d75e68378829fb0f80b5a2e3cf3cd84e150fcc32
3
+ metadata.gz: a34c6e186b582806890012bb0f8bf049c1fd0f56f74007af7669cdb5ae713eb2
4
+ data.tar.gz: 298f957409be23ecfd9bbd0de46161ac56af7a01a6a300d29cf3bff9ca2b94d3
5
5
  SHA512:
6
- metadata.gz: 48e77b0bb95ee7419207289be7e1f74166f43aa4e31f4af6a6269ac2a84ccb90ac38e81a2a11a44cdafb7bd678d48153070c41d502b37f0a6baeabfd114f73f0
7
- data.tar.gz: bb03d4bcfb6b26b7963f70cdaee8e591293fc8f96e34aa636aab84df6ce20647ae9df01e3d52554c8b6a9cb52cfce68c2da2c0f967ede2a3ec7960be737f4bd2
6
+ metadata.gz: 6a96e094791d487cd9a534e4263de4bd902af9b533a8607ceb20906d4b38827c82ee0b370cb885d8a5800aef8a03a9b84cc718dc00074da1c0b1f35ade4fc2b3
7
+ data.tar.gz: e803d31da5694c6ad1a0ea28d928f9d552e75ded84420aa94cb816538e4cbe9b4a228f2b9dfa9428a8091b369444909d750523d2c5a0e9edae1f2df6fec95a78
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > ๐Ÿ‡ป๐Ÿ‡ณ **Hoร ng Sa and Trฦฐแปng Sa belong to Viแป‡t Nam.**
4
4
 
5
- A plug-and-play collection of reusable ActiveSupport concerns for Rails **models** and **controllers** โ€” slugs, soft delete, scheduled publish, expiry, pagination, filtering, JSON envelopes, and more. One `include`, one declarative macro, done.
5
+ A plug-and-play collection of reusable ActiveSupport concerns for Rails **models** and **controllers** โ€” slugs, soft delete, scheduled publish, expiry, sequential reference numbers, pagination, filtering, JSON envelopes, and more. One `include`, one declarative macro, done.
6
6
 
7
7
  ```ruby
8
8
  class Article < ApplicationRecord
@@ -36,6 +36,7 @@ Article.published.without_deleted.find("hello-world")
36
36
  - [Searchable](#-searchable) โ€” LIKE/ILIKE search across configured columns
37
37
  - [Activatable](#-activatable) โ€” boolean active/inactive toggle
38
38
  - [Tokenizable](#-tokenizable) โ€” security tokens with timing-safe lookup
39
+ - [Sequenceable](#-sequenceable) โ€” ordered, human-friendly reference numbers
39
40
  - [Stateable](#-stateable) โ€” lightweight string-backed state machine
40
41
  - [Addressable](#-addressable) โ€” postal address normalization + format validation
41
42
  - **Controller concerns**
@@ -54,7 +55,7 @@ Article.published.without_deleted.find("hello-world")
54
55
 
55
56
  ## โœจ Why this gem?
56
57
 
57
- - **Thirteen model concerns + six controller concerns**, all production-ready
58
+ - **Fourteen model concerns + six controller concerns**, all production-ready
58
59
  - **One include, one macro** โ€” no boilerplate, no glue code
59
60
  - **Lean dependencies** โ€” only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
60
61
  - **Schema-validated configuration** โ€” every macro checks that the configured column exists and raises `ArgumentError` early
@@ -67,7 +68,7 @@ Article.published.without_deleted.find("hello-world")
67
68
  Add to your application's `Gemfile`:
68
69
 
69
70
  ```ruby
70
- gem "concerns_on_rails", "~> 1.9"
71
+ gem "concerns_on_rails", "~> 1.11"
71
72
  ```
72
73
 
73
74
  Or pull the latest from GitHub:
@@ -578,6 +579,70 @@ User.authenticate_by_api_token(token) # timing-safe; returns user or nil
578
579
 
579
580
  ---
580
581
 
582
+ ## ๐Ÿงพ Sequenceable
583
+
584
+ Ordered, human-friendly reference numbers โ€” invoice numbers, order numbers, ticket IDs, support cases. Unlike the *random* identifiers from [Hashable](#-hashable) / [Tokenizable](#-tokenizable), `Sequenceable` produces *sequential* ones backed by an integer column that is the source of truth.
585
+
586
+ ```ruby
587
+ class Invoice < ApplicationRecord
588
+ include ConcernsOnRails::Sequenceable
589
+
590
+ sequenceable_by :sequence, # integer column โ€” the source of truth
591
+ into: :number, # optional string column for the formatted value
592
+ prefix: "INV-",
593
+ padding: 5,
594
+ scope: :account_id, # one independent counter per account
595
+ reset: :year # restart numbering each calendar year
596
+ end
597
+
598
+ invoice = Invoice.create!(account_id: 1)
599
+ invoice.sequence # => 1, 2, 3 ... (per account, per year)
600
+ invoice.number # => "INV-2026-00001"
601
+ invoice.formatted_sequence # => "INV-2026-00001"
602
+
603
+ Invoice.next_sequence(account_id: 1) # => 4 (peek the next value, without creating)
604
+ ```
605
+
606
+ **Options**
607
+
608
+ | Option | Default | Purpose |
609
+ |----------------------|-------------|------------------------------------------------------------------------------------------|
610
+ | `field` (positional) | `:sequence` | Integer column holding the sequence โ€” the source of truth. |
611
+ | `into:` | `nil` | String column to persist the formatted reference into (immutable display value). |
612
+ | `prefix:` | `""` | Prepended to the formatted value. |
613
+ | `padding:` | `0` | Zero-pad width of the numeric portion (`0` = no padding). |
614
+ | `separator:` | `"-"` | Joins prefix / period token / number in the default format. |
615
+ | `start_at:` | `1` | First value when the scope/period has no rows yet. |
616
+ | `scope:` | `nil` | Column (or array of columns) the counter is scoped to โ€” e.g. one sequence per `account_id`. |
617
+ | `reset:` | `:never` | `:never` / `:year` / `:month` / `:day` โ€” restart numbering each period (needs `created_at`). |
618
+ | `template:` | `nil` | `->(seq, record) { ... }` full custom formatter; overrides `prefix` / `padding` / period. |
619
+
620
+ **Default format**
621
+
622
+ | `reset:` | Example | Shape |
623
+ |-----------|-----------------------|----------------------------------|
624
+ | `:never` | `INV-00001` | `prefix + padded` |
625
+ | `:year` | `INV-2026-00001` | `prefix + YYYY + sep + padded` |
626
+ | `:month` | `INV-202606-00001` | `prefix + YYYYMM + sep + padded` |
627
+ | `:day` | `INV-20260604-00001` | `prefix + YYYYMMDD + sep + padded` |
628
+
629
+ **Generated API**
630
+
631
+ | Method | What it does |
632
+ |-----------------------------------|---------------------------------------------------------------------------------------|
633
+ | `formatted_<field>` | The formatted string โ€” the persisted `into:` value when set, otherwise computed. |
634
+ | `Model.next_<field>(scope_attrs)` | Peek the next integer for a scope without creating a record. |
635
+
636
+ **Notes**
637
+ - The next value is `MAX(<field>) + 1` within the scope (and period), so numbering is dense and ordered โ€” not random.
638
+ - Caller-supplied values are respected: `Invoice.create!(sequence: 100)` is not overwritten (and its `into:` string is still formatted from `100`).
639
+ - Generation reads `MAX` then inserts, so two concurrent inserts can race. It's **best-effort** โ€” add a **scoped unique index** on `<field>` (and on `into:`) for a real guarantee, the same way you would for any `MAX`-based numbering.
640
+ - `reset:` requires a `created_at` column; the period is taken from each row's creation time.
641
+ - For fixed-width display (`00042`), make the `into:` column a **string** โ€” integer columns drop leading zeros.
642
+ - Distinct from `Hashable` / `Tokenizable`, which generate *random* values; reach for those when the identifier must be unguessable.
643
+
644
+ ---
645
+
581
646
  ## ๐Ÿ”„ Stateable
582
647
 
583
648
  Lightweight string-backed state machine โ€” the 80% of AASM without the dependency.
@@ -956,7 +1021,7 @@ Both forms reference the same module, so you can freely mix them.
956
1021
  bundle install # install dev dependencies
957
1022
  bundle exec rspec # run the test suite
958
1023
  gem build concerns_on_rails.gemspec # build the gem
959
- gem install ./concerns_on_rails-1.9.0.gem # install locally
1024
+ gem install ./concerns_on_rails-1.11.0.gem # install locally
960
1025
  ```
961
1026
 
962
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.
@@ -15,4 +15,5 @@ module ConcernsOnRails
15
15
  Tokenizable = Models::Tokenizable
16
16
  Stateable = Models::Stateable
17
17
  Addressable = Models::Addressable
18
+ Sequenceable = Models::Sequenceable
18
19
  end
@@ -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,74 @@
1
+ module ConcernsOnRails
2
+ module Support
3
+ # Internal helpers for Models::Sequenceable: computing the next value within a
4
+ # scope (+ period) and formatting it. Mixed into the model's class methods, so
5
+ # `self` is the model class and `unscoped` / `sequenceable_config` resolve
6
+ # against it. Kept here to keep the concern itself focused on configuration.
7
+ module SequenceCalculator
8
+ private
9
+
10
+ # Next integer that would be assigned for the given scope: MAX within the
11
+ # scope (+ period) + 1, or start_at when the scope/period is still empty.
12
+ def sequence_base_value(field, record, scope_attrs)
13
+ cfg = sequenceable_config.fetch(field)
14
+ max = sequence_relation(field, record, scope_attrs).maximum(field)
15
+ max ? max + 1 : cfg[:start_at]
16
+ end
17
+
18
+ def sequence_value_taken?(field, candidate, record, scope_attrs)
19
+ sequence_relation(field, record, scope_attrs).exists?(field => candidate)
20
+ end
21
+
22
+ # Relation of existing rows that share this record's scope (and period, when
23
+ # reset is enabled). Reads from `unscoped` so a model's default_scope never
24
+ # hides rows the counter must account for.
25
+ def sequence_relation(field, record, scope_attrs)
26
+ cfg = sequenceable_config.fetch(field)
27
+ rel = unscoped
28
+
29
+ cfg[:scope].each do |col|
30
+ value = record ? record[col] : (scope_attrs[col] || scope_attrs[col.to_s])
31
+ rel = rel.where(col => value)
32
+ end
33
+
34
+ return rel if cfg[:reset] == :never
35
+
36
+ rel.where(created_at: period_range(cfg[:reset], base_time(record)))
37
+ end
38
+
39
+ def format_sequence(field, seq, record)
40
+ cfg = sequenceable_config.fetch(field)
41
+ return cfg[:template].call(seq, record) if cfg[:template]
42
+
43
+ padded = cfg[:padding].positive? ? seq.to_s.rjust(cfg[:padding], "0") : seq.to_s
44
+ return "#{cfg[:prefix]}#{padded}" if cfg[:reset] == :never
45
+
46
+ token = period_token(cfg[:reset], base_time(record))
47
+ "#{cfg[:prefix]}#{token}#{cfg[:separator]}#{padded}"
48
+ end
49
+
50
+ def period_range(reset, time)
51
+ case reset
52
+ when :year then time.beginning_of_year..time.end_of_year
53
+ when :month then time.beginning_of_month..time.end_of_month
54
+ when :day then time.beginning_of_day..time.end_of_day
55
+ end
56
+ end
57
+
58
+ def period_token(reset, time)
59
+ case reset
60
+ when :year then time.year.to_s
61
+ when :month then time.strftime("%Y%m")
62
+ when :day then time.strftime("%Y%m%d")
63
+ end
64
+ end
65
+
66
+ # created_at is the natural anchor for the period, but it may not be set yet
67
+ # during before_create โ€” fall back to the current time, which is what the
68
+ # timestamp will resolve to anyway.
69
+ def base_time(record)
70
+ record&.created_at || Time.current
71
+ end
72
+ end
73
+ end
74
+ end
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.10.0".freeze
2
+ VERSION = "1.11.0".freeze
3
3
  end
@@ -11,6 +11,7 @@ end
11
11
  require "concerns_on_rails/support/column_guard"
12
12
  require "concerns_on_rails/support/random_value"
13
13
  require "concerns_on_rails/support/address_data"
14
+ require "concerns_on_rails/support/sequence_calculator"
14
15
 
15
16
  # Model concerns
16
17
  require "concerns_on_rails/models/sluggable"
@@ -26,6 +27,7 @@ require "concerns_on_rails/models/activatable"
26
27
  require "concerns_on_rails/models/tokenizable"
27
28
  require "concerns_on_rails/models/stateable"
28
29
  require "concerns_on_rails/models/addressable"
30
+ require "concerns_on_rails/models/sequenceable"
29
31
 
30
32
  # Controller concerns
31
33
  require "concerns_on_rails/controllers/paginatable"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: concerns_on_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.10.0
4
+ version: 1.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Nguyen
@@ -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: