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/de/index.md
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# InTimeScope
|
|
2
|
+
|
|
3
|
+
Schreiben Sie das jedes Mal 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
|
+
Das war's. Eine Zeile DSL, kein rohes SQL in Ihren Models.
|
|
18
|
+
|
|
19
|
+
## Warum dieses Gem?
|
|
20
|
+
|
|
21
|
+
Dieses Gem existiert, um:
|
|
22
|
+
|
|
23
|
+
- **Zeitbereich-Logik konsistent zu halten** in Ihrer gesamten Codebasis
|
|
24
|
+
- **Copy-Paste SQL zu vermeiden**, das leicht falsch geschrieben wird
|
|
25
|
+
- **Zeit zu einem erstklassigen Domain-Konzept zu machen** mit benannten Scopes wie `in_time_published`
|
|
26
|
+
- **Nullfähigkeit automatisch zu erkennen** aus Ihrem Schema für optimierte Abfragen
|
|
27
|
+
|
|
28
|
+
## Empfohlen für
|
|
29
|
+
|
|
30
|
+
- Neue Rails-Anwendungen mit Gültigkeitszeiträumen
|
|
31
|
+
- Models mit `start_at` / `end_at` Spalten
|
|
32
|
+
- Teams, die konsistente Zeitlogik ohne verstreute `where`-Klauseln wollen
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
bundle add in_time_scope
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Schnellstart
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
class Event < ActiveRecord::Base
|
|
44
|
+
in_time_scope
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Klassen-Scope
|
|
48
|
+
Event.in_time # Aktuell aktive Datensätze
|
|
49
|
+
Event.in_time(Time.parse("2024-06-01")) # Zu einem bestimmten Zeitpunkt aktive Datensätze
|
|
50
|
+
|
|
51
|
+
# Instanz-Methode
|
|
52
|
+
event.in_time? # Ist dieser Datensatz jetzt aktiv?
|
|
53
|
+
event.in_time?(some_time) # War er zu diesem Zeitpunkt aktiv?
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Funktionen
|
|
57
|
+
|
|
58
|
+
### Auto-optimiertes SQL
|
|
59
|
+
|
|
60
|
+
Das Gem liest Ihr Schema und generiert das richtige SQL:
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
# NULL-erlaubte Spalten → NULL-bewusste Abfrage
|
|
64
|
+
WHERE (start_at IS NULL OR start_at <= ?) AND (end_at IS NULL OR end_at > ?)
|
|
65
|
+
|
|
66
|
+
# NOT NULL Spalten → einfache Abfrage
|
|
67
|
+
WHERE start_at <= ? AND end_at > ?
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Benannte Scopes
|
|
71
|
+
|
|
72
|
+
Mehrere Zeitfenster pro Model:
|
|
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
|
+
### Benutzerdefinierte Spalten
|
|
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
|
+
### Nur-Start-Muster (Versionshistorie)
|
|
91
|
+
|
|
92
|
+
Für Datensätze, bei denen jede Zeile bis zur nächsten gültig ist:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
class Price < ActiveRecord::Base
|
|
96
|
+
in_time_scope start_at: { null: false }, end_at: { column: nil }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Bonus: effizientes has_one mit NOT EXISTS
|
|
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) # Kein N+1, holt nur den neuesten pro Benutzer
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Nur-Ende-Muster (Ablauf)
|
|
108
|
+
|
|
109
|
+
Für Datensätze, die aktiv sind, bis sie ablaufen:
|
|
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
|
+
### Inverse Scopes
|
|
118
|
+
|
|
119
|
+
Datensätze außerhalb des Zeitfensters abfragen:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
# Noch nicht gestartete Datensätze (start_at > time)
|
|
123
|
+
Event.before_in_time
|
|
124
|
+
event.before_in_time?
|
|
125
|
+
|
|
126
|
+
# Bereits beendete Datensätze (end_at <= time)
|
|
127
|
+
Event.after_in_time
|
|
128
|
+
event.after_in_time?
|
|
129
|
+
|
|
130
|
+
# Datensätze außerhalb des Zeitfensters (vor ODER nach)
|
|
131
|
+
Event.out_of_time
|
|
132
|
+
event.out_of_time? # Logische Umkehrung von in_time?
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Funktioniert auch mit benannten Scopes:
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
Article.before_in_time_published # Noch nicht veröffentlicht
|
|
139
|
+
Article.after_in_time_published # Veröffentlichung beendet
|
|
140
|
+
Article.out_of_time_published # Derzeit nicht veröffentlicht
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Optionen-Referenz
|
|
144
|
+
|
|
145
|
+
| Option | Standard | Beschreibung | Beispiel |
|
|
146
|
+
| --- | --- | --- | --- |
|
|
147
|
+
| `scope_name` (1. Arg) | `:in_time` | Benannter Scope wie `in_time_published` | `in_time_scope :published` |
|
|
148
|
+
| `start_at: { column: }` | `:start_at` | Benutzerdefinierter Spaltenname, `nil` zum Deaktivieren | `start_at: { column: :available_at }` |
|
|
149
|
+
| `end_at: { column: }` | `:end_at` | Benutzerdefinierter Spaltenname, `nil` zum Deaktivieren | `end_at: { column: nil }` |
|
|
150
|
+
| `start_at: { null: }` | Auto-Erkennung | NULL-Behandlung erzwingen | `start_at: { null: false }` |
|
|
151
|
+
| `end_at: { null: }` | Auto-Erkennung | NULL-Behandlung erzwingen | `end_at: { null: true }` |
|
|
152
|
+
|
|
153
|
+
## Beispiele
|
|
154
|
+
|
|
155
|
+
- [Punktesystem mit Ablaufdatum](./point-system.md) - Vollständiges Zeitfenster-Muster
|
|
156
|
+
- [Benutzernamen-Historie](./user-name-history.md) - Nur-Start-Muster
|
|
157
|
+
|
|
158
|
+
## Danksagungen
|
|
159
|
+
|
|
160
|
+
Inspiriert von [onk/shibaraku](https://github.com/onk/shibaraku). Dieses Gem erweitert das Konzept mit:
|
|
161
|
+
|
|
162
|
+
- Schema-bewusste NULL-Behandlung für optimierte Abfragen
|
|
163
|
+
- Mehrere benannte Scopes pro Model
|
|
164
|
+
- Nur-Start / Nur-Ende Muster
|
|
165
|
+
- `latest_in_time` / `earliest_in_time` für effiziente `has_one` Assoziationen
|
|
166
|
+
- Inverse Scopes: `before_in_time`, `after_in_time`, `out_of_time`
|
|
167
|
+
|
|
168
|
+
## Entwicklung
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
# Abhängigkeiten installieren
|
|
172
|
+
bin/setup
|
|
173
|
+
|
|
174
|
+
# Tests ausführen
|
|
175
|
+
bundle exec rspec
|
|
176
|
+
|
|
177
|
+
# Linting ausführen
|
|
178
|
+
bundle exec rubocop
|
|
179
|
+
|
|
180
|
+
# CLAUDE.md generieren (für KI-Coding-Assistenten)
|
|
181
|
+
npx rulesync generate
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Dieses Projekt verwendet [rulesync](https://github.com/dyoshikawa/rulesync) zur Verwaltung von KI-Assistenten-Regeln. Bearbeiten Sie `.rulesync/rules/*.md` und führen Sie `npx rulesync generate` aus, um `CLAUDE.md` zu aktualisieren.
|
|
185
|
+
|
|
186
|
+
## Beitragen
|
|
187
|
+
|
|
188
|
+
Bug-Reports und Pull Requests sind willkommen auf [GitHub](https://github.com/kyohah/in_time_scope).
|
|
189
|
+
|
|
190
|
+
## Lizenz
|
|
191
|
+
|
|
192
|
+
MIT-Lizenz
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# Beispiel: Punktesystem mit Ablaufdatum
|
|
2
|
+
|
|
3
|
+
Dieses Beispiel zeigt, wie man ein Punktesystem mit Ablaufdaten unter Verwendung von `in_time_scope` implementiert. Punkte können vorab gewährt werden, um in der Zukunft aktiv zu werden, wodurch Cron-Jobs überflüssig werden.
|
|
4
|
+
|
|
5
|
+
Siehe auch: [spec/point_system_spec.rb](https://github.com/kyohah/in_time_scope/blob/main/spec/point_system_spec.rb)
|
|
6
|
+
|
|
7
|
+
## Anwendungsfall
|
|
8
|
+
|
|
9
|
+
- Benutzer sammeln Punkte mit Gültigkeitszeiträumen (Startdatum und Ablaufdatum)
|
|
10
|
+
- Punkte können vorab gewährt werden, um in der Zukunft aktiviert zu werden (z.B. monatliche Mitgliedschaftsboni)
|
|
11
|
+
- Berechnung gültiger Punkte zu jedem Zeitpunkt ohne Cron-Jobs
|
|
12
|
+
- Abfrage bevorstehender Punkte, abgelaufener Punkte usw.
|
|
13
|
+
|
|
14
|
+
## Keine Cron-Jobs erforderlich
|
|
15
|
+
|
|
16
|
+
**Das ist DIE Killer-Funktion.** Traditionelle Punktesysteme sind ein Albtraum aus geplanten Jobs:
|
|
17
|
+
|
|
18
|
+
### Die Cron-Hölle, die Sie kennen
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
# activate_points_job.rb - läuft jede 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 - läuft jede 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
|
+
# Und dann brauchen Sie:
|
|
40
|
+
# - Sidekiq / Delayed Job / Good Job
|
|
41
|
+
# - Redis (für Sidekiq)
|
|
42
|
+
# - Cron oder whenever gem
|
|
43
|
+
# - Monitoring für Job-Fehler
|
|
44
|
+
# - Retry-Logik für fehlgeschlagene Jobs
|
|
45
|
+
# - Sperrmechanismen zur Vermeidung von Doppelausführungen
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Der InTimeScope-Weg
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
# Das war's. Keine Jobs. Keine Status-Spalte. Keine Infrastruktur.
|
|
52
|
+
user.points.in_time.sum(:amount)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Eine Zeile. Null Infrastruktur. Immer genau.**
|
|
56
|
+
|
|
57
|
+
### Warum das funktioniert
|
|
58
|
+
|
|
59
|
+
Die Spalten `start_at` und `end_at` SIND der Status. Es gibt keine Notwendigkeit für eine `status`-Spalte, da der Zeitvergleich zur Abfragezeit erfolgt:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
# All das funktioniert ohne Hintergrundverarbeitung:
|
|
63
|
+
user.points.in_time # Aktuell gültig
|
|
64
|
+
user.points.in_time(1.month.from_now) # Nächsten Monat gültig
|
|
65
|
+
user.points.in_time(1.year.ago) # Waren letztes Jahr gültig (Audit!)
|
|
66
|
+
user.points.before_in_time # Ausstehend (noch nicht aktiv)
|
|
67
|
+
user.points.after_in_time # Abgelaufen
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Was Sie eliminieren
|
|
71
|
+
|
|
72
|
+
| Komponente | Cron-basiertes System | InTimeScope |
|
|
73
|
+
|-----------|------------------|-------------|
|
|
74
|
+
| Hintergrund-Job-Bibliothek | Erforderlich | **Nicht benötigt** |
|
|
75
|
+
| Redis/Datenbank für Jobs | Erforderlich | **Nicht benötigt** |
|
|
76
|
+
| Job-Scheduler (cron) | Erforderlich | **Nicht benötigt** |
|
|
77
|
+
| Status-Spalte | Erforderlich | **Nicht benötigt** |
|
|
78
|
+
| Migration zur Status-Aktualisierung | Erforderlich | **Nicht benötigt** |
|
|
79
|
+
| Monitoring für Job-Fehler | Erforderlich | **Nicht benötigt** |
|
|
80
|
+
| Retry-Logik | Erforderlich | **Nicht benötigt** |
|
|
81
|
+
| Race-Condition-Handling | Erforderlich | **Nicht benötigt** |
|
|
82
|
+
|
|
83
|
+
### Bonus: Zeitreisen kostenlos
|
|
84
|
+
|
|
85
|
+
Bei Cron-basierten Systemen erfordert die Beantwortung von "Wie viele Punkte hatte Benutzer X am 15. Januar?" komplexes Audit-Logging oder Event Sourcing.
|
|
86
|
+
|
|
87
|
+
Mit InTimeScope:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
user.points.in_time(Date.parse("2024-01-15").middle_of_day).sum(:amount)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**Historische Abfragen funktionieren einfach.** Keine zusätzlichen Tabellen. Kein Event Sourcing. Keine Komplexität.
|
|
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 # Wann Punkte nutzbar werden
|
|
106
|
+
t.datetime :end_at, null: false # Wann Punkte ablaufen
|
|
107
|
+
t.timestamps
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
add_index :points, [:user_id, :start_at, :end_at]
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Modelle
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
class Point < ApplicationRecord
|
|
119
|
+
belongs_to :user
|
|
120
|
+
|
|
121
|
+
# Sowohl start_at als auch end_at sind erforderlich (vollständiges Zeitfenster)
|
|
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
|
+
# Monatliche Bonuspunkte gewähren (vorausgeplant)
|
|
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, # Wird nächsten Monat aktiviert
|
|
135
|
+
end_at: (1 + months_valid).months.from_now
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Die Kraft von `has_many :in_time_points`
|
|
142
|
+
|
|
143
|
+
Diese einfache Zeile ermöglicht **N+1-freies Eager Loading** für gültige Punkte:
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
# 100 Benutzer mit ihren gültigen Punkten in nur 2 Abfragen laden
|
|
147
|
+
users = User.includes(:in_time_points).limit(100)
|
|
148
|
+
|
|
149
|
+
users.each do |user|
|
|
150
|
+
# Keine zusätzlichen Abfragen! Bereits geladen.
|
|
151
|
+
total = user.in_time_points.sum(&:amount)
|
|
152
|
+
puts "#{user.name}: #{total} points"
|
|
153
|
+
end
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Ohne diese Assoziation bräuchten Sie:
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
# N+1-Problem: 1 Abfrage für Benutzer + 100 Abfragen für Punkte
|
|
160
|
+
users = User.limit(100)
|
|
161
|
+
users.each do |user|
|
|
162
|
+
total = user.points.in_time.sum(:amount) # Abfrage pro Benutzer!
|
|
163
|
+
end
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Verwendung
|
|
167
|
+
|
|
168
|
+
### Punkte mit verschiedenen Gültigkeitszeiträumen gewähren
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
user = User.find(1)
|
|
172
|
+
|
|
173
|
+
# Sofortige Punkte (1 Jahr gültig)
|
|
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
|
+
# Vorausgeplante Punkte für 6-Monats-Mitglieder
|
|
182
|
+
# Punkte werden nächsten Monat aktiviert, 6 Monate nach Aktivierung gültig
|
|
183
|
+
user.grant_monthly_bonus(amount: 500, months_valid: 6)
|
|
184
|
+
|
|
185
|
+
# Kampagnenpunkte (zeitlich begrenzt)
|
|
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
|
+
### Punkte abfragen
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
# Aktuell gültige Punkte
|
|
198
|
+
user.in_time_member_points.sum(:amount)
|
|
199
|
+
# => 100 (nur der Willkommensbonus ist aktuell aktiv)
|
|
200
|
+
|
|
201
|
+
# Prüfen, wie viele Punkte nächsten Monat verfügbar sein werden
|
|
202
|
+
user.in_time_member_points(1.month.from_now).sum(:amount)
|
|
203
|
+
# => 600 (Willkommensbonus + monatlicher Bonus)
|
|
204
|
+
|
|
205
|
+
# Ausstehende Punkte (geplant aber noch nicht aktiv)
|
|
206
|
+
user.points.before_in_time.sum(:amount)
|
|
207
|
+
# => 500 (monatlicher Bonus wartet auf Aktivierung)
|
|
208
|
+
|
|
209
|
+
# Abgelaufene Punkte
|
|
210
|
+
user.points.after_in_time.sum(:amount)
|
|
211
|
+
|
|
212
|
+
# Alle ungültigen Punkte (ausstehend + abgelaufen)
|
|
213
|
+
user.points.out_of_time.sum(:amount)
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Admin-Dashboard-Abfragen
|
|
217
|
+
|
|
218
|
+
```ruby
|
|
219
|
+
# Historisches Audit: Punkte gültig an einem bestimmten Datum
|
|
220
|
+
Point.in_time(Date.parse("2024-01-15").middle_of_day)
|
|
221
|
+
.group(:user_id)
|
|
222
|
+
.sum(:amount)
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## Automatischer Mitgliedschaftsbonus-Ablauf
|
|
226
|
+
|
|
227
|
+
Für 6-Monats-Premium-Mitglieder können Sie wiederkehrende Boni einrichten **ohne Cron, ohne Sidekiq, ohne Redis, ohne Monitoring**:
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
# Wenn sich ein Benutzer für Premium anmeldet, Mitgliedschaft und alle Boni atomar erstellen
|
|
231
|
+
ActiveRecord::Base.transaction do
|
|
232
|
+
membership = Membership.create!(user: user, plan: "premium_6_months")
|
|
233
|
+
|
|
234
|
+
# Alle 6 monatlichen Boni bei der Anmeldung vorab erstellen
|
|
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 # Jeder Bonus 6 Monate gültig
|
|
241
|
+
)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
# => Erstellt Mitgliedschaft + 6 Punktedatensätze, die monatlich aktiviert werden
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
## Warum dieses Design überlegen ist
|
|
248
|
+
|
|
249
|
+
### Korrektheit
|
|
250
|
+
|
|
251
|
+
- **Keine Race Conditions**: Cron-Jobs können zweimal laufen, Ausführungen überspringen oder sich überlappen. InTimeScope-Abfragen sind immer deterministisch.
|
|
252
|
+
- **Kein Timing-Drift**: Cron läuft in Intervallen (jede Minute? alle 5 Minuten?). InTimeScope ist millisekundengenau.
|
|
253
|
+
- **Keine verlorenen Updates**: Job-Fehler können Punkte in falschen Zuständen hinterlassen. InTimeScope hat keinen Zustand, der beschädigt werden kann.
|
|
254
|
+
|
|
255
|
+
### Einfachheit
|
|
256
|
+
|
|
257
|
+
- **Keine Infrastruktur**: Löschen Sie Sidekiq. Löschen Sie Redis. Löschen Sie das Job-Monitoring.
|
|
258
|
+
- **Keine Migrationen für Status-Änderungen**: Die Zeit IST der Status. Keine `UPDATE`-Anweisungen nötig.
|
|
259
|
+
- **Kein Debugging von Job-Logs**: Fragen Sie einfach die Datenbank ab, um genau zu sehen, was passiert.
|
|
260
|
+
|
|
261
|
+
### Testbarkeit
|
|
262
|
+
|
|
263
|
+
```ruby
|
|
264
|
+
# Cron-basiertes Testen ist mühsam:
|
|
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-Tests sind trivial:
|
|
272
|
+
expect(user.points.in_time(1.month.from_now).sum(:amount)).to eq(500)
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### Zusammenfassung
|
|
276
|
+
|
|
277
|
+
| Aspekt | Cron-basiert | InTimeScope |
|
|
278
|
+
|--------|-----------|-------------|
|
|
279
|
+
| Infrastruktur | Sidekiq + Redis + Cron | **Keine** |
|
|
280
|
+
| Punkteaktivierung | Batch-Job (verzögert) | **Sofort** |
|
|
281
|
+
| Historische Abfragen | Unmöglich ohne Audit-Log | **Eingebaut** |
|
|
282
|
+
| Timing-Genauigkeit | Minuten (Cron-Intervall) | **Millisekunden** |
|
|
283
|
+
| Debugging | Job-Logs + Datenbank | **Nur Datenbank** |
|
|
284
|
+
| Testen | Zeitreisen + Jobs ausführen | **Nur Abfrage** |
|
|
285
|
+
| Fehlermodi | Viele (Job-Fehler, Race Conditions) | **Keine** |
|
|
286
|
+
|
|
287
|
+
## Tipps
|
|
288
|
+
|
|
289
|
+
1. **Verwenden Sie Datenbankindizes** auf `[user_id, start_at, end_at]` für optimale Performance.
|
|
290
|
+
|
|
291
|
+
2. **Gewähren Sie Punkte vorab bei der Anmeldung** anstatt Cron-Jobs zu planen.
|
|
292
|
+
|
|
293
|
+
3. **Verwenden Sie `in_time(time)` für Audits**, um Punktestände zu jedem historischen Zeitpunkt zu prüfen.
|
|
294
|
+
|
|
295
|
+
4. **Kombinieren Sie mit inversen Scopes**, um Admin-Dashboards mit ausstehenden/abgelaufenen Punkten zu erstellen.
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# Beispiel: Benutzernamen-Historie
|
|
2
|
+
|
|
3
|
+
Dieses Beispiel zeigt, wie man die Benutzernamen-Historie mit `in_time_scope` verwaltet, sodass Sie den Namen eines Benutzers zu jedem Zeitpunkt abfragen können.
|
|
4
|
+
|
|
5
|
+
Siehe auch: [spec/user_name_history_spec.rb](https://github.com/kyohah/in_time_scope/blob/main/spec/user_name_history_spec.rb)
|
|
6
|
+
|
|
7
|
+
## Anwendungsfall
|
|
8
|
+
|
|
9
|
+
- Benutzer können ihren Anzeigenamen ändern
|
|
10
|
+
- Sie müssen eine Historie aller Namensänderungen aufbewahren
|
|
11
|
+
- Sie möchten den Namen abrufen, der zu einem bestimmten Zeitpunkt aktiv war (z.B. für Audit-Logs, historische Berichte)
|
|
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 # Wann dieser Name aktiv wurde
|
|
28
|
+
t.timestamps
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
add_index :user_name_histories, [:user_id, :start_at]
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Modelle
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
class UserNameHistory < ApplicationRecord
|
|
40
|
+
belongs_to :user
|
|
41
|
+
include InTimeScope
|
|
42
|
+
|
|
43
|
+
# Nur-Start-Muster: Jeder Datensatz ist von start_at bis zum nächsten Datensatz gültig
|
|
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
|
+
# Den aktuellen Namen abrufen (neuester Datensatz, der begonnen hat)
|
|
51
|
+
has_one :current_name_history,
|
|
52
|
+
-> { latest_in_time(:user_id) },
|
|
53
|
+
class_name: "UserNameHistory"
|
|
54
|
+
|
|
55
|
+
# Praktische Methode für den aktuellen Namen
|
|
56
|
+
def current_name
|
|
57
|
+
current_name_history&.name
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Namen zu einem bestimmten Zeitpunkt abrufen
|
|
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
|
+
## Verwendung
|
|
68
|
+
|
|
69
|
+
### Namenshistorie erstellen
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
user = User.create!(email: "alice@example.com")
|
|
73
|
+
|
|
74
|
+
# Anfänglicher Name
|
|
75
|
+
UserNameHistory.create!(
|
|
76
|
+
user: user,
|
|
77
|
+
name: "Alice",
|
|
78
|
+
start_at: Time.parse("2024-01-01")
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Namensänderung
|
|
82
|
+
UserNameHistory.create!(
|
|
83
|
+
user: user,
|
|
84
|
+
name: "Alice Smith",
|
|
85
|
+
start_at: Time.parse("2024-06-01")
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Weitere Namensänderung
|
|
89
|
+
UserNameHistory.create!(
|
|
90
|
+
user: user,
|
|
91
|
+
name: "Alice Johnson",
|
|
92
|
+
start_at: Time.parse("2024-09-01")
|
|
93
|
+
)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Namen abfragen
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
# Aktueller Name (verwendet has_one mit latest_in_time)
|
|
100
|
+
user.current_name
|
|
101
|
+
# => "Alice Johnson"
|
|
102
|
+
|
|
103
|
+
# Name zu einem bestimmten Zeitpunkt
|
|
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
|
+
### Effizientes Eager Loading
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
# Benutzer mit ihren aktuellen Namen laden (kein 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
|
+
### Aktive Datensätze abfragen
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
# Alle Namensdatensätze, die aktuell aktiv sind
|
|
129
|
+
UserNameHistory.in_time
|
|
130
|
+
# => Gibt den neuesten Namensdatensatz für jeden Benutzer zurück
|
|
131
|
+
|
|
132
|
+
# Namensdatensätze, die zu einem bestimmten Zeitpunkt aktiv waren
|
|
133
|
+
UserNameHistory.in_time(Time.parse("2024-05-01"))
|
|
134
|
+
|
|
135
|
+
# Namensdatensätze, die noch nicht begonnen haben (für die Zukunft geplant)
|
|
136
|
+
UserNameHistory.before_in_time
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Wie `latest_in_time` funktioniert
|
|
140
|
+
|
|
141
|
+
Der `latest_in_time(:user_id)` Scope generiert eine effiziente `NOT EXISTS` Unterabfrage:
|
|
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
|
+
Dies gibt nur den neuesten Datensatz pro Benutzer zurück, der zum gegebenen Zeitpunkt aktiv war, was es perfekt für `has_one` Assoziationen macht.
|
|
155
|
+
|
|
156
|
+
## Tipps
|
|
157
|
+
|
|
158
|
+
1. **Verwenden Sie immer `latest_in_time` mit `has_one`** - Es stellt sicher, dass Sie genau einen Datensatz pro Fremdschlüssel erhalten.
|
|
159
|
+
|
|
160
|
+
2. **Fügen Sie einen zusammengesetzten Index hinzu** auf `[user_id, start_at]` für optimale Abfrageleistung.
|
|
161
|
+
|
|
162
|
+
3. **Verwenden Sie `includes` für Eager Loading** - Das `NOT EXISTS`-Muster funktioniert effizient mit Rails Eager Loading.
|
|
163
|
+
|
|
164
|
+
4. **Erwägen Sie das Hinzufügen einer Unique-Constraint** auf `[user_id, start_at]`, um doppelte Datensätze zum gleichen Zeitpunkt zu verhindern.
|