concerns_on_rails 1.2.0 → 1.3.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: 603d571510be78d62d3d71f071b703dbf70bf505e6463e1766e4a99c4c039012
4
- data.tar.gz: eefa6a75c74a0ff0db01e129cc85c73af7ae3b16fb38c2ceab757ff5fc1e2656
3
+ metadata.gz: 0d4b68b3625ea62546fa003654e7bd0618a56b09b615b406dcaed12f16560338
4
+ data.tar.gz: 4d048584cdf09f1eb9859a4cca0ccd64182b331bfa8a6ffb897ff61ab1c4636a
5
5
  SHA512:
6
- metadata.gz: 01b37a0a698df95131be947ebc0874c970dc1f4cadf591aca2de84b1bc4da8075967cc5d469a45713165d07c763e55c7296b34a621248bffa82e7af127c5b5b0
7
- data.tar.gz: e0978b66b4208ef653563602e9b153f10f56867cd2ad821ae8323f12fad8eff9c917127f64ae567a0ffa62945b562c5244aed72e743419e47a22a1de8f121ad1
6
+ metadata.gz: 8797f42f52e32309614fa95bf2a52b0a17e6adff54f07bbdcc9f9f27863a92fdfe0dfec1a14d7ad9b34285e25abc6a12cf37194a4da4532874a29d0fc7a13b01
7
+ data.tar.gz: e60133f68c2b4778cdea6b34fdcddd279eee5076ef060c009734c9e42eda3d379d65c9adfa847dd30dcb47e06e4a39c88313644fa248e407a5d60e242e8e3756
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.3.0 (2026-05-16)
4
+
5
+ ### Added
6
+ - Hashable: Auto-generate a random value on create (`:hex`, `:uuid`, `:integer`, or `:custom` alphabet). Adds `hashable_by` macro and a dynamic `regenerate_<field>!` instance method.
7
+
3
8
  ## 1.1.0 (2025-04-17)
4
9
 
5
10
  ### Added
data/README.md CHANGED
@@ -10,6 +10,7 @@ A simple collection of reusable Rails concerns to keep your models clean and DRY
10
10
  - 🔢 `Sortable`: Sort records based on a field using `acts_as_list`, with flexible sorting field and direction
11
11
  - 📤 `Publishable`: Easily manage published/unpublished records using a simple `published_at` field
12
12
  - ❌ `SoftDeletable`: Soft delete records using a configurable timestamp field (e.g., `deleted_at`) with automatic scoping
13
+ - 🔐 `Hashable`: Auto-generate a random hex/UUID/integer/custom-alphabet value on create, with a `regenerate_<field>!` helper
13
14
 
14
15
  ---
15
16
 
@@ -194,6 +195,49 @@ end
194
195
 
195
196
  ---
196
197
 
198
+ ### 5. Hashable
199
+
200
+ Auto-generate a random value (hex, UUID, fixed-digit integer, or custom-alphabet string) on create.
201
+
202
+ ```ruby
203
+ class Order < ApplicationRecord
204
+ include ConcernsOnRails::Hashable
205
+
206
+ # Defaults: type: :hex, length: 16 (32-char hex string)
207
+ hashable_by :token
208
+ end
209
+
210
+ order = Order.create!
211
+ order.token # => "a3f7c9b1e2d40859e2f1c9b73d40a857"
212
+ order.regenerate_token! # rolls a new random value and persists it
213
+ ```
214
+
215
+ #### Types
216
+
217
+ ```ruby
218
+ hashable_by :token, type: :hex, length: 16
219
+ hashable_by :external_id, type: :uuid
220
+ hashable_by :code, type: :integer, length: 6
221
+ hashable_by :code, type: :custom, length: 8,
222
+ alphabet: "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
223
+ ```
224
+
225
+ | Type | `length` means | Example output |
226
+ |------------|-------------------------|--------------------------|
227
+ | `:hex` | byte count (output is `length * 2` hex chars) | `"a3f7c9b1e2d40859"` |
228
+ | `:uuid` | ignored | `"550e8400-e29b-41d4-a716-446655440000"` |
229
+ | `:integer` | digit count | `483921` |
230
+ | `:custom` | output length, samples from `alphabet:` | `"K7M3PQ9A"` |
231
+
232
+ #### Notes
233
+ - Auto-assigns on `before_create` only when the field is blank, so callers can still pass an explicit value.
234
+ - A `regenerate_<field>!` instance method is defined dynamically to match the configured column.
235
+ - No uniqueness retry is built in. For collision-prone configurations (e.g. short integer codes), add a unique index and rescue at the application level.
236
+ - For fixed-width numeric codes (e.g. `000042`), use a string column — integer columns drop leading zeros.
237
+ - If your model has `validates :<field>, presence: true`, switch to a `before_validation` callback in your model since the concern uses `before_create`.
238
+
239
+ ---
240
+
197
241
  ## 🛠️ Development
198
242
 
199
243
  To build the gem:
@@ -0,0 +1,77 @@
1
+ require "active_support/concern"
2
+ require "securerandom"
3
+
4
+ module ConcernsOnRails
5
+ module Hashable
6
+ extend ActiveSupport::Concern
7
+
8
+ VALID_TYPES = %i[hex uuid integer custom].freeze
9
+
10
+ included do
11
+ class_attribute :hashable_field, instance_accessor: false
12
+ class_attribute :hashable_type, instance_accessor: false, default: :hex
13
+ class_attribute :hashable_length, instance_accessor: false, default: 16
14
+ class_attribute :hashable_alphabet, instance_accessor: false, default: nil
15
+ end
16
+
17
+ class_methods do
18
+ # Define hashable field and generation options.
19
+ # Example:
20
+ # hashable_by :token
21
+ # hashable_by :token, type: :hex, length: 16
22
+ # hashable_by :external_id, type: :uuid
23
+ # hashable_by :code, type: :integer, length: 6
24
+ # hashable_by :code, type: :custom, length: 8, alphabet: "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
25
+ def hashable_by(field, type: :hex, length: 16, alphabet: nil)
26
+ self.hashable_field = field.to_sym
27
+ self.hashable_type = type.to_sym
28
+ self.hashable_length = length.to_i
29
+ self.hashable_alphabet = alphabet
30
+
31
+ validate_hashable_options!
32
+ before_create :assign_hashable_value
33
+
34
+ define_method("regenerate_#{hashable_field}!") do
35
+ update!(self.class.hashable_field => self.class.generate_hashable_value)
36
+ end
37
+ end
38
+ end
39
+
40
+ class_methods do
41
+ # Generate a new random value using the configured type/length/alphabet.
42
+ def generate_hashable_value
43
+ case hashable_type
44
+ when :hex then SecureRandom.hex(hashable_length)
45
+ when :uuid then SecureRandom.uuid
46
+ when :integer then SecureRandom.random_number(10**hashable_length).to_s.rjust(hashable_length, "0").to_i
47
+ when :custom then Array.new(hashable_length) { hashable_alphabet[SecureRandom.random_number(hashable_alphabet.size)] }.join
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def validate_hashable_options!
54
+ unless column_names.include?(hashable_field.to_s)
55
+ raise ArgumentError, "ConcernsOnRails::Hashable: hashable_field '#{hashable_field}' does not exist in the database"
56
+ end
57
+
58
+ unless VALID_TYPES.include?(hashable_type)
59
+ raise ArgumentError, "ConcernsOnRails::Hashable: unknown type '#{hashable_type}'. Valid types: #{VALID_TYPES.join(', ')}"
60
+ end
61
+
62
+ return unless hashable_type == :custom && (!hashable_alphabet.is_a?(String) || hashable_alphabet.empty?)
63
+
64
+ raise ArgumentError, "ConcernsOnRails::Hashable: type :custom requires a non-empty alphabet: String"
65
+ end
66
+ end
67
+
68
+ # Assigns the generated value only when the field is blank,
69
+ # so callers can still pass an explicit value at create time.
70
+ def assign_hashable_value
71
+ field = self.class.hashable_field
72
+ return if self[field].present?
73
+
74
+ self[field] = self.class.generate_hashable_value
75
+ end
76
+ end
77
+ end
@@ -8,7 +8,7 @@ module ConcernsOnRails
8
8
  class_attribute :publishable_field, instance_accessor: false, default: :published_at
9
9
 
10
10
  scope :published, -> { where(arel_table[publishable_field].lteq(Time.zone.now)) }
11
- scope :unpublished, -> {
11
+ scope :unpublished, lambda {
12
12
  where(arel_table[publishable_field].eq(nil).or(arel_table[publishable_field].gt(Time.zone.now)))
13
13
  }
14
14
  end
@@ -17,9 +17,9 @@ module ConcernsOnRails
17
17
  def publishable_by(field = nil)
18
18
  self.publishable_field = field || :published_at
19
19
 
20
- unless column_names.include?(publishable_field.to_s)
21
- raise ArgumentError, "ConcernsOnRails::Publishable: publishable_field '#{publishable_field}' does not exist in the database"
22
- end
20
+ return if column_names.include?(publishable_field.to_s)
21
+
22
+ raise ArgumentError, "ConcernsOnRails::Publishable: publishable_field '#{publishable_field}' does not exist in the database"
23
23
  end
24
24
  end
25
25
 
@@ -44,6 +44,7 @@ module ConcernsOnRails
44
44
  def published?
45
45
  value = self[self.class.publishable_field]
46
46
  return false unless value.present?
47
+
47
48
  value.respond_to?(:<=) ? value <= Time.zone.now : true
48
49
  end
49
50
 
@@ -12,6 +12,7 @@ module ConcernsOnRails
12
12
  self.sluggable_field ||= :name
13
13
 
14
14
  extend FriendlyId
15
+
15
16
  # we need use a lambda to access the instance variable
16
17
  # instead of friendly_id :slug_source, use: :slugged
17
18
  friendly_id :slug_source, use: :slugged
@@ -37,11 +38,12 @@ module ConcernsOnRails
37
38
  end
38
39
 
39
40
  private
41
+
40
42
  # Validate sluggable_field exists in database
41
43
  def validate_sluggable_field!
42
- unless column_names.include?(sluggable_field.to_s)
43
- raise ArgumentError, "ConcernsOnRails::Sluggable: sluggable_field '#{sluggable_field}' does not exist in the database"
44
- end
44
+ return if column_names.include?(sluggable_field.to_s)
45
+
46
+ raise ArgumentError, "ConcernsOnRails::Sluggable: sluggable_field '#{sluggable_field}' does not exist in the database"
45
47
  end
46
48
  end
47
49
 
@@ -55,4 +57,4 @@ module ConcernsOnRails
55
57
  respond_to?(field) ? send(field) : to_s
56
58
  end
57
59
  end
58
- end
60
+ end
@@ -8,14 +8,13 @@ module ConcernsOnRails
8
8
  # declare class attributes and set default values
9
9
  class_attribute :soft_delete_field, instance_accessor: false, default: :deleted_at
10
10
  class_attribute :soft_delete_touch, instance_accessor: false, default: true
11
-
11
+
12
12
  # scopes
13
13
  scope :active, -> { unscope(where: soft_delete_field).where(soft_delete_field => nil) }
14
14
  scope :without_deleted, -> { unscope(where: soft_delete_field).where(soft_delete_field => nil) }
15
15
  scope :soft_deleted, -> { unscope(where: soft_delete_field).where.not(soft_delete_field => nil) }
16
16
  # Optionally, uncomment to hide deleted by default:
17
17
  default_scope { without_deleted }
18
-
19
18
  end
20
19
 
21
20
  class_methods do
@@ -26,9 +25,9 @@ module ConcernsOnRails
26
25
  self.soft_delete_field = field || :deleted_at
27
26
  self.soft_delete_touch = touch
28
27
 
29
- unless column_names.include?(soft_delete_field.to_s)
30
- raise ArgumentError, "ConcernsOnRails::SoftDeletable: soft_delete_field '#{soft_delete_field}' does not exist in the database"
31
- end
28
+ return if column_names.include?(soft_delete_field.to_s)
29
+
30
+ raise ArgumentError, "ConcernsOnRails::SoftDeletable: soft_delete_field '#{soft_delete_field}' does not exist in the database"
32
31
  end
33
32
 
34
33
  # Override destroy_all to perform soft delete on all records
@@ -50,24 +49,26 @@ module ConcernsOnRails
50
49
 
51
50
  def soft_delete!
52
51
  return true if deleted?
52
+
53
53
  before_soft_delete
54
54
  result = if self.class.soft_delete_touch
55
- update(self.class.soft_delete_field => Time.zone.now)
56
- else
57
- update_column(self.class.soft_delete_field, Time.zone.now)
58
- end
55
+ update(self.class.soft_delete_field => Time.zone.now)
56
+ else
57
+ update_column(self.class.soft_delete_field, Time.zone.now)
58
+ end
59
59
  after_soft_delete if result
60
60
  result
61
61
  end
62
62
 
63
63
  def restore!
64
64
  return true unless deleted?
65
+
65
66
  before_restore
66
67
  result = if self.class.soft_delete_touch
67
- update(self.class.soft_delete_field => nil)
68
- else
69
- update_column(self.class.soft_delete_field, nil)
70
- end
68
+ update(self.class.soft_delete_field => nil)
69
+ else
70
+ update_column(self.class.soft_delete_field, nil)
71
+ end
71
72
  after_restore if result
72
73
  result
73
74
  end
@@ -77,15 +78,15 @@ module ConcernsOnRails
77
78
  self.class.unscoped.where(self.class.primary_key => id).delete_all
78
79
  freeze
79
80
  end
80
-
81
+
81
82
  def deleted?
82
83
  self[self.class.soft_delete_field].present?
83
84
  end
84
85
 
85
86
  # alias methods
86
87
  # define here to avoid issue: undefined method `deleted?' for module `ConcernsOnRails::SoftDeletable'
87
- alias_method :is_soft_deleted?, :deleted?
88
- alias_method :soft_deleted?, :deleted?
88
+ alias is_soft_deleted? deleted?
89
+ alias soft_deleted? deleted?
89
90
 
90
91
  def is_really_deleted?
91
92
  !self.class.unscoped.exists?(id)
@@ -26,6 +26,7 @@ module ConcernsOnRails
26
26
  unless column_names.include?(sortable_field.to_s)
27
27
  raise ArgumentError, "#{name}: '#{sortable_field}' column not found. Call `sortable_by :your_column` to configure the sort field."
28
28
  end
29
+
29
30
  order(sortable_field => sortable_direction)
30
31
  end
31
32
  end
@@ -40,7 +41,9 @@ module ConcernsOnRails
40
41
  # sortable_by position: :desc
41
42
  #
42
43
  # sortable_by :position, use_acts_as_list: false
43
- def sortable_by(field_config, use_acts_as_list: true)
44
+ def sortable_by(field_config = nil, use_acts_as_list: true, **field_options)
45
+ field_config = field_options if field_config.nil? && field_options.any?
46
+
44
47
  # parse field_config
45
48
  field, direction = parse_sortable_config(field_config)
46
49
 
@@ -57,6 +60,7 @@ module ConcernsOnRails
57
60
  end
58
61
 
59
62
  private
63
+
60
64
  def parse_sortable_config(config)
61
65
  if config.is_a?(Hash)
62
66
  # extract key and value
@@ -71,9 +75,9 @@ module ConcernsOnRails
71
75
 
72
76
  # Validate sortable_field exists in database
73
77
  def validate_sortable_field!
74
- unless column_names.include?(sortable_field.to_s)
75
- raise ArgumentError, "ConcernsOnRails::Sortable: sortable_field '#{sortable_field}' does not exist in the database"
76
- end
78
+ return if column_names.include?(sortable_field.to_s)
79
+
80
+ raise ArgumentError, "ConcernsOnRails::Sortable: sortable_field '#{sortable_field}' does not exist in the database"
77
81
  end
78
82
  end
79
83
  end
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.2.0"
2
+ VERSION = "1.3.0".freeze
3
3
  end
@@ -4,6 +4,7 @@ require "concerns_on_rails/sortable"
4
4
  require "concerns_on_rails/publishable"
5
5
  require "concerns_on_rails/sluggable"
6
6
  require "concerns_on_rails/soft_deletable"
7
+ require "concerns_on_rails/hashable"
7
8
 
8
9
  module ConcernsOnRails
9
10
  end
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.2.0
4
+ version: 1.3.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-10 00:00:00.000000000 Z
11
+ date: 2026-05-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -70,6 +70,7 @@ files:
70
70
  - CODE_OF_CONDUCT.md
71
71
  - README.md
72
72
  - lib/concerns_on_rails.rb
73
+ - lib/concerns_on_rails/hashable.rb
73
74
  - lib/concerns_on_rails/publishable.rb
74
75
  - lib/concerns_on_rails/sluggable.rb
75
76
  - lib/concerns_on_rails/soft_deletable.rb