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/fr/index.md
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# InTimeScope
|
|
2
|
+
|
|
3
|
+
Vous écrivez ceci à chaque fois dans 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
|
+
C'est tout. Une ligne de DSL, zéro SQL brut dans vos modèles.
|
|
18
|
+
|
|
19
|
+
## Pourquoi ce Gem ?
|
|
20
|
+
|
|
21
|
+
Ce gem existe pour :
|
|
22
|
+
|
|
23
|
+
- **Maintenir une logique de plage temporelle cohérente** dans tout votre codebase
|
|
24
|
+
- **Éviter le copier-coller de SQL** facile à mal écrire
|
|
25
|
+
- **Faire du temps un concept de domaine de première classe** avec des scopes nommés comme `in_time_published`
|
|
26
|
+
- **Détecter automatiquement la nullabilité** depuis votre schéma pour des requêtes optimisées
|
|
27
|
+
|
|
28
|
+
## Recommandé pour
|
|
29
|
+
|
|
30
|
+
- Les nouvelles applications Rails avec des périodes de validité
|
|
31
|
+
- Les modèles avec des colonnes `start_at` / `end_at`
|
|
32
|
+
- Les équipes qui veulent une logique temporelle cohérente sans clauses `where` dispersées
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
bundle add in_time_scope
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Démarrage rapide
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
class Event < ActiveRecord::Base
|
|
44
|
+
in_time_scope
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Scope de classe
|
|
48
|
+
Event.in_time # Enregistrements actifs maintenant
|
|
49
|
+
Event.in_time(Time.parse("2024-06-01")) # Enregistrements actifs à un moment précis
|
|
50
|
+
|
|
51
|
+
# Méthode d'instance
|
|
52
|
+
event.in_time? # Cet enregistrement est-il actif maintenant ?
|
|
53
|
+
event.in_time?(some_time) # Était-il actif à ce moment-là ?
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Fonctionnalités
|
|
57
|
+
|
|
58
|
+
### SQL auto-optimisé
|
|
59
|
+
|
|
60
|
+
Le gem lit votre schéma et génère le bon SQL :
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
# Colonnes autorisant NULL → requête NULL-aware
|
|
64
|
+
WHERE (start_at IS NULL OR start_at <= ?) AND (end_at IS NULL OR end_at > ?)
|
|
65
|
+
|
|
66
|
+
# Colonnes NOT NULL → requête simple
|
|
67
|
+
WHERE start_at <= ? AND end_at > ?
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Scopes nommés
|
|
71
|
+
|
|
72
|
+
Plusieurs fenêtres temporelles par modèle :
|
|
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
|
+
### Colonnes personnalisées
|
|
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
|
+
### Modèle début uniquement (historique de versions)
|
|
91
|
+
|
|
92
|
+
Pour les enregistrements où chaque ligne est valide jusqu'à la suivante :
|
|
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 : has_one efficace avec 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) # Pas de N+1, récupère uniquement le plus récent par utilisateur
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Modèle fin uniquement (expiration)
|
|
108
|
+
|
|
109
|
+
Pour les enregistrements actifs jusqu'à leur expiration :
|
|
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
|
+
### Scopes inversés
|
|
118
|
+
|
|
119
|
+
Requêter les enregistrements en dehors de la fenêtre temporelle :
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
# Enregistrements pas encore commencés (start_at > time)
|
|
123
|
+
Event.before_in_time
|
|
124
|
+
event.before_in_time?
|
|
125
|
+
|
|
126
|
+
# Enregistrements déjà terminés (end_at <= time)
|
|
127
|
+
Event.after_in_time
|
|
128
|
+
event.after_in_time?
|
|
129
|
+
|
|
130
|
+
# Enregistrements en dehors de la fenêtre temporelle (avant OU après)
|
|
131
|
+
Event.out_of_time
|
|
132
|
+
event.out_of_time? # Inverse logique de in_time?
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Fonctionne aussi avec les scopes nommés :
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
Article.before_in_time_published # Pas encore publié
|
|
139
|
+
Article.after_in_time_published # Publication terminée
|
|
140
|
+
Article.out_of_time_published # Non publié actuellement
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Référence des options
|
|
144
|
+
|
|
145
|
+
| Option | Défaut | Description | Exemple |
|
|
146
|
+
| --- | --- | --- | --- |
|
|
147
|
+
| `scope_name` (1er arg) | `:in_time` | Scope nommé comme `in_time_published` | `in_time_scope :published` |
|
|
148
|
+
| `start_at: { column: }` | `:start_at` | Nom de colonne personnalisé, `nil` pour désactiver | `start_at: { column: :available_at }` |
|
|
149
|
+
| `end_at: { column: }` | `:end_at` | Nom de colonne personnalisé, `nil` pour désactiver | `end_at: { column: nil }` |
|
|
150
|
+
| `start_at: { null: }` | auto-détection | Forcer la gestion des NULL | `start_at: { null: false }` |
|
|
151
|
+
| `end_at: { null: }` | auto-détection | Forcer la gestion des NULL | `end_at: { null: true }` |
|
|
152
|
+
|
|
153
|
+
## Exemples
|
|
154
|
+
|
|
155
|
+
- [Système de points avec expiration](./point-system.md) - Modèle fenêtre temporelle complète
|
|
156
|
+
- [Historique des noms d'utilisateur](./user-name-history.md) - Modèle début uniquement
|
|
157
|
+
|
|
158
|
+
## Remerciements
|
|
159
|
+
|
|
160
|
+
Inspiré par [onk/shibaraku](https://github.com/onk/shibaraku). Ce gem étend le concept avec :
|
|
161
|
+
|
|
162
|
+
- Gestion NULL sensible au schéma pour des requêtes optimisées
|
|
163
|
+
- Plusieurs scopes nommés par modèle
|
|
164
|
+
- Modèles début uniquement / fin uniquement
|
|
165
|
+
- `latest_in_time` / `earliest_in_time` pour des associations `has_one` efficaces
|
|
166
|
+
- Scopes inversés : `before_in_time`, `after_in_time`, `out_of_time`
|
|
167
|
+
|
|
168
|
+
## Développement
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
# Installer les dépendances
|
|
172
|
+
bin/setup
|
|
173
|
+
|
|
174
|
+
# Exécuter les tests
|
|
175
|
+
bundle exec rspec
|
|
176
|
+
|
|
177
|
+
# Exécuter le linting
|
|
178
|
+
bundle exec rubocop
|
|
179
|
+
|
|
180
|
+
# Générer CLAUDE.md (pour les assistants de codage IA)
|
|
181
|
+
npx rulesync generate
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Ce projet utilise [rulesync](https://github.com/dyoshikawa/rulesync) pour gérer les règles des assistants IA. Éditez `.rulesync/rules/*.md` et exécutez `npx rulesync generate` pour mettre à jour `CLAUDE.md`.
|
|
185
|
+
|
|
186
|
+
## Contribuer
|
|
187
|
+
|
|
188
|
+
Les rapports de bugs et les pull requests sont les bienvenus sur [GitHub](https://github.com/kyohah/in_time_scope).
|
|
189
|
+
|
|
190
|
+
## Licence
|
|
191
|
+
|
|
192
|
+
Licence MIT
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# Exemple de système de points avec expiration
|
|
2
|
+
|
|
3
|
+
Cet exemple montre comment implémenter un système de points avec dates d'expiration en utilisant `in_time_scope`. Les points peuvent être pré-accordés pour devenir actifs dans le futur, éliminant le besoin de jobs cron.
|
|
4
|
+
|
|
5
|
+
Voir aussi : [spec/point_system_spec.rb](https://github.com/kyohah/in_time_scope/blob/main/spec/point_system_spec.rb)
|
|
6
|
+
|
|
7
|
+
## Cas d'utilisation
|
|
8
|
+
|
|
9
|
+
- Les utilisateurs gagnent des points avec des périodes de validité (date de début et date d'expiration)
|
|
10
|
+
- Les points peuvent être pré-accordés pour s'activer dans le futur (ex : bonus mensuels d'adhésion)
|
|
11
|
+
- Calculer les points valides à tout moment sans jobs cron
|
|
12
|
+
- Requêter les points à venir, expirés, etc.
|
|
13
|
+
|
|
14
|
+
## Aucun job Cron requis
|
|
15
|
+
|
|
16
|
+
**C'est LA fonctionnalité phare.** Les systèmes de points traditionnels sont un cauchemar de jobs planifiés :
|
|
17
|
+
|
|
18
|
+
### L'enfer Cron auquel vous êtes habitué
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
# activate_points_job.rb - s'exécute chaque 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 - s'exécute chaque 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
|
+
# Et ensuite vous avez besoin de :
|
|
40
|
+
# - Sidekiq / Delayed Job / Good Job
|
|
41
|
+
# - Redis (pour Sidekiq)
|
|
42
|
+
# - Cron ou whenever gem
|
|
43
|
+
# - Monitoring des échecs de jobs
|
|
44
|
+
# - Logique de retry pour les jobs échoués
|
|
45
|
+
# - Mécanismes de verrouillage pour éviter les exécutions en double
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### La méthode InTimeScope
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
# C'est tout. Pas de jobs. Pas de colonne status. Pas d'infrastructure.
|
|
52
|
+
user.points.in_time.sum(:amount)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Une ligne. Zéro infrastructure. Toujours précis.**
|
|
56
|
+
|
|
57
|
+
### Pourquoi ça fonctionne
|
|
58
|
+
|
|
59
|
+
Les colonnes `start_at` et `end_at` SONT l'état. Pas besoin de colonne `status` car la comparaison temporelle se fait au moment de la requête :
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
# Tout cela fonctionne sans traitement en arrière-plan :
|
|
63
|
+
user.points.in_time # Actuellement valides
|
|
64
|
+
user.points.in_time(1.month.from_now) # Valides le mois prochain
|
|
65
|
+
user.points.in_time(1.year.ago) # Étaient valides l'année dernière (audit !)
|
|
66
|
+
user.points.before_in_time # En attente (pas encore actifs)
|
|
67
|
+
user.points.after_in_time # Expirés
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Ce que vous éliminez
|
|
71
|
+
|
|
72
|
+
| Composant | Système basé sur Cron | InTimeScope |
|
|
73
|
+
|-----------|------------------|-------------|
|
|
74
|
+
| Bibliothèque de jobs en arrière-plan | Requis | **Non nécessaire** |
|
|
75
|
+
| Redis/base de données pour les jobs | Requis | **Non nécessaire** |
|
|
76
|
+
| Planificateur de jobs (cron) | Requis | **Non nécessaire** |
|
|
77
|
+
| Colonne status | Requis | **Non nécessaire** |
|
|
78
|
+
| Migration pour mettre à jour le status | Requis | **Non nécessaire** |
|
|
79
|
+
| Monitoring des échecs de jobs | Requis | **Non nécessaire** |
|
|
80
|
+
| Logique de retry | Requis | **Non nécessaire** |
|
|
81
|
+
| Gestion des conditions de concurrence | Requis | **Non nécessaire** |
|
|
82
|
+
|
|
83
|
+
### Bonus : voyage dans le temps gratuit
|
|
84
|
+
|
|
85
|
+
Avec les systèmes basés sur cron, répondre à "Combien de points l'utilisateur X avait-il le 15 janvier ?" nécessite une journalisation d'audit complexe ou du event sourcing.
|
|
86
|
+
|
|
87
|
+
Avec InTimeScope :
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
user.points.in_time(Date.parse("2024-01-15").middle_of_day).sum(:amount)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**Les requêtes historiques fonctionnent directement.** Pas de tables supplémentaires. Pas d'event sourcing. Pas de complexité.
|
|
94
|
+
|
|
95
|
+
## Schéma
|
|
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 # Quand les points deviennent utilisables
|
|
106
|
+
t.datetime :end_at, null: false # Quand les points expirent
|
|
107
|
+
t.timestamps
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
add_index :points, [:user_id, :start_at, :end_at]
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Modèles
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
class Point < ApplicationRecord
|
|
119
|
+
belongs_to :user
|
|
120
|
+
|
|
121
|
+
# start_at et end_at sont tous deux requis (fenêtre temporelle complète)
|
|
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
|
+
# Accorder des points bonus mensuels (pré-planifiés)
|
|
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, # S'active le mois prochain
|
|
135
|
+
end_at: (1 + months_valid).months.from_now
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### La puissance de `has_many :in_time_points`
|
|
142
|
+
|
|
143
|
+
Cette simple ligne débloque le **chargement anticipé sans N+1** pour les points valides :
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
# Charger 100 utilisateurs avec leurs points valides en seulement 2 requêtes
|
|
147
|
+
users = User.includes(:in_time_points).limit(100)
|
|
148
|
+
|
|
149
|
+
users.each do |user|
|
|
150
|
+
# Pas de requêtes supplémentaires ! Déjà chargé.
|
|
151
|
+
total = user.in_time_points.sum(&:amount)
|
|
152
|
+
puts "#{user.name}: #{total} points"
|
|
153
|
+
end
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Sans cette association, vous auriez besoin de :
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
# Problème N+1 : 1 requête pour les utilisateurs + 100 requêtes pour les points
|
|
160
|
+
users = User.limit(100)
|
|
161
|
+
users.each do |user|
|
|
162
|
+
total = user.points.in_time.sum(:amount) # Requête par utilisateur !
|
|
163
|
+
end
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Utilisation
|
|
167
|
+
|
|
168
|
+
### Accorder des points avec différentes périodes de validité
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
user = User.find(1)
|
|
172
|
+
|
|
173
|
+
# Points immédiats (valides 1 an)
|
|
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
|
+
# Points pré-planifiés pour les membres 6 mois
|
|
182
|
+
# Les points s'activent le mois prochain, valides 6 mois après activation
|
|
183
|
+
user.grant_monthly_bonus(amount: 500, months_valid: 6)
|
|
184
|
+
|
|
185
|
+
# Points de campagne (durée limitée)
|
|
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
|
+
### Requêter les points
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
# Points valides actuellement
|
|
198
|
+
user.in_time_member_points.sum(:amount)
|
|
199
|
+
# => 100 (seul le bonus de bienvenue est actuellement actif)
|
|
200
|
+
|
|
201
|
+
# Vérifier combien de points seront disponibles le mois prochain
|
|
202
|
+
user.in_time_member_points(1.month.from_now).sum(:amount)
|
|
203
|
+
# => 600 (bonus de bienvenue + bonus mensuel)
|
|
204
|
+
|
|
205
|
+
# Points en attente (planifiés mais pas encore actifs)
|
|
206
|
+
user.points.before_in_time.sum(:amount)
|
|
207
|
+
# => 500 (bonus mensuel en attente d'activation)
|
|
208
|
+
|
|
209
|
+
# Points expirés
|
|
210
|
+
user.points.after_in_time.sum(:amount)
|
|
211
|
+
|
|
212
|
+
# Tous les points invalides (en attente + expirés)
|
|
213
|
+
user.points.out_of_time.sum(:amount)
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Requêtes pour tableau de bord admin
|
|
217
|
+
|
|
218
|
+
```ruby
|
|
219
|
+
# Audit historique : points valides à une date spécifique
|
|
220
|
+
Point.in_time(Date.parse("2024-01-15").middle_of_day)
|
|
221
|
+
.group(:user_id)
|
|
222
|
+
.sum(:amount)
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## Flux de bonus d'adhésion automatique
|
|
226
|
+
|
|
227
|
+
Pour les membres premium 6 mois, vous pouvez configurer des bonus récurrents **sans cron, sans Sidekiq, sans Redis, sans monitoring** :
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
# Quand l'utilisateur s'inscrit en premium, créer l'adhésion et tous les bonus de façon atomique
|
|
231
|
+
ActiveRecord::Base.transaction do
|
|
232
|
+
membership = Membership.create!(user: user, plan: "premium_6_months")
|
|
233
|
+
|
|
234
|
+
# Pré-créer les 6 bonus mensuels à l'inscription
|
|
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 # Chaque bonus valide 6 mois
|
|
241
|
+
)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
# => Crée l'adhésion + 6 enregistrements de points qui s'activeront mensuellement
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
## Pourquoi cette conception est supérieure
|
|
248
|
+
|
|
249
|
+
### Exactitude
|
|
250
|
+
|
|
251
|
+
- **Pas de conditions de concurrence** : Les jobs cron peuvent s'exécuter deux fois, sauter des exécutions ou se chevaucher. Les requêtes InTimeScope sont toujours déterministes.
|
|
252
|
+
- **Pas de dérive temporelle** : Cron s'exécute à intervalles (chaque minute ? toutes les 5 minutes ?). InTimeScope est précis à la milliseconde.
|
|
253
|
+
- **Pas de mises à jour perdues** : Les échecs de jobs peuvent laisser les points dans des états incorrects. InTimeScope n'a pas d'état à corrompre.
|
|
254
|
+
|
|
255
|
+
### Simplicité
|
|
256
|
+
|
|
257
|
+
- **Pas d'infrastructure** : Supprimez Sidekiq. Supprimez Redis. Supprimez le monitoring des jobs.
|
|
258
|
+
- **Pas de migrations pour les changements de status** : Le temps EST le status. Pas besoin d'instructions `UPDATE`.
|
|
259
|
+
- **Pas de débogage des logs de jobs** : Interrogez simplement la base de données pour voir exactement ce qui se passe.
|
|
260
|
+
|
|
261
|
+
### Testabilité
|
|
262
|
+
|
|
263
|
+
```ruby
|
|
264
|
+
# Les tests basés sur cron sont pénibles :
|
|
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
|
+
# Les tests InTimeScope sont triviaux :
|
|
272
|
+
expect(user.points.in_time(1.month.from_now).sum(:amount)).to eq(500)
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### Résumé
|
|
276
|
+
|
|
277
|
+
| Aspect | Basé sur Cron | InTimeScope |
|
|
278
|
+
|--------|-----------|-------------|
|
|
279
|
+
| Infrastructure | Sidekiq + Redis + Cron | **Aucune** |
|
|
280
|
+
| Activation des points | Job batch (différé) | **Instantané** |
|
|
281
|
+
| Requêtes historiques | Impossible sans log d'audit | **Intégré** |
|
|
282
|
+
| Précision temporelle | Minutes (intervalle cron) | **Millisecondes** |
|
|
283
|
+
| Débogage | Logs de jobs + base de données | **Base de données uniquement** |
|
|
284
|
+
| Tests | Voyage dans le temps + exécuter les jobs | **Juste une requête** |
|
|
285
|
+
| Modes d'échec | Nombreux (échecs de jobs, conditions de concurrence) | **Aucun** |
|
|
286
|
+
|
|
287
|
+
## Conseils
|
|
288
|
+
|
|
289
|
+
1. **Utilisez des index de base de données** sur `[user_id, start_at, end_at]` pour des performances optimales.
|
|
290
|
+
|
|
291
|
+
2. **Pré-accordez les points à l'inscription** au lieu de planifier des jobs cron.
|
|
292
|
+
|
|
293
|
+
3. **Utilisez `in_time(time)` pour les audits** pour vérifier les soldes de points à n'importe quel moment historique.
|
|
294
|
+
|
|
295
|
+
4. **Combinez avec les scopes inversés** pour construire des tableaux de bord admin affichant les points en attente/expirés.
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# Exemple d'historique des noms d'utilisateur
|
|
2
|
+
|
|
3
|
+
Cet exemple montre comment gérer l'historique des noms d'utilisateur avec `in_time_scope`, vous permettant de requêter le nom d'un utilisateur à n'importe quel moment.
|
|
4
|
+
|
|
5
|
+
Voir aussi : [spec/user_name_history_spec.rb](https://github.com/kyohah/in_time_scope/blob/main/spec/user_name_history_spec.rb)
|
|
6
|
+
|
|
7
|
+
## Cas d'utilisation
|
|
8
|
+
|
|
9
|
+
- Les utilisateurs peuvent changer leur nom d'affichage
|
|
10
|
+
- Vous devez conserver un historique de tous les changements de nom
|
|
11
|
+
- Vous voulez récupérer le nom qui était actif à un moment spécifique (ex : pour les logs d'audit, rapports historiques)
|
|
12
|
+
|
|
13
|
+
## Schéma
|
|
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 # Quand ce nom est devenu actif
|
|
28
|
+
t.timestamps
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
add_index :user_name_histories, [:user_id, :start_at]
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Modèles
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
class UserNameHistory < ApplicationRecord
|
|
40
|
+
belongs_to :user
|
|
41
|
+
include InTimeScope
|
|
42
|
+
|
|
43
|
+
# Modèle début uniquement : chaque enregistrement est valide de start_at jusqu'au suivant
|
|
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
|
+
# Obtenir le nom actuel (dernier enregistrement qui a commencé)
|
|
51
|
+
has_one :current_name_history,
|
|
52
|
+
-> { latest_in_time(:user_id) },
|
|
53
|
+
class_name: "UserNameHistory"
|
|
54
|
+
|
|
55
|
+
# Méthode pratique pour le nom actuel
|
|
56
|
+
def current_name
|
|
57
|
+
current_name_history&.name
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Obtenir le nom à un moment spécifique
|
|
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
|
+
## Utilisation
|
|
68
|
+
|
|
69
|
+
### Créer l'historique des noms
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
user = User.create!(email: "alice@example.com")
|
|
73
|
+
|
|
74
|
+
# Nom initial
|
|
75
|
+
UserNameHistory.create!(
|
|
76
|
+
user: user,
|
|
77
|
+
name: "Alice",
|
|
78
|
+
start_at: Time.parse("2024-01-01")
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Changement de nom
|
|
82
|
+
UserNameHistory.create!(
|
|
83
|
+
user: user,
|
|
84
|
+
name: "Alice Smith",
|
|
85
|
+
start_at: Time.parse("2024-06-01")
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Autre changement de nom
|
|
89
|
+
UserNameHistory.create!(
|
|
90
|
+
user: user,
|
|
91
|
+
name: "Alice Johnson",
|
|
92
|
+
start_at: Time.parse("2024-09-01")
|
|
93
|
+
)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Requêter les noms
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
# Nom actuel (utilise has_one avec latest_in_time)
|
|
100
|
+
user.current_name
|
|
101
|
+
# => "Alice Johnson"
|
|
102
|
+
|
|
103
|
+
# Nom à un moment spécifique
|
|
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
|
+
### Chargement anticipé efficace
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
# Charger les utilisateurs avec leurs noms actuels (pas de 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
|
+
### Requêter les enregistrements actifs
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
# Tous les enregistrements de noms actuellement actifs
|
|
129
|
+
UserNameHistory.in_time
|
|
130
|
+
# => Retourne le dernier enregistrement de nom pour chaque utilisateur
|
|
131
|
+
|
|
132
|
+
# Enregistrements de noms qui étaient actifs à un moment spécifique
|
|
133
|
+
UserNameHistory.in_time(Time.parse("2024-05-01"))
|
|
134
|
+
|
|
135
|
+
# Enregistrements de noms pas encore commencés (planifiés pour le futur)
|
|
136
|
+
UserNameHistory.before_in_time
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Comment fonctionne `latest_in_time`
|
|
140
|
+
|
|
141
|
+
Le scope `latest_in_time(:user_id)` génère une sous-requête `NOT EXISTS` efficace :
|
|
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
|
+
Cela retourne uniquement l'enregistrement le plus récent par utilisateur qui était actif au moment donné, ce qui est parfait pour les associations `has_one`.
|
|
155
|
+
|
|
156
|
+
## Conseils
|
|
157
|
+
|
|
158
|
+
1. **Utilisez toujours `latest_in_time` avec `has_one`** - Cela garantit que vous obtenez exactement un enregistrement par clé étrangère.
|
|
159
|
+
|
|
160
|
+
2. **Ajoutez un index composite** sur `[user_id, start_at]` pour des performances de requête optimales.
|
|
161
|
+
|
|
162
|
+
3. **Utilisez `includes` pour le chargement anticipé** - Le pattern `NOT EXISTS` fonctionne efficacement avec le chargement anticipé de Rails.
|
|
163
|
+
|
|
164
|
+
4. **Envisagez d'ajouter une contrainte d'unicité** sur `[user_id, start_at]` pour éviter les enregistrements en double au même moment.
|