active_record_in_time_scope 0.1.7
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 +7 -0
- data/.rubocop.yml +47 -0
- data/.rulesync/commands/translate-readme.md +46 -0
- data/.rulesync/rules/project.md +87 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +191 -0
- data/Rakefile +10 -0
- data/Steepfile +25 -0
- 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/active_record_in_time_scope/class_methods.rb +457 -0
- data/lib/active_record_in_time_scope/version.rb +6 -0
- data/lib/active_record_in_time_scope.rb +132 -0
- data/mise.toml +2 -0
- data/rbs_collection.yaml +16 -0
- data/rulesync.jsonc +6 -0
- data/sig/active_record_in_time_scope.rbs +95 -0
- metadata +223 -0
data/docs/ja/index.md
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# InTimeScope
|
|
2
|
+
|
|
3
|
+
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
|
+
これだけです。DSL1行で、モデルに生SQLを書く必要がなくなります。
|
|
18
|
+
|
|
19
|
+
## このGemを使う理由
|
|
20
|
+
|
|
21
|
+
このGemの目的:
|
|
22
|
+
|
|
23
|
+
- **時間範囲ロジックの一貫性を保つ** - コードベース全体で統一
|
|
24
|
+
- **コピペSQLを回避** - 間違えやすいSQLの繰り返しを防止
|
|
25
|
+
- **時間をファーストクラスのドメイン概念に** - `in_time_published`のような名前付きスコープ
|
|
26
|
+
- **NULL許可を自動検出** - スキーマから最適化されたクエリを生成
|
|
27
|
+
|
|
28
|
+
## 推奨される用途
|
|
29
|
+
|
|
30
|
+
- 有効期限を持つRailsアプリケーション
|
|
31
|
+
- `start_at` / `end_at`カラムを持つモデル
|
|
32
|
+
- 散在する`where`句なしで一貫した時間ロジックを求めるチーム
|
|
33
|
+
|
|
34
|
+
## インストール
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
bundle add in_time_scope
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## クイックスタート
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
class Event < ActiveRecord::Base
|
|
44
|
+
in_time_scope
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# クラススコープ
|
|
48
|
+
Event.in_time # 現在有効なレコード
|
|
49
|
+
Event.in_time(Time.parse("2024-06-01")) # 特定時刻に有効なレコード
|
|
50
|
+
|
|
51
|
+
# インスタンスメソッド
|
|
52
|
+
event.in_time? # このレコードは現在有効?
|
|
53
|
+
event.in_time?(some_time) # その時刻に有効だった?
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## 機能
|
|
57
|
+
|
|
58
|
+
### 自動最適化されたSQL
|
|
59
|
+
|
|
60
|
+
GemがスキーマをベースにSQLを読み取り、適切なSQLを生成します:
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
# NULL許可カラム → NULL対応クエリ
|
|
64
|
+
WHERE (start_at IS NULL OR start_at <= ?) AND (end_at IS NULL OR end_at > ?)
|
|
65
|
+
|
|
66
|
+
# NOT NULLカラム → シンプルなクエリ
|
|
67
|
+
WHERE start_at <= ? AND end_at > ?
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### 名前付きスコープ
|
|
71
|
+
|
|
72
|
+
モデルごとに複数の時間範囲:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
class Article < ActiveRecord::Base
|
|
76
|
+
in_time_scope :published # → Article.in_time_published
|
|
77
|
+
in_time_scope :featured # → Article.in_time_featured
|
|
78
|
+
end
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### カスタムカラム
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
class Campaign < ActiveRecord::Base
|
|
85
|
+
in_time_scope start_at: { column: :available_at },
|
|
86
|
+
end_at: { column: :expired_at }
|
|
87
|
+
end
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### 開始のみパターン(バージョン履歴)
|
|
91
|
+
|
|
92
|
+
各行が次の行まで有効なレコード用:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
class Price < ActiveRecord::Base
|
|
96
|
+
in_time_scope start_at: { null: false }, end_at: { column: nil }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# ボーナス:NOT EXISTSを使った効率的なhas_one
|
|
100
|
+
class User < ActiveRecord::Base
|
|
101
|
+
has_one :current_price, -> { latest_in_time(:user_id) }, class_name: "Price"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
User.includes(:current_price) # N+1なし、ユーザーごとに最新のみ取得
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### 終了のみパターン(有効期限)
|
|
108
|
+
|
|
109
|
+
有効期限が切れるまで有効なレコード用:
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
class Coupon < ActiveRecord::Base
|
|
113
|
+
in_time_scope start_at: { column: nil }, end_at: { null: false }
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### 逆スコープ
|
|
118
|
+
|
|
119
|
+
時間範囲外のレコードをクエリ:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
# まだ開始していないレコード (start_at > time)
|
|
123
|
+
Event.before_in_time
|
|
124
|
+
event.before_in_time?
|
|
125
|
+
|
|
126
|
+
# すでに終了したレコード (end_at <= time)
|
|
127
|
+
Event.after_in_time
|
|
128
|
+
event.after_in_time?
|
|
129
|
+
|
|
130
|
+
# 時間範囲外のレコード(開始前または終了後)
|
|
131
|
+
Event.out_of_time
|
|
132
|
+
event.out_of_time? # in_time?の論理的な逆
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
名前付きスコープでも動作:
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
Article.before_in_time_published # まだ公開されていない
|
|
139
|
+
Article.after_in_time_published # 公開終了
|
|
140
|
+
Article.out_of_time_published # 現在公開されていない
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## オプションリファレンス
|
|
144
|
+
|
|
145
|
+
| オプション | デフォルト | 説明 | 例 |
|
|
146
|
+
| --- | --- | --- | --- |
|
|
147
|
+
| `scope_name`(第1引数) | `:in_time` | `in_time_published`のような名前付きスコープ | `in_time_scope :published` |
|
|
148
|
+
| `start_at: { column: }` | `:start_at` | カスタムカラム名、`nil`で無効化 | `start_at: { column: :available_at }` |
|
|
149
|
+
| `end_at: { column: }` | `:end_at` | カスタムカラム名、`nil`で無効化 | `end_at: { column: nil }` |
|
|
150
|
+
| `start_at: { null: }` | 自動検出 | NULL処理を強制 | `start_at: { null: false }` |
|
|
151
|
+
| `end_at: { null: }` | 自動検出 | NULL処理を強制 | `end_at: { null: true }` |
|
|
152
|
+
|
|
153
|
+
## 使用例
|
|
154
|
+
|
|
155
|
+
- [有効期限付きポイントシステム](./point-system.md) - フルタイムウィンドウパターン
|
|
156
|
+
- [ユーザー名履歴](./user-name-history.md) - 開始のみパターン
|
|
157
|
+
|
|
158
|
+
## 謝辞
|
|
159
|
+
|
|
160
|
+
[onk/shibaraku](https://github.com/onk/shibaraku)にインスパイアされました。このGemは以下の機能で概念を拡張しています:
|
|
161
|
+
|
|
162
|
+
- 最適化されたクエリのためのスキーマ対応NULL処理
|
|
163
|
+
- モデルごとの複数の名前付きスコープ
|
|
164
|
+
- 開始のみ / 終了のみパターン
|
|
165
|
+
- 効率的な`has_one`アソシエーション用の`latest_in_time` / `earliest_in_time`
|
|
166
|
+
- 逆スコープ: `before_in_time`, `after_in_time`, `out_of_time`
|
|
167
|
+
|
|
168
|
+
## 開発
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
# 依存関係のインストール
|
|
172
|
+
bin/setup
|
|
173
|
+
|
|
174
|
+
# テスト実行
|
|
175
|
+
bundle exec rspec
|
|
176
|
+
|
|
177
|
+
# Lint実行
|
|
178
|
+
bundle exec rubocop
|
|
179
|
+
|
|
180
|
+
# CLAUDE.mdの生成(AIコーディングアシスタント用)
|
|
181
|
+
npx rulesync generate
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
このプロジェクトは[rulesync](https://github.com/dyoshikawa/rulesync)を使用してAIアシスタントルールを管理しています。`.rulesync/rules/*.md`を編集し、`npx rulesync generate`を実行して`CLAUDE.md`を更新してください。
|
|
185
|
+
|
|
186
|
+
## 貢献
|
|
187
|
+
|
|
188
|
+
バグレポートとプルリクエストは[GitHub](https://github.com/kyohah/in_time_scope)で受け付けています。
|
|
189
|
+
|
|
190
|
+
## ライセンス
|
|
191
|
+
|
|
192
|
+
MITライセンス
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# 有効期限付きポイントシステムの例
|
|
2
|
+
|
|
3
|
+
この例では、`in_time_scope`を使用して有効期限付きポイントシステムを実装する方法を示します。ポイントは将来有効になるように事前付与でき、cronジョブが不要になります。
|
|
4
|
+
|
|
5
|
+
参照: [spec/point_system_spec.rb](https://github.com/kyohah/in_time_scope/blob/main/spec/point_system_spec.rb)
|
|
6
|
+
|
|
7
|
+
## ユースケース
|
|
8
|
+
|
|
9
|
+
- ユーザーが有効期限付きのポイントを獲得(開始日と終了日)
|
|
10
|
+
- ポイントを将来有効になるように事前付与可能(例:月額メンバーシップボーナス)
|
|
11
|
+
- cronジョブなしで任意の時点の有効ポイントを計算
|
|
12
|
+
- 今後のポイント、期限切れポイントなどを検索
|
|
13
|
+
|
|
14
|
+
## Cronジョブ不要
|
|
15
|
+
|
|
16
|
+
**これが最大の特徴です。** 従来のポイントシステムはスケジュールジョブの悪夢です:
|
|
17
|
+
|
|
18
|
+
### よくあるCron地獄
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
# activate_points_job.rb - 毎分実行
|
|
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 - 毎分実行
|
|
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
|
+
# さらに必要なもの:
|
|
40
|
+
# - Sidekiq / Delayed Job / Good Job
|
|
41
|
+
# - Redis(Sidekiq用)
|
|
42
|
+
# - Cronまたはwhenever gem
|
|
43
|
+
# - ジョブ失敗の監視
|
|
44
|
+
# - 失敗時のリトライロジック
|
|
45
|
+
# - 重複実行防止のロック機構
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### InTimeScopeを使う方法
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
# これだけ。ジョブなし。ステータスカラムなし。インフラ不要。
|
|
52
|
+
user.points.in_time.sum(:amount)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**1行。インフラゼロ。常に正確。**
|
|
56
|
+
|
|
57
|
+
### なぜこれが機能するのか
|
|
58
|
+
|
|
59
|
+
`start_at`と`end_at`カラムがそのまま状態です。`status`カラムは不要で、時間比較はクエリ時に行われます:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
# これらすべてがバックグラウンド処理なしで動作:
|
|
63
|
+
user.points.in_time # 現在有効
|
|
64
|
+
user.points.in_time(1.month.from_now) # 来月有効
|
|
65
|
+
user.points.in_time(1.year.ago) # 昨年有効だった(監査!)
|
|
66
|
+
user.points.before_in_time # 保留中(まだ有効でない)
|
|
67
|
+
user.points.after_in_time # 期限切れ
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### 削減できるもの
|
|
71
|
+
|
|
72
|
+
| コンポーネント | Cronベースシステム | InTimeScope |
|
|
73
|
+
|-----------|------------------|-------------|
|
|
74
|
+
| バックグラウンドジョブライブラリ | 必要 | **不要** |
|
|
75
|
+
| ジョブ用Redis/データベース | 必要 | **不要** |
|
|
76
|
+
| ジョブスケジューラ(cron) | 必要 | **不要** |
|
|
77
|
+
| ステータスカラム | 必要 | **不要** |
|
|
78
|
+
| ステータス更新のマイグレーション | 必要 | **不要** |
|
|
79
|
+
| ジョブ失敗の監視 | 必要 | **不要** |
|
|
80
|
+
| リトライロジック | 必要 | **不要** |
|
|
81
|
+
| 競合状態の処理 | 必要 | **不要** |
|
|
82
|
+
|
|
83
|
+
### ボーナス:タイムトラベルが無料
|
|
84
|
+
|
|
85
|
+
Cronベースのシステムでは、「ユーザーXの1月15日のポイント残高は?」という質問に答えるには複雑な監査ログやイベントソーシングが必要です。
|
|
86
|
+
|
|
87
|
+
InTimeScopeなら:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
user.points.in_time(Date.parse("2024-01-15").middle_of_day).sum(:amount)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**履歴クエリがそのまま動きます。** 追加テーブル不要。イベントソーシング不要。複雑さゼロ。
|
|
94
|
+
|
|
95
|
+
## スキーマ
|
|
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 # ポイントが使用可能になる日時
|
|
106
|
+
t.datetime :end_at, null: false # ポイントが期限切れになる日時
|
|
107
|
+
t.timestamps
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
add_index :points, [:user_id, :start_at, :end_at]
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## モデル
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
class Point < ApplicationRecord
|
|
119
|
+
belongs_to :user
|
|
120
|
+
|
|
121
|
+
# start_atとend_atの両方が必須(完全な時間範囲)
|
|
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
|
+
# 月次ボーナスポイントを付与(事前スケジュール)
|
|
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, # 来月有効化
|
|
135
|
+
end_at: (1 + months_valid).months.from_now
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### `has_many :in_time_points`の威力
|
|
142
|
+
|
|
143
|
+
このシンプルな1行で、有効ポイントの**N+1問題のないEager Loading**が可能になります:
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
# 100人のユーザーと有効ポイントをたった2クエリで取得
|
|
147
|
+
users = User.includes(:in_time_points).limit(100)
|
|
148
|
+
|
|
149
|
+
users.each do |user|
|
|
150
|
+
# 追加クエリなし!すでにロード済み。
|
|
151
|
+
total = user.in_time_points.sum(&:amount)
|
|
152
|
+
puts "#{user.name}: #{total} points"
|
|
153
|
+
end
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
このアソシエーションがないと:
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
# N+1問題:ユーザー1クエリ + ポイント100クエリ
|
|
160
|
+
users = User.limit(100)
|
|
161
|
+
users.each do |user|
|
|
162
|
+
total = user.points.in_time.sum(:amount) # ユーザーごとにクエリ!
|
|
163
|
+
end
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## 使い方
|
|
167
|
+
|
|
168
|
+
### 異なる有効期限でポイントを付与
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
user = User.find(1)
|
|
172
|
+
|
|
173
|
+
# 即時ポイント(1年間有効)
|
|
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
|
+
# 6ヶ月会員向けの事前スケジュールポイント
|
|
182
|
+
# ポイントは来月有効化、有効化後6ヶ月間有効
|
|
183
|
+
user.grant_monthly_bonus(amount: 500, months_valid: 6)
|
|
184
|
+
|
|
185
|
+
# キャンペーンポイント(期間限定)
|
|
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
|
+
### ポイントの検索
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
# 現在の有効ポイント
|
|
198
|
+
user.in_time_member_points.sum(:amount)
|
|
199
|
+
# => 100(ウェルカムボーナスのみ現在有効)
|
|
200
|
+
|
|
201
|
+
# 来月利用可能になるポイント数を確認
|
|
202
|
+
user.in_time_member_points(1.month.from_now).sum(:amount)
|
|
203
|
+
# => 600(ウェルカムボーナス + 月次ボーナス)
|
|
204
|
+
|
|
205
|
+
# 保留中のポイント(スケジュール済みだがまだ有効でない)
|
|
206
|
+
user.points.before_in_time.sum(:amount)
|
|
207
|
+
# => 500(有効化待ちの月次ボーナス)
|
|
208
|
+
|
|
209
|
+
# 期限切れポイント
|
|
210
|
+
user.points.after_in_time.sum(:amount)
|
|
211
|
+
|
|
212
|
+
# すべての無効ポイント(保留中 + 期限切れ)
|
|
213
|
+
user.points.out_of_time.sum(:amount)
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### 管理ダッシュボードクエリ
|
|
217
|
+
|
|
218
|
+
```ruby
|
|
219
|
+
# 履歴監査:特定日に有効だったポイント
|
|
220
|
+
Point.in_time(Date.parse("2024-01-15").middle_of_day)
|
|
221
|
+
.group(:user_id)
|
|
222
|
+
.sum(:amount)
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## 自動メンバーシップボーナスフロー
|
|
226
|
+
|
|
227
|
+
6ヶ月プレミアムメンバー向けに、**cronなし、Sidekiqなし、Redisなし、監視なし**で定期ボーナスを設定できます:
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
# ユーザーがプレミアムに登録したとき、メンバーシップと全ボーナスをアトミックに作成
|
|
231
|
+
ActiveRecord::Base.transaction do
|
|
232
|
+
membership = Membership.create!(user: user, plan: "premium_6_months")
|
|
233
|
+
|
|
234
|
+
# 登録時に6ヶ月分の月次ボーナスを事前作成
|
|
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 # 各ボーナスは6ヶ月間有効
|
|
241
|
+
)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
# => メンバーシップ + 毎月有効化される6つのポイントレコードを作成
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
## この設計が優れている理由
|
|
248
|
+
|
|
249
|
+
### 正確性
|
|
250
|
+
|
|
251
|
+
- **競合状態なし**: Cronジョブは2回実行されたり、スキップしたり、重複したりする可能性があります。InTimeScopeのクエリは常に決定論的です。
|
|
252
|
+
- **タイミングのずれなし**: Cronは間隔で実行(毎分?5分ごと?)。InTimeScopeはミリ秒単位で正確です。
|
|
253
|
+
- **更新漏れなし**: ジョブの失敗でポイントが不正な状態になる可能性があります。InTimeScopeには破損する状態がありません。
|
|
254
|
+
|
|
255
|
+
### シンプルさ
|
|
256
|
+
|
|
257
|
+
- **インフラ不要**: Sidekiqを削除。Redisを削除。ジョブ監視を削除。
|
|
258
|
+
- **ステータス変更のマイグレーション不要**: 時間がそのまま状態です。`UPDATE`文は不要。
|
|
259
|
+
- **ジョブログのデバッグ不要**: データベースをクエリするだけで何が起きているかわかります。
|
|
260
|
+
|
|
261
|
+
### テスト容易性
|
|
262
|
+
|
|
263
|
+
```ruby
|
|
264
|
+
# Cronベースのテストは面倒:
|
|
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のテストは簡単:
|
|
272
|
+
expect(user.points.in_time(1.month.from_now).sum(:amount)).to eq(500)
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### まとめ
|
|
276
|
+
|
|
277
|
+
| 観点 | Cronベース | InTimeScope |
|
|
278
|
+
|--------|-----------|-------------|
|
|
279
|
+
| インフラ | Sidekiq + Redis + Cron | **なし** |
|
|
280
|
+
| ポイント有効化 | バッチジョブ(遅延) | **即時** |
|
|
281
|
+
| 履歴クエリ | 監査ログなしでは不可能 | **組み込み** |
|
|
282
|
+
| タイミング精度 | 分(cron間隔) | **ミリ秒** |
|
|
283
|
+
| デバッグ | ジョブログ + データベース | **データベースのみ** |
|
|
284
|
+
| テスト | タイムトラベル + ジョブ実行 | **クエリのみ** |
|
|
285
|
+
| 障害モード | 多数(ジョブ失敗、競合状態) | **なし** |
|
|
286
|
+
|
|
287
|
+
## Tips
|
|
288
|
+
|
|
289
|
+
1. **データベースインデックスを使用** - `[user_id, start_at, end_at]`にインデックスを追加してパフォーマンスを最適化。
|
|
290
|
+
|
|
291
|
+
2. **登録時にポイントを事前付与** - cronジョブをスケジュールする代わりに。
|
|
292
|
+
|
|
293
|
+
3. **監査には`in_time(time)`を使用** - 任意の過去時点のポイント残高を確認。
|
|
294
|
+
|
|
295
|
+
4. **逆スコープと組み合わせ** - 保留中/期限切れポイントを表示する管理ダッシュボードを構築。
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# ユーザー名履歴の例
|
|
2
|
+
|
|
3
|
+
この例では、`in_time_scope`を使用してユーザー名の履歴を管理し、任意の時点でのユーザー名を取得する方法を示します。
|
|
4
|
+
|
|
5
|
+
参照: [spec/user_name_history_spec.rb](https://github.com/kyohah/in_time_scope/blob/main/spec/user_name_history_spec.rb)
|
|
6
|
+
|
|
7
|
+
## ユースケース
|
|
8
|
+
|
|
9
|
+
- ユーザーが表示名を変更できる
|
|
10
|
+
- すべての名前変更の履歴を保持する必要がある
|
|
11
|
+
- 特定の時点で有効だった名前を取得したい(監査ログ、履歴レポートなど)
|
|
12
|
+
|
|
13
|
+
## スキーマ
|
|
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 # この名前が有効になった日時
|
|
28
|
+
t.timestamps
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
add_index :user_name_histories, [:user_id, :start_at]
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## モデル
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
class UserNameHistory < ApplicationRecord
|
|
40
|
+
belongs_to :user
|
|
41
|
+
include InTimeScope
|
|
42
|
+
|
|
43
|
+
# 開始日のみパターン: 各レコードはstart_atから次のレコードまで有効
|
|
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
|
+
# 現在の名前を取得(開始済みの最新レコード)
|
|
51
|
+
has_one :current_name_history,
|
|
52
|
+
-> { latest_in_time(:user_id) },
|
|
53
|
+
class_name: "UserNameHistory"
|
|
54
|
+
|
|
55
|
+
# 現在の名前を取得する便利メソッド
|
|
56
|
+
def current_name
|
|
57
|
+
current_name_history&.name
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# 特定時点の名前を取得
|
|
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
|
+
## 使い方
|
|
68
|
+
|
|
69
|
+
### 名前履歴の作成
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
user = User.create!(email: "alice@example.com")
|
|
73
|
+
|
|
74
|
+
# 初期の名前
|
|
75
|
+
UserNameHistory.create!(
|
|
76
|
+
user: user,
|
|
77
|
+
name: "Alice",
|
|
78
|
+
start_at: Time.parse("2024-01-01")
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# 名前の変更
|
|
82
|
+
UserNameHistory.create!(
|
|
83
|
+
user: user,
|
|
84
|
+
name: "Alice Smith",
|
|
85
|
+
start_at: Time.parse("2024-06-01")
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# さらに名前を変更
|
|
89
|
+
UserNameHistory.create!(
|
|
90
|
+
user: user,
|
|
91
|
+
name: "Alice Johnson",
|
|
92
|
+
start_at: Time.parse("2024-09-01")
|
|
93
|
+
)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### 名前の取得
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
# 現在の名前(has_oneとlatest_in_timeを使用)
|
|
100
|
+
user.current_name
|
|
101
|
+
# => "Alice Johnson"
|
|
102
|
+
|
|
103
|
+
# 特定時点の名前
|
|
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
|
+
### 効率的なEager Loading
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
# ユーザーと現在の名前を一括取得(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
|
+
### 有効なレコードの検索
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
# 現在有効なすべての名前レコード
|
|
129
|
+
UserNameHistory.in_time
|
|
130
|
+
# => 各ユーザーの最新の名前レコードを返す
|
|
131
|
+
|
|
132
|
+
# 特定時点で有効だった名前レコード
|
|
133
|
+
UserNameHistory.in_time(Time.parse("2024-05-01"))
|
|
134
|
+
|
|
135
|
+
# まだ開始していない名前レコード(将来予定)
|
|
136
|
+
UserNameHistory.before_in_time
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## `latest_in_time`の仕組み
|
|
140
|
+
|
|
141
|
+
`latest_in_time(:user_id)`スコープは効率的な`NOT EXISTS`サブクエリを生成します:
|
|
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
|
+
これにより、指定時点で有効だったユーザーごとの最新レコードのみが返され、`has_one`アソシエーションに最適です。
|
|
155
|
+
|
|
156
|
+
## Tips
|
|
157
|
+
|
|
158
|
+
1. **`has_one`では必ず`latest_in_time`を使用** - 外部キーごとに1レコードのみが取得されることを保証します。
|
|
159
|
+
|
|
160
|
+
2. **`[user_id, start_at]`の複合インデックスを追加** - クエリパフォーマンスを最適化します。
|
|
161
|
+
|
|
162
|
+
3. **Eager Loadingには`includes`を使用** - `NOT EXISTS`パターンはRailsのEager Loadingと効率的に連携します。
|
|
163
|
+
|
|
164
|
+
4. **`[user_id, start_at]`にユニーク制約を追加することを検討** - 同時刻の重複レコードを防止します。
|