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.
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.
@@ -0,0 +1,5 @@
1
+ # 目次
2
+
3
+ - [はじめに](./index.md)
4
+ - [有効期限付きポイントシステム](./point-system.md)
5
+ - [ユーザー名履歴](./user-name-history.md)