concerns_on_rails 1.3.0 → 1.5.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 +16 -0
- data/README.md +115 -0
- data/lib/concerns_on_rails/expirable.rb +85 -0
- data/lib/concerns_on_rails/schedulable.rb +128 -0
- data/lib/concerns_on_rails/version.rb +1 -1
- data/lib/concerns_on_rails.rb +2 -0
- metadata +5 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d359c073e004cdacffbfeaf9e588abb581bf6794a631211fc3eca309f9522e31
|
|
4
|
+
data.tar.gz: c686a9ef9f5fa323a7d59ba4786e58c6d0a78265e65e01526036350b1ec8bc26
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 85af55992f8dad780dcbe588df3b8df21f042d46337eb331b85638040a1c016be004d17387e894fca8f787d8c1995b0d89f694fd9af9315cb4743ff556799d94
|
|
7
|
+
data.tar.gz: cd515ebcec0029801f5483872d94d7e833bbba64b0cd00da2247744e78c8789178ce950b4f0a52023612e19f8e3b2e40e6198800291a5e42655198e585692288
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 1.5.0 (2026-05-16)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Expirable: Single-timestamp expiry for tokens, API keys, sessions, and similar records. Adds `expirable_by` macro, `.active` / `.expired` / `.expiring_within(duration)` scopes, predicates (`active?`, `expired?`), mutators (`expire!`, `extend_expiry!`), and `time_until_expiry`. `nil` expiry means "never expires"; the expiry boundary is exclusive.
|
|
7
|
+
|
|
8
|
+
## 1.4.2 (2026-05-16)
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Schedulable: Manage time-windowed records via `starts_at` / `ends_at` columns. Adds `schedulable_by` macro, scopes (`.current`, `.upcoming`, `.expired`, `.active_at(time)`), predicates (`current?`, `upcoming?`, `expired?`, `active_at?`), and mutators (`start!`, `finish!`, `reschedule!`). Supports custom column names and open-ended schedules (`starts_at: nil`).
|
|
12
|
+
|
|
13
|
+
### Internal
|
|
14
|
+
- Refactored `active_at?` into two private predicate helpers (`schedulable_started_by?` / `schedulable_not_ended_at?`) to satisfy `Metrics/CyclomaticComplexity`.
|
|
15
|
+
|
|
16
|
+
### Notes
|
|
17
|
+
- The `v1.4.0` and `v1.4.1` tags were created but never released to RubyGems (CI failed on `Gemfile.lock` regeneration and a RuboCop complexity check respectively). `1.4.2` is the first usable release of the Schedulable concern.
|
|
18
|
+
|
|
3
19
|
## 1.3.0 (2026-05-16)
|
|
4
20
|
|
|
5
21
|
### Added
|
data/README.md
CHANGED
|
@@ -11,6 +11,8 @@ A simple collection of reusable Rails concerns to keep your models clean and DRY
|
|
|
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
13
|
- 🔐 `Hashable`: Auto-generate a random hex/UUID/integer/custom-alphabet value on create, with a `regenerate_<field>!` helper
|
|
14
|
+
- 🗓️ `Schedulable`: Manage time-windowed records via `starts_at` / `ends_at` with `.current`, `.upcoming`, `.expired`, and `.active_at(time)` scopes
|
|
15
|
+
- ⏳ `Expirable`: Single-timestamp expiry with `.active` / `.expired` / `.expiring_within(duration)` scopes and `expire!` / `extend_expiry!` / `time_until_expiry` helpers
|
|
14
16
|
|
|
15
17
|
---
|
|
16
18
|
|
|
@@ -238,6 +240,119 @@ hashable_by :code, type: :custom, length: 8,
|
|
|
238
240
|
|
|
239
241
|
---
|
|
240
242
|
|
|
243
|
+
### 6. 🗓️ Schedulable
|
|
244
|
+
|
|
245
|
+
Manage records with a time window using `starts_at` / `ends_at` columns.
|
|
246
|
+
|
|
247
|
+
```ruby
|
|
248
|
+
class Promotion < ApplicationRecord
|
|
249
|
+
include ConcernsOnRails::Schedulable
|
|
250
|
+
|
|
251
|
+
# Defaults: starts_at: :starts_at, ends_at: :ends_at
|
|
252
|
+
schedulable_by
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
promo = Promotion.create!(starts_at: 1.hour.ago, ends_at: 1.day.from_now)
|
|
256
|
+
promo.current? # => true
|
|
257
|
+
promo.upcoming? # => false
|
|
258
|
+
promo.expired? # => false
|
|
259
|
+
|
|
260
|
+
Promotion.current # currently active
|
|
261
|
+
Promotion.upcoming # starts_at in the future
|
|
262
|
+
Promotion.expired # ends_at in the past
|
|
263
|
+
Promotion.active_at(Time.zone.now) # active at any specific time
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
#### Custom column names
|
|
267
|
+
|
|
268
|
+
```ruby
|
|
269
|
+
class Event < ApplicationRecord
|
|
270
|
+
include ConcernsOnRails::Schedulable
|
|
271
|
+
|
|
272
|
+
schedulable_by starts_at: :starts_on, ends_at: :ends_on
|
|
273
|
+
end
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
#### Open-ended start (only an expiry)
|
|
277
|
+
|
|
278
|
+
```ruby
|
|
279
|
+
class Coupon < ApplicationRecord
|
|
280
|
+
include ConcernsOnRails::Schedulable
|
|
281
|
+
|
|
282
|
+
schedulable_by starts_at: nil, ends_at: :expires_at
|
|
283
|
+
end
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
#### Mutators
|
|
287
|
+
|
|
288
|
+
```ruby
|
|
289
|
+
promo.start! # sets starts_at to now
|
|
290
|
+
promo.finish! # sets ends_at to now
|
|
291
|
+
promo.reschedule!(starts_at: 1.day.from_now,
|
|
292
|
+
ends_at: 2.days.from_now) # sets both
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
#### Notes
|
|
296
|
+
- Boundary semantics are **inclusive start, exclusive end**: a record is active at exactly `starts_at`, but not at exactly `ends_at`.
|
|
297
|
+
- A `nil` `ends_at` means "no end" — the record stays active forever once started.
|
|
298
|
+
- A `nil` `starts_at` means "not yet started" — the record is not active (unless `starts_at` is unconfigured).
|
|
299
|
+
- No `default_scope` is added; chain `.current` (or any other scope) explicitly to filter.
|
|
300
|
+
- `schedulable_by` validates that the configured columns exist and raises `ArgumentError` otherwise.
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
### 7. ⏳ Expirable
|
|
305
|
+
|
|
306
|
+
For records with a single expiry timestamp — auth tokens, API keys, sessions, password-reset links, invitations.
|
|
307
|
+
|
|
308
|
+
```ruby
|
|
309
|
+
class ApiToken < ApplicationRecord
|
|
310
|
+
include ConcernsOnRails::Expirable
|
|
311
|
+
|
|
312
|
+
# Default field: :expires_at
|
|
313
|
+
expirable_by
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
token = ApiToken.create!(expires_at: 1.hour.from_now)
|
|
317
|
+
token.active? # => true
|
|
318
|
+
token.expired? # => false
|
|
319
|
+
token.time_until_expiry # => ActiveSupport::Duration (~1.hour)
|
|
320
|
+
|
|
321
|
+
ApiToken.active # nil expiry OR future expiry
|
|
322
|
+
ApiToken.expired # past expiry
|
|
323
|
+
ApiToken.expiring_within(1.day) # future expiry within the next 1.day
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
#### Mutators
|
|
327
|
+
|
|
328
|
+
```ruby
|
|
329
|
+
token.expire! # sets expires_at to now
|
|
330
|
+
token.expire!(2.hours.from_now) # sets to an explicit time
|
|
331
|
+
token.extend_expiry!(by: 1.day) # pushes expiry forward
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
`extend_expiry!` is "smart" about the base:
|
|
335
|
+
- If `expires_at` is nil or already in the past, the new value is `now + by`.
|
|
336
|
+
- If `expires_at` is still in the future, `by` is added to the existing value.
|
|
337
|
+
|
|
338
|
+
#### Custom field name
|
|
339
|
+
|
|
340
|
+
```ruby
|
|
341
|
+
class License < ApplicationRecord
|
|
342
|
+
include ConcernsOnRails::Expirable
|
|
343
|
+
|
|
344
|
+
expirable_by :valid_until
|
|
345
|
+
end
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
#### Notes
|
|
349
|
+
- `nil` `expires_at` means **never expires** (the record stays `active?`).
|
|
350
|
+
- The expiry boundary is **exclusive**: at exactly `expires_at`, the record is `expired?`.
|
|
351
|
+
- No `default_scope` is added; chain `.active` explicitly to filter.
|
|
352
|
+
- `Expirable` overlaps with `Schedulable`'s open-ended pattern (`schedulable_by starts_at: nil, ends_at: :expires_at`). Use `Expirable` when the API ergonomics around expiry — `active?`, `expire!`, `extend_expiry!`, `expiring_within` — fit your domain better; use `Schedulable` when you also need a start time.
|
|
353
|
+
|
|
354
|
+
---
|
|
355
|
+
|
|
241
356
|
## 🛠️ Development
|
|
242
357
|
|
|
243
358
|
To build the gem:
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
|
|
3
|
+
module ConcernsOnRails
|
|
4
|
+
module Expirable
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
DEFAULT_FIELD = :expires_at
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
class_attribute :expirable_field, instance_accessor: false, default: DEFAULT_FIELD
|
|
11
|
+
|
|
12
|
+
scope :active, lambda {
|
|
13
|
+
column = arel_table[expirable_field]
|
|
14
|
+
where(column.eq(nil).or(column.gt(Time.zone.now)))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
scope :expired, lambda {
|
|
18
|
+
where(arel_table[expirable_field].lteq(Time.zone.now))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
scope :expiring_within, lambda { |duration|
|
|
22
|
+
column = arel_table[expirable_field]
|
|
23
|
+
now = Time.zone.now
|
|
24
|
+
where(column.gt(now)).where(column.lteq(now + duration))
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class_methods do
|
|
29
|
+
# Configure the expiry column.
|
|
30
|
+
# Example:
|
|
31
|
+
# expirable_by # uses :expires_at
|
|
32
|
+
# expirable_by :valid_until
|
|
33
|
+
def expirable_by(field = DEFAULT_FIELD)
|
|
34
|
+
self.expirable_field = field.to_sym
|
|
35
|
+
|
|
36
|
+
return if column_names.include?(expirable_field.to_s)
|
|
37
|
+
|
|
38
|
+
raise ArgumentError, "ConcernsOnRails::Expirable: expirable_field '#{expirable_field}' does not exist in the database"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def active?
|
|
43
|
+
!expired?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# nil means never expires; equal-to-now is treated as expired (exclusive boundary).
|
|
47
|
+
def expired?
|
|
48
|
+
value = self[self.class.expirable_field]
|
|
49
|
+
return false if value.nil?
|
|
50
|
+
|
|
51
|
+
value <= Time.zone.now
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def expire!(time = Time.zone.now)
|
|
55
|
+
update(self.class.expirable_field => time)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Push expiry forward by `by:`. If the record has no expiry yet, or has
|
|
59
|
+
# already expired, the new expiry is `now + by`. Otherwise it's added to
|
|
60
|
+
# the existing expiry.
|
|
61
|
+
def extend_expiry!(by:)
|
|
62
|
+
update(self.class.expirable_field => expiry_extension_base + by)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Returns an ActiveSupport::Duration of how long until expiry, or nil
|
|
66
|
+
# when there's no expiry set, or 0.seconds when already expired.
|
|
67
|
+
def time_until_expiry
|
|
68
|
+
value = self[self.class.expirable_field]
|
|
69
|
+
return nil if value.nil?
|
|
70
|
+
|
|
71
|
+
now = Time.zone.now
|
|
72
|
+
return 0.seconds if value <= now
|
|
73
|
+
|
|
74
|
+
(value - now).seconds
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def expiry_extension_base
|
|
80
|
+
value = self[self.class.expirable_field]
|
|
81
|
+
now = Time.zone.now
|
|
82
|
+
value.nil? || value <= now ? now : value
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
|
|
3
|
+
module ConcernsOnRails
|
|
4
|
+
module Schedulable
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
DEFAULT_STARTS_AT_FIELD = :starts_at
|
|
8
|
+
DEFAULT_ENDS_AT_FIELD = :ends_at
|
|
9
|
+
|
|
10
|
+
included do
|
|
11
|
+
class_attribute :schedulable_starts_at_field, instance_accessor: false, default: DEFAULT_STARTS_AT_FIELD
|
|
12
|
+
class_attribute :schedulable_ends_at_field, instance_accessor: false, default: DEFAULT_ENDS_AT_FIELD
|
|
13
|
+
|
|
14
|
+
scope :active_at, lambda { |time|
|
|
15
|
+
starts_field = schedulable_starts_at_field
|
|
16
|
+
ends_field = schedulable_ends_at_field
|
|
17
|
+
relation = all
|
|
18
|
+
relation = relation.where(arel_table[starts_field].lteq(time)) if starts_field
|
|
19
|
+
relation = relation.where(arel_table[ends_field].eq(nil).or(arel_table[ends_field].gt(time))) if ends_field
|
|
20
|
+
relation
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
scope :current, -> { active_at(Time.zone.now) }
|
|
24
|
+
|
|
25
|
+
scope :upcoming, lambda {
|
|
26
|
+
field = schedulable_starts_at_field
|
|
27
|
+
next none unless field
|
|
28
|
+
|
|
29
|
+
where(arel_table[field].gt(Time.zone.now))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
scope :expired, lambda {
|
|
33
|
+
field = schedulable_ends_at_field
|
|
34
|
+
next none unless field
|
|
35
|
+
|
|
36
|
+
where(arel_table[field].lteq(Time.zone.now))
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class_methods do
|
|
41
|
+
# Configure the start/end timestamp columns.
|
|
42
|
+
# Example:
|
|
43
|
+
# schedulable_by # uses :starts_at and :ends_at
|
|
44
|
+
# schedulable_by starts_at: :starts_on, ends_at: :ends_on
|
|
45
|
+
# schedulable_by starts_at: nil, ends_at: :expires_at # open-ended start
|
|
46
|
+
def schedulable_by(starts_at: DEFAULT_STARTS_AT_FIELD, ends_at: DEFAULT_ENDS_AT_FIELD)
|
|
47
|
+
self.schedulable_starts_at_field = starts_at&.to_sym
|
|
48
|
+
self.schedulable_ends_at_field = ends_at&.to_sym
|
|
49
|
+
|
|
50
|
+
if schedulable_starts_at_field.nil? && schedulable_ends_at_field.nil?
|
|
51
|
+
raise ArgumentError, "ConcernsOnRails::Schedulable: at least one of starts_at: or ends_at: must be configured"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
[schedulable_starts_at_field, schedulable_ends_at_field].compact.each do |field|
|
|
55
|
+
next if column_names.include?(field.to_s)
|
|
56
|
+
|
|
57
|
+
raise ArgumentError, "ConcernsOnRails::Schedulable: field '#{field}' does not exist in the database"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Is the record active at the given time? Inclusive start, exclusive end.
|
|
63
|
+
def active_at?(time)
|
|
64
|
+
schedulable_started_by?(time) && schedulable_not_ended_at?(time)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def current?
|
|
68
|
+
active_at?(Time.zone.now)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def upcoming?
|
|
72
|
+
field = self.class.schedulable_starts_at_field
|
|
73
|
+
value = field && self[field]
|
|
74
|
+
return false unless value
|
|
75
|
+
|
|
76
|
+
value > Time.zone.now
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def expired?
|
|
80
|
+
field = self.class.schedulable_ends_at_field
|
|
81
|
+
value = field && self[field]
|
|
82
|
+
return false unless value
|
|
83
|
+
|
|
84
|
+
value <= Time.zone.now
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def start!(time = Time.zone.now)
|
|
88
|
+
field = self.class.schedulable_starts_at_field
|
|
89
|
+
raise "ConcernsOnRails::Schedulable: starts_at field not configured" unless field
|
|
90
|
+
|
|
91
|
+
update(field => time)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def finish!(time = Time.zone.now)
|
|
95
|
+
field = self.class.schedulable_ends_at_field
|
|
96
|
+
raise "ConcernsOnRails::Schedulable: ends_at field not configured" unless field
|
|
97
|
+
|
|
98
|
+
update(field => time)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def reschedule!(starts_at:, ends_at:)
|
|
102
|
+
attrs = {}
|
|
103
|
+
starts_field = self.class.schedulable_starts_at_field
|
|
104
|
+
ends_field = self.class.schedulable_ends_at_field
|
|
105
|
+
attrs[starts_field] = starts_at if starts_field
|
|
106
|
+
attrs[ends_field] = ends_at if ends_field
|
|
107
|
+
update(attrs)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def schedulable_started_by?(time)
|
|
113
|
+
field = self.class.schedulable_starts_at_field
|
|
114
|
+
return true unless field
|
|
115
|
+
|
|
116
|
+
value = self[field]
|
|
117
|
+
!value.nil? && value <= time
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def schedulable_not_ended_at?(time)
|
|
121
|
+
field = self.class.schedulable_ends_at_field
|
|
122
|
+
return true unless field
|
|
123
|
+
|
|
124
|
+
value = self[field]
|
|
125
|
+
value.nil? || value > time
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
data/lib/concerns_on_rails.rb
CHANGED
|
@@ -5,6 +5,8 @@ require "concerns_on_rails/publishable"
|
|
|
5
5
|
require "concerns_on_rails/sluggable"
|
|
6
6
|
require "concerns_on_rails/soft_deletable"
|
|
7
7
|
require "concerns_on_rails/hashable"
|
|
8
|
+
require "concerns_on_rails/schedulable"
|
|
9
|
+
require "concerns_on_rails/expirable"
|
|
8
10
|
|
|
9
11
|
module ConcernsOnRails
|
|
10
12
|
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.
|
|
4
|
+
version: 1.5.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-16 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -70,8 +70,10 @@ files:
|
|
|
70
70
|
- CODE_OF_CONDUCT.md
|
|
71
71
|
- README.md
|
|
72
72
|
- lib/concerns_on_rails.rb
|
|
73
|
+
- lib/concerns_on_rails/expirable.rb
|
|
73
74
|
- lib/concerns_on_rails/hashable.rb
|
|
74
75
|
- lib/concerns_on_rails/publishable.rb
|
|
76
|
+
- lib/concerns_on_rails/schedulable.rb
|
|
75
77
|
- lib/concerns_on_rails/sluggable.rb
|
|
76
78
|
- lib/concerns_on_rails/soft_deletable.rb
|
|
77
79
|
- lib/concerns_on_rails/sortable.rb
|
|
@@ -91,7 +93,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
91
93
|
requirements:
|
|
92
94
|
- - ">="
|
|
93
95
|
- !ruby/object:Gem::Version
|
|
94
|
-
version: 2.
|
|
96
|
+
version: 3.2.0
|
|
95
97
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
96
98
|
requirements:
|
|
97
99
|
- - ">="
|