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 +4 -4
- data/CHANGELOG.md +5 -0
- data/README.md +44 -0
- data/lib/concerns_on_rails/hashable.rb +77 -0
- data/lib/concerns_on_rails/publishable.rb +5 -4
- data/lib/concerns_on_rails/sluggable.rb +6 -4
- data/lib/concerns_on_rails/soft_deletable.rb +17 -16
- data/lib/concerns_on_rails/sortable.rb +8 -4
- data/lib/concerns_on_rails/version.rb +1 -1
- data/lib/concerns_on_rails.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0d4b68b3625ea62546fa003654e7bd0618a56b09b615b406dcaed12f16560338
|
|
4
|
+
data.tar.gz: 4d048584cdf09f1eb9859a4cca0ccd64182b331bfa8a6ffb897ff61ab1c4636a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
data/lib/concerns_on_rails.rb
CHANGED
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.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-
|
|
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
|