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.
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.
@@ -0,0 +1,5 @@
1
+ # 目录
2
+
3
+ - [简介](./index.md)
4
+ - [带有效期的积分系统](./point-system.md)
5
+ - [用户名历史](./user-name-history.md)