concerns_on_rails 1.4.2 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6e784268a8fb00ef3e77b613eca3ddc44da0ca95e0f9f1784ce019356f2f5905
4
- data.tar.gz: fdc824e479b2607b097762debf8940c622044f47a108a5482729e9b74c1233c0
3
+ metadata.gz: d359c073e004cdacffbfeaf9e588abb581bf6794a631211fc3eca309f9522e31
4
+ data.tar.gz: c686a9ef9f5fa323a7d59ba4786e58c6d0a78265e65e01526036350b1ec8bc26
5
5
  SHA512:
6
- metadata.gz: ae9b3876ae0eea6a02f51710d33074aba898bc1ab22609a33cfb27a8eb1747c5d4c12ef01fc1de180026658adf0f4d1d788842abc5f83322616d80dac35ac238
7
- data.tar.gz: '09cb150b92adbcd6ab21b670b122d42ad6f71bcbb4616e5bfa89ac2cdc385fc8e42cbac98f8b11d3c2d9bba462c32a0fa2d8e9bd46c5b6e3e0f8407e41242e97'
6
+ metadata.gz: 85af55992f8dad780dcbe588df3b8df21f042d46337eb331b85638040a1c016be004d17387e894fca8f787d8c1995b0d89f694fd9af9315cb4743ff556799d94
7
+ data.tar.gz: cd515ebcec0029801f5483872d94d7e833bbba64b0cd00da2247744e78c8789178ce950b4f0a52023612e19f8e3b2e40e6198800291a5e42655198e585692288
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
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
+
3
8
  ## 1.4.2 (2026-05-16)
4
9
 
5
10
  ### Added
data/README.md CHANGED
@@ -12,6 +12,7 @@ A simple collection of reusable Rails concerns to keep your models clean and DRY
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
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
15
16
 
16
17
  ---
17
18
 
@@ -300,6 +301,58 @@ promo.reschedule!(starts_at: 1.day.from_now,
300
301
 
301
302
  ---
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
+
303
356
  ## 🛠️ Development
304
357
 
305
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
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.4.2".freeze
2
+ VERSION = "1.5.0".freeze
3
3
  end
@@ -6,6 +6,7 @@ require "concerns_on_rails/sluggable"
6
6
  require "concerns_on_rails/soft_deletable"
7
7
  require "concerns_on_rails/hashable"
8
8
  require "concerns_on_rails/schedulable"
9
+ require "concerns_on_rails/expirable"
9
10
 
10
11
  module ConcernsOnRails
11
12
  end
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.4.2
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Nguyen
@@ -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/expirable.rb
73
74
  - lib/concerns_on_rails/hashable.rb
74
75
  - lib/concerns_on_rails/publishable.rb
75
76
  - lib/concerns_on_rails/schedulable.rb