in_time_scope 0.1.5 → 0.1.6
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/.rubocop.yml +1 -1
- data/.rulesync/commands/translate-readme.md +46 -0
- data/{CLAUDE.md → .rulesync/rules/project.md} +23 -7
- data/README.md +104 -221
- data/docs/book.toml +14 -0
- data/docs/de/SUMMARY.md +5 -0
- data/docs/de/index.md +192 -0
- data/docs/de/point-system.md +295 -0
- data/docs/de/user-name-history.md +164 -0
- data/docs/fr/SUMMARY.md +5 -0
- data/docs/fr/index.md +192 -0
- data/docs/fr/point-system.md +295 -0
- data/docs/fr/user-name-history.md +164 -0
- data/docs/ja/SUMMARY.md +5 -0
- data/docs/ja/index.md +192 -0
- data/docs/ja/point-system.md +295 -0
- data/docs/ja/user-name-history.md +164 -0
- data/docs/src/SUMMARY.md +5 -0
- data/docs/src/index.md +194 -0
- data/docs/src/point-system.md +295 -0
- data/docs/src/user-name-history.md +164 -0
- data/docs/zh/SUMMARY.md +5 -0
- data/docs/zh/index.md +192 -0
- data/docs/zh/point-system.md +295 -0
- data/docs/zh/user-name-history.md +164 -0
- data/lib/in_time_scope/class_methods.rb +139 -91
- data/lib/in_time_scope/version.rb +1 -1
- data/rulesync.jsonc +6 -0
- data/sig/in_time_scope.rbs +24 -14
- metadata +25 -2
data/docs/src/index.md
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# InTimeScope
|
|
2
|
+
|
|
3
|
+
Are you writing this every time in Rails?
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
# Before
|
|
7
|
+
Event.where("start_at <= ? AND (end_at IS NULL OR end_at > ?)", Time.current, Time.current)
|
|
8
|
+
|
|
9
|
+
# After
|
|
10
|
+
class Event < ActiveRecord::Base
|
|
11
|
+
in_time_scope
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
Event.in_time
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
That's it. One line of DSL, zero raw SQL in your models.
|
|
18
|
+
|
|
19
|
+
**This is a simple, thin gem that just provides scopes. No learning curve required.**
|
|
20
|
+
|
|
21
|
+
## Why This Gem?
|
|
22
|
+
|
|
23
|
+
This gem exists to:
|
|
24
|
+
|
|
25
|
+
- **Keep time-range logic consistent** across your entire codebase
|
|
26
|
+
- **Avoid copy-paste SQL** that's easy to get wrong
|
|
27
|
+
- **Make time a first-class domain concept** with named scopes like `in_time_published`
|
|
28
|
+
- **Auto-detect nullability** from your schema for optimized queries
|
|
29
|
+
|
|
30
|
+
## Recommended For
|
|
31
|
+
|
|
32
|
+
- New Rails applications with validity periods
|
|
33
|
+
- Models with `start_at` / `end_at` columns
|
|
34
|
+
- Teams that want consistent time logic without scattered `where` clauses
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
bundle add in_time_scope
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quick Start
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
class Event < ActiveRecord::Base
|
|
46
|
+
in_time_scope
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Class scope
|
|
50
|
+
Event.in_time # Records active now
|
|
51
|
+
Event.in_time(Time.parse("2024-06-01")) # Records active at specific time
|
|
52
|
+
|
|
53
|
+
# Instance method
|
|
54
|
+
event.in_time? # Is this record active now?
|
|
55
|
+
event.in_time?(some_time) # Was it active at that time?
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Features
|
|
59
|
+
|
|
60
|
+
### Auto-Optimized SQL
|
|
61
|
+
|
|
62
|
+
The gem reads your schema and generates the right SQL:
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
# NULL-allowed columns → NULL-aware query
|
|
66
|
+
WHERE (start_at IS NULL OR start_at <= ?) AND (end_at IS NULL OR end_at > ?)
|
|
67
|
+
|
|
68
|
+
# NOT NULL columns → simple query
|
|
69
|
+
WHERE start_at <= ? AND end_at > ?
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Named Scopes
|
|
73
|
+
|
|
74
|
+
Multiple time windows per model:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
class Article < ActiveRecord::Base
|
|
78
|
+
in_time_scope :published # → Article.in_time_published
|
|
79
|
+
in_time_scope :featured # → Article.in_time_featured
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Custom Columns
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
class Campaign < ActiveRecord::Base
|
|
87
|
+
in_time_scope start_at: { column: :available_at },
|
|
88
|
+
end_at: { column: :expired_at }
|
|
89
|
+
end
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Start-Only Pattern (Version History)
|
|
93
|
+
|
|
94
|
+
For records where each row is valid until the next one:
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
class Price < ActiveRecord::Base
|
|
98
|
+
in_time_scope start_at: { null: false }, end_at: { column: nil }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Bonus: efficient has_one with NOT EXISTS
|
|
102
|
+
class User < ActiveRecord::Base
|
|
103
|
+
has_one :current_price, -> { latest_in_time(:user_id) }, class_name: "Price"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
User.includes(:current_price) # No N+1, fetches only latest per user
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### End-Only Pattern (Expiration)
|
|
110
|
+
|
|
111
|
+
For records that are active until they expire:
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
class Coupon < ActiveRecord::Base
|
|
115
|
+
in_time_scope start_at: { column: nil }, end_at: { null: false }
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Inverse Scopes
|
|
120
|
+
|
|
121
|
+
Query records outside the time window:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
# Records not yet started (start_at > time)
|
|
125
|
+
Event.before_in_time
|
|
126
|
+
event.before_in_time?
|
|
127
|
+
|
|
128
|
+
# Records already ended (end_at <= time)
|
|
129
|
+
Event.after_in_time
|
|
130
|
+
event.after_in_time?
|
|
131
|
+
|
|
132
|
+
# Records outside time window (before OR after)
|
|
133
|
+
Event.out_of_time
|
|
134
|
+
event.out_of_time? # Logical inverse of in_time?
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Works with named scopes too:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
Article.before_in_time_published # Not yet published
|
|
141
|
+
Article.after_in_time_published # Publication ended
|
|
142
|
+
Article.out_of_time_published # Not currently published
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Options Reference
|
|
146
|
+
|
|
147
|
+
| Option | Default | Description | Example |
|
|
148
|
+
| --- | --- | --- | --- |
|
|
149
|
+
| `scope_name` (1st arg) | `:in_time` | Named scope like `in_time_published` | `in_time_scope :published` |
|
|
150
|
+
| `start_at: { column: }` | `:start_at` | Custom column name, `nil` to disable | `start_at: { column: :available_at }` |
|
|
151
|
+
| `end_at: { column: }` | `:end_at` | Custom column name, `nil` to disable | `end_at: { column: nil }` |
|
|
152
|
+
| `start_at: { null: }` | auto-detect | Force NULL handling | `start_at: { null: false }` |
|
|
153
|
+
| `end_at: { null: }` | auto-detect | Force NULL handling | `end_at: { null: true }` |
|
|
154
|
+
|
|
155
|
+
## Examples
|
|
156
|
+
|
|
157
|
+
- [Point System with Expiration](./point-system.md) - Full time window pattern
|
|
158
|
+
- [User Name History](./user-name-history.md) - Start-only pattern
|
|
159
|
+
|
|
160
|
+
## Acknowledgements
|
|
161
|
+
|
|
162
|
+
Inspired by [onk/shibaraku](https://github.com/onk/shibaraku). This gem extends the concept with:
|
|
163
|
+
|
|
164
|
+
- Schema-aware NULL handling for optimized queries
|
|
165
|
+
- Multiple named scopes per model
|
|
166
|
+
- Start-only / End-only patterns
|
|
167
|
+
- `latest_in_time` / `earliest_in_time` for efficient `has_one` associations
|
|
168
|
+
- Inverse scopes: `before_in_time`, `after_in_time`, `out_of_time`
|
|
169
|
+
|
|
170
|
+
## Development
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
# Install dependencies
|
|
174
|
+
bin/setup
|
|
175
|
+
|
|
176
|
+
# Run tests
|
|
177
|
+
bundle exec rspec
|
|
178
|
+
|
|
179
|
+
# Run linting
|
|
180
|
+
bundle exec rubocop
|
|
181
|
+
|
|
182
|
+
# Generate CLAUDE.md (for AI coding assistants)
|
|
183
|
+
npx rulesync generate
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
This project uses [rulesync](https://github.com/dyoshikawa/rulesync) to manage AI assistant rules. Edit `.rulesync/rules/*.md` and run `npx rulesync generate` to update `CLAUDE.md`.
|
|
187
|
+
|
|
188
|
+
## Contributing
|
|
189
|
+
|
|
190
|
+
Bug reports and pull requests are welcome on [GitHub](https://github.com/kyohah/in_time_scope).
|
|
191
|
+
|
|
192
|
+
## License
|
|
193
|
+
|
|
194
|
+
MIT License
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# Point System with Expiration Example
|
|
2
|
+
|
|
3
|
+
This example demonstrates how to implement a point system with expiration dates using `in_time_scope`. Points can be pre-granted to become active in the future, eliminating the need for cron jobs.
|
|
4
|
+
|
|
5
|
+
See also: [spec/point_system_spec.rb](https://github.com/kyohah/in_time_scope/blob/main/spec/point_system_spec.rb)
|
|
6
|
+
|
|
7
|
+
## Use Case
|
|
8
|
+
|
|
9
|
+
- Users earn points with validity periods (start date and expiration date)
|
|
10
|
+
- Points can be pre-granted to activate in the future (e.g., monthly membership bonuses)
|
|
11
|
+
- Calculate valid points at any given time without cron jobs
|
|
12
|
+
- Query upcoming points, expired points, etc.
|
|
13
|
+
|
|
14
|
+
## No Cron Jobs Required
|
|
15
|
+
|
|
16
|
+
**This is the killer feature.** Traditional point systems are a nightmare of scheduled jobs:
|
|
17
|
+
|
|
18
|
+
### The Cron Hell You're Used To
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
# activate_points_job.rb - runs every minute
|
|
22
|
+
class ActivatePointsJob < ApplicationJob
|
|
23
|
+
def perform
|
|
24
|
+
Point.where(status: "pending")
|
|
25
|
+
.where("start_at <= ?", Time.current)
|
|
26
|
+
.update_all(status: "active")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# expire_points_job.rb - runs every minute
|
|
31
|
+
class ExpirePointsJob < ApplicationJob
|
|
32
|
+
def perform
|
|
33
|
+
Point.where(status: "active")
|
|
34
|
+
.where("end_at <= ?", Time.current)
|
|
35
|
+
.update_all(status: "expired")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# And then you need:
|
|
40
|
+
# - Sidekiq / Delayed Job / Good Job
|
|
41
|
+
# - Redis (for Sidekiq)
|
|
42
|
+
# - Cron or whenever gem
|
|
43
|
+
# - Monitoring for job failures
|
|
44
|
+
# - Retry logic for failed jobs
|
|
45
|
+
# - Lock mechanisms to prevent duplicate runs
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### The InTimeScope Way
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
# That's it. No jobs. No status column. No infrastructure.
|
|
52
|
+
user.points.in_time.sum(:amount)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**One line. Zero infrastructure. Always accurate.**
|
|
56
|
+
|
|
57
|
+
### Why This Works
|
|
58
|
+
|
|
59
|
+
The `start_at` and `end_at` columns ARE the state. There's no need for a `status` column because the time comparison happens at query time:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
# These all work without any background processing:
|
|
63
|
+
user.points.in_time # Currently valid
|
|
64
|
+
user.points.in_time(1.month.from_now) # Valid next month
|
|
65
|
+
user.points.in_time(1.year.ago) # Were valid last year (auditing!)
|
|
66
|
+
user.points.before_in_time # Pending (not yet active)
|
|
67
|
+
user.points.after_in_time # Expired
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### What You Eliminate
|
|
71
|
+
|
|
72
|
+
| Component | Cron-Based System | InTimeScope |
|
|
73
|
+
|-----------|------------------|-------------|
|
|
74
|
+
| Background job library | Required | **Not needed** |
|
|
75
|
+
| Redis/database for jobs | Required | **Not needed** |
|
|
76
|
+
| Job scheduler (cron) | Required | **Not needed** |
|
|
77
|
+
| Status column | Required | **Not needed** |
|
|
78
|
+
| Migration to update status | Required | **Not needed** |
|
|
79
|
+
| Monitoring for job failures | Required | **Not needed** |
|
|
80
|
+
| Retry logic | Required | **Not needed** |
|
|
81
|
+
| Race condition handling | Required | **Not needed** |
|
|
82
|
+
|
|
83
|
+
### Bonus: Time Travel for Free
|
|
84
|
+
|
|
85
|
+
With cron-based systems, answering "How many points did user X have on January 15th?" requires complex audit logging or event sourcing.
|
|
86
|
+
|
|
87
|
+
With InTimeScope:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
user.points.in_time(Date.parse("2024-01-15").middle_of_day).sum(:amount)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**Historical queries just work.** No extra tables. No event sourcing. No complexity.
|
|
94
|
+
|
|
95
|
+
## Schema
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
# Migration
|
|
99
|
+
class CreatePoints < ActiveRecord::Migration[7.0]
|
|
100
|
+
def change
|
|
101
|
+
create_table :points do |t|
|
|
102
|
+
t.references :user, null: false, foreign_key: true
|
|
103
|
+
t.integer :amount, null: false
|
|
104
|
+
t.string :reason, null: false
|
|
105
|
+
t.datetime :start_at, null: false # When points become usable
|
|
106
|
+
t.datetime :end_at, null: false # When points expire
|
|
107
|
+
t.timestamps
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
add_index :points, [:user_id, :start_at, :end_at]
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Models
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
class Point < ApplicationRecord
|
|
119
|
+
belongs_to :user
|
|
120
|
+
|
|
121
|
+
# Both start_at and end_at are required (full time window)
|
|
122
|
+
in_time_scope start_at: { null: false }, end_at: { null: false }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
class User < ApplicationRecord
|
|
126
|
+
has_many :points
|
|
127
|
+
has_many :in_time_points, -> { in_time }, class_name: "Point"
|
|
128
|
+
|
|
129
|
+
# Grant monthly bonus points (pre-scheduled)
|
|
130
|
+
def grant_monthly_bonus(amount:, months_valid: 6)
|
|
131
|
+
points.create!(
|
|
132
|
+
amount: amount,
|
|
133
|
+
reason: "Monthly membership bonus",
|
|
134
|
+
start_at: 1.month.from_now, # Activates next month
|
|
135
|
+
end_at: (1 + months_valid).months.from_now
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### The Power of `has_many :in_time_points`
|
|
142
|
+
|
|
143
|
+
This simple line unlocks **N+1 free eager loading** for valid points:
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
# Load 100 users with their valid points in just 2 queries
|
|
147
|
+
users = User.includes(:in_time_points).limit(100)
|
|
148
|
+
|
|
149
|
+
users.each do |user|
|
|
150
|
+
# No additional queries! Already loaded.
|
|
151
|
+
total = user.in_time_points.sum(&:amount)
|
|
152
|
+
puts "#{user.name}: #{total} points"
|
|
153
|
+
end
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Without this association, you'd need:
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
# N+1 problem: 1 query for users + 100 queries for points
|
|
160
|
+
users = User.limit(100)
|
|
161
|
+
users.each do |user|
|
|
162
|
+
total = user.points.in_time.sum(:amount) # Query per user!
|
|
163
|
+
end
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Usage
|
|
167
|
+
|
|
168
|
+
### Granting Points with Different Validity Periods
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
user = User.find(1)
|
|
172
|
+
|
|
173
|
+
# Immediate points (valid for 1 year)
|
|
174
|
+
user.points.create!(
|
|
175
|
+
amount: 100,
|
|
176
|
+
reason: "Welcome bonus",
|
|
177
|
+
start_at: Time.current,
|
|
178
|
+
end_at: 1.year.from_now
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Pre-scheduled points for 6-month members
|
|
182
|
+
# Points activate next month, valid for 6 months after activation
|
|
183
|
+
user.grant_monthly_bonus(amount: 500, months_valid: 6)
|
|
184
|
+
|
|
185
|
+
# Campaign points (limited time)
|
|
186
|
+
user.points.create!(
|
|
187
|
+
amount: 200,
|
|
188
|
+
reason: "Summer campaign",
|
|
189
|
+
start_at: Date.parse("2024-07-01").beginning_of_day,
|
|
190
|
+
end_at: Date.parse("2024-08-31").end_of_day
|
|
191
|
+
)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Querying Points
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
# Current valid points
|
|
198
|
+
user.in_time_member_points.sum(:amount)
|
|
199
|
+
# => 100 (only the welcome bonus is currently active)
|
|
200
|
+
|
|
201
|
+
# Check how many points will be available next month
|
|
202
|
+
user.in_time_member_points(1.month.from_now).sum(:amount)
|
|
203
|
+
# => 600 (welcome bonus + monthly bonus)
|
|
204
|
+
|
|
205
|
+
# Pending points (scheduled but not yet active)
|
|
206
|
+
user.points.before_in_time.sum(:amount)
|
|
207
|
+
# => 500 (monthly bonus waiting to activate)
|
|
208
|
+
|
|
209
|
+
# Expired points
|
|
210
|
+
user.points.after_in_time.sum(:amount)
|
|
211
|
+
|
|
212
|
+
# All invalid points (pending + expired)
|
|
213
|
+
user.points.out_of_time.sum(:amount)
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Admin Dashboard Queries
|
|
217
|
+
|
|
218
|
+
```ruby
|
|
219
|
+
# Historical audit: points valid on a specific date
|
|
220
|
+
Point.in_time(Date.parse("2024-01-15").middle_of_day)
|
|
221
|
+
.group(:user_id)
|
|
222
|
+
.sum(:amount)
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## Automatic Membership Bonus Flow
|
|
226
|
+
|
|
227
|
+
For 6-month premium members, you can set up recurring bonuses **without cron, without Sidekiq, without Redis, without monitoring**:
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
# When user signs up for premium, create membership and all bonuses atomically
|
|
231
|
+
ActiveRecord::Base.transaction do
|
|
232
|
+
membership = Membership.create!(user: user, plan: "premium_6_months")
|
|
233
|
+
|
|
234
|
+
# Pre-create all 6 monthly bonuses at signup
|
|
235
|
+
6.times do |month|
|
|
236
|
+
user.points.create!(
|
|
237
|
+
amount: 500,
|
|
238
|
+
reason: "Premium member bonus - Month #{month + 1}",
|
|
239
|
+
start_at: (month + 1).months.from_now,
|
|
240
|
+
end_at: (month + 7).months.from_now # Each bonus valid for 6 months
|
|
241
|
+
)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
# => Creates membership + 6 point records that will activate monthly
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
## Why This Design is Superior
|
|
248
|
+
|
|
249
|
+
### Correctness
|
|
250
|
+
|
|
251
|
+
- **No race conditions**: Cron jobs can run twice, skip runs, or overlap. InTimeScope queries are always deterministic.
|
|
252
|
+
- **No timing drift**: Cron runs at intervals (every minute? every 5 minutes?). InTimeScope is accurate to the millisecond.
|
|
253
|
+
- **No lost updates**: Job failures can leave points in wrong states. InTimeScope has no state to corrupt.
|
|
254
|
+
|
|
255
|
+
### Simplicity
|
|
256
|
+
|
|
257
|
+
- **No infrastructure**: Delete your Sidekiq. Delete your Redis. Delete your job monitoring.
|
|
258
|
+
- **No migrations for status changes**: The time IS the status. No `UPDATE` statements needed.
|
|
259
|
+
- **No debugging job logs**: Just query the database to see exactly what's happening.
|
|
260
|
+
|
|
261
|
+
### Testability
|
|
262
|
+
|
|
263
|
+
```ruby
|
|
264
|
+
# Cron-based testing is painful:
|
|
265
|
+
travel_to 1.month.from_now do
|
|
266
|
+
ActivatePointsJob.perform_now
|
|
267
|
+
ExpirePointsJob.perform_now
|
|
268
|
+
expect(user.points.active.sum(:amount)).to eq(500)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# InTimeScope testing is trivial:
|
|
272
|
+
expect(user.points.in_time(1.month.from_now).sum(:amount)).to eq(500)
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### Summary
|
|
276
|
+
|
|
277
|
+
| Aspect | Cron-Based | InTimeScope |
|
|
278
|
+
|--------|-----------|-------------|
|
|
279
|
+
| Infrastructure | Sidekiq + Redis + Cron | **None** |
|
|
280
|
+
| Point activation | Batch job (delayed) | **Instant** |
|
|
281
|
+
| Historical queries | Impossible without audit log | **Built-in** |
|
|
282
|
+
| Timing accuracy | Minutes (cron interval) | **Milliseconds** |
|
|
283
|
+
| Debugging | Job logs + database | **Database only** |
|
|
284
|
+
| Testing | Time travel + run jobs | **Just query** |
|
|
285
|
+
| Failure modes | Many (job failures, race conditions) | **None** |
|
|
286
|
+
|
|
287
|
+
## Tips
|
|
288
|
+
|
|
289
|
+
1. **Use database indexes** on `[user_id, start_at, end_at]` for optimal performance.
|
|
290
|
+
|
|
291
|
+
2. **Pre-grant points at signup** instead of scheduling cron jobs.
|
|
292
|
+
|
|
293
|
+
3. **Use `in_time(time)` for audits** to check point balances at any historical time.
|
|
294
|
+
|
|
295
|
+
4. **Combine with inverse scopes** to build admin dashboards showing pending/expired points.
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# User Name History Example
|
|
2
|
+
|
|
3
|
+
This example demonstrates how to manage user name history with `in_time_scope`, allowing you to query a user's name at any point in time.
|
|
4
|
+
|
|
5
|
+
See also: [spec/user_name_history_spec.rb](https://github.com/kyohah/in_time_scope/blob/main/spec/user_name_history_spec.rb)
|
|
6
|
+
|
|
7
|
+
## Use Case
|
|
8
|
+
|
|
9
|
+
- Users can change their display name
|
|
10
|
+
- You need to keep a history of all name changes
|
|
11
|
+
- You want to retrieve the name that was active at a specific time (e.g., for audit logs, historical reports)
|
|
12
|
+
|
|
13
|
+
## Schema
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
# Migration
|
|
17
|
+
class CreateUserNameHistories < ActiveRecord::Migration[7.0]
|
|
18
|
+
def change
|
|
19
|
+
create_table :users do |t|
|
|
20
|
+
t.string :email, null: false
|
|
21
|
+
t.timestamps
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
create_table :user_name_histories do |t|
|
|
25
|
+
t.references :user, null: false, foreign_key: true
|
|
26
|
+
t.string :name, null: false
|
|
27
|
+
t.datetime :start_at, null: false # When this name became active
|
|
28
|
+
t.timestamps
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
add_index :user_name_histories, [:user_id, :start_at]
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Models
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
class UserNameHistory < ApplicationRecord
|
|
40
|
+
belongs_to :user
|
|
41
|
+
include InTimeScope
|
|
42
|
+
|
|
43
|
+
# Start-only pattern: each record is valid from start_at until the next record
|
|
44
|
+
in_time_scope start_at: { null: false }, end_at: { column: nil }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class User < ApplicationRecord
|
|
48
|
+
has_many :user_name_histories
|
|
49
|
+
|
|
50
|
+
# Get the current name (latest record that has started)
|
|
51
|
+
has_one :current_name_history,
|
|
52
|
+
-> { latest_in_time(:user_id) },
|
|
53
|
+
class_name: "UserNameHistory"
|
|
54
|
+
|
|
55
|
+
# Convenience method for current name
|
|
56
|
+
def current_name
|
|
57
|
+
current_name_history&.name
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Get name at a specific time
|
|
61
|
+
def name_at(time)
|
|
62
|
+
user_name_histories.in_time(time).order(start_at: :desc).first&.name
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Usage
|
|
68
|
+
|
|
69
|
+
### Creating Name History
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
user = User.create!(email: "alice@example.com")
|
|
73
|
+
|
|
74
|
+
# Initial name
|
|
75
|
+
UserNameHistory.create!(
|
|
76
|
+
user: user,
|
|
77
|
+
name: "Alice",
|
|
78
|
+
start_at: Time.parse("2024-01-01")
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Name change
|
|
82
|
+
UserNameHistory.create!(
|
|
83
|
+
user: user,
|
|
84
|
+
name: "Alice Smith",
|
|
85
|
+
start_at: Time.parse("2024-06-01")
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Another name change
|
|
89
|
+
UserNameHistory.create!(
|
|
90
|
+
user: user,
|
|
91
|
+
name: "Alice Johnson",
|
|
92
|
+
start_at: Time.parse("2024-09-01")
|
|
93
|
+
)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Querying Names
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
# Current name (uses has_one with latest_in_time)
|
|
100
|
+
user.current_name
|
|
101
|
+
# => "Alice Johnson"
|
|
102
|
+
|
|
103
|
+
# Name at a specific time
|
|
104
|
+
user.name_at(Time.parse("2024-03-15"))
|
|
105
|
+
# => "Alice"
|
|
106
|
+
|
|
107
|
+
user.name_at(Time.parse("2024-07-15"))
|
|
108
|
+
# => "Alice Smith"
|
|
109
|
+
|
|
110
|
+
user.name_at(Time.parse("2024-10-15"))
|
|
111
|
+
# => "Alice Johnson"
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Efficient Eager Loading
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
# Load users with their current names (no N+1)
|
|
118
|
+
users = User.includes(:current_name_history).limit(100)
|
|
119
|
+
|
|
120
|
+
users.each do |user|
|
|
121
|
+
puts "#{user.email}: #{user.current_name_history&.name}"
|
|
122
|
+
end
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Querying Active Records
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
# All name records that are currently active
|
|
129
|
+
UserNameHistory.in_time
|
|
130
|
+
# => Returns the latest name record for each user
|
|
131
|
+
|
|
132
|
+
# Name records that were active at a specific time
|
|
133
|
+
UserNameHistory.in_time(Time.parse("2024-05-01"))
|
|
134
|
+
|
|
135
|
+
# Name records not yet started (scheduled for future)
|
|
136
|
+
UserNameHistory.before_in_time
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## How `latest_in_time` Works
|
|
140
|
+
|
|
141
|
+
The `latest_in_time(:user_id)` scope generates an efficient `NOT EXISTS` subquery:
|
|
142
|
+
|
|
143
|
+
```sql
|
|
144
|
+
SELECT * FROM user_name_histories AS h
|
|
145
|
+
WHERE h.start_at <= '2024-10-01'
|
|
146
|
+
AND NOT EXISTS (
|
|
147
|
+
SELECT 1 FROM user_name_histories AS newer
|
|
148
|
+
WHERE newer.user_id = h.user_id
|
|
149
|
+
AND newer.start_at <= '2024-10-01'
|
|
150
|
+
AND newer.start_at > h.start_at
|
|
151
|
+
)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
This returns only the most recent record per user that was active at the given time, making it perfect for `has_one` associations.
|
|
155
|
+
|
|
156
|
+
## Tips
|
|
157
|
+
|
|
158
|
+
1. **Always use `latest_in_time` with `has_one`** - It ensures you get exactly one record per foreign key.
|
|
159
|
+
|
|
160
|
+
2. **Add a composite index** on `[user_id, start_at]` for optimal query performance.
|
|
161
|
+
|
|
162
|
+
3. **Use `includes` for eager loading** - The `NOT EXISTS` pattern works efficiently with Rails eager loading.
|
|
163
|
+
|
|
164
|
+
4. **Consider adding a unique constraint** on `[user_id, start_at]` to prevent duplicate records at the same time.
|