activerecord-temporal 0.1.0 → 0.2.0

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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +8 -0
  3. data/README.md +775 -7
  4. data/lib/activerecord/temporal/application_versioning/application_versioned.rb +122 -0
  5. data/lib/activerecord/temporal/application_versioning/command_recorder.rb +14 -0
  6. data/lib/activerecord/temporal/application_versioning/migration.rb +25 -0
  7. data/lib/activerecord/temporal/application_versioning/schema_statements.rb +33 -0
  8. data/lib/activerecord/temporal/application_versioning.rb +3 -69
  9. data/lib/activerecord/temporal/patches/association_reflection.rb +10 -3
  10. data/lib/activerecord/temporal/patches/command_recorder.rb +23 -0
  11. data/lib/activerecord/temporal/patches/join_dependency.rb +3 -0
  12. data/lib/activerecord/temporal/patches/merger.rb +1 -1
  13. data/lib/activerecord/temporal/patches/relation.rb +10 -6
  14. data/lib/activerecord/temporal/patches/through_association.rb +4 -1
  15. data/lib/activerecord/temporal/{as_of_query → querying}/association_macros.rb +1 -1
  16. data/lib/activerecord/temporal/querying/association_scope.rb +55 -0
  17. data/lib/activerecord/temporal/{as_of_query → querying}/association_walker.rb +1 -1
  18. data/lib/activerecord/temporal/querying/predicate_builder/contains_handler.rb +24 -0
  19. data/lib/activerecord/temporal/querying/predicate_builder/handlers.rb +31 -0
  20. data/lib/activerecord/temporal/querying/query_methods.rb +37 -0
  21. data/lib/activerecord/temporal/querying/scope_registry.rb +95 -0
  22. data/lib/activerecord/temporal/querying/scoping.rb +70 -0
  23. data/lib/activerecord/temporal/{as_of_query → querying}/time_dimensions.rb +13 -3
  24. data/lib/activerecord/temporal/querying/where_clause_refinement.rb +17 -0
  25. data/lib/activerecord/temporal/querying.rb +95 -0
  26. data/lib/activerecord/temporal/scoping.rb +7 -0
  27. data/lib/activerecord/temporal/system_versioning/command_recorder.rb +27 -1
  28. data/lib/activerecord/temporal/system_versioning/history_model.rb +47 -0
  29. data/lib/activerecord/temporal/system_versioning/history_model_namespace.rb +45 -0
  30. data/lib/activerecord/temporal/system_versioning/history_models.rb +29 -0
  31. data/lib/activerecord/temporal/system_versioning/migration.rb +35 -0
  32. data/lib/activerecord/temporal/system_versioning/schema_creation.rb +2 -2
  33. data/lib/activerecord/temporal/system_versioning/schema_statements.rb +80 -8
  34. data/lib/activerecord/temporal/system_versioning/system_versioned.rb +13 -0
  35. data/lib/activerecord/temporal/system_versioning.rb +6 -18
  36. data/lib/activerecord/temporal/version.rb +1 -1
  37. data/lib/activerecord/temporal.rb +75 -30
  38. metadata +27 -14
  39. data/lib/activerecord/temporal/as_of_query/association_scope.rb +0 -54
  40. data/lib/activerecord/temporal/as_of_query/query_methods.rb +0 -24
  41. data/lib/activerecord/temporal/as_of_query/scope_registry.rb +0 -38
  42. data/lib/activerecord/temporal/as_of_query.rb +0 -109
  43. data/lib/activerecord/temporal/system_versioning/model.rb +0 -37
  44. data/lib/activerecord/temporal/system_versioning/namespace.rb +0 -34
data/README.md CHANGED
@@ -1,12 +1,780 @@
1
1
  # Active Record Temporal
2
2
 
3
- ## Features
3
+ This gem is an Active Record plugin for temporal data modeling in PostgreSQL.
4
4
 
5
- - Flashback (or as-of) queries
6
- - Application versioning
7
- - System versioning
5
+ It provides both system versioning and application versioning. They can be used alone, in parallel, or in conjunction (e.g., for bitemporal data). Both systems use the same interface for time-travel queries.
8
6
 
9
- ## Contributing
7
+ ## Why Temporal Data?
10
8
 
11
- - Run `rake db:create` to create the PostgreSQL test database
12
- - Run `rake` to run tests and linter
9
+ As applications mature, changing business requirements become increasingly complicated by the need to handle historical data. You might need to:
10
+
11
+ - Update subscription plans, but retain existing subscribers' original payment schedules
12
+ - Allow users to see information as it was before their view permission was revoked
13
+ - Understand why generated financial reports have changed recently
14
+ - Restore erroneously updated data
15
+
16
+ Many Rails applications use a patchwork of approaches:
17
+
18
+ - **Soft deletes** with a `deleted_at` column, but updates that still permanently overwrite data.
19
+ - **Audit gems or JSON columns** that serialize changes. Their data doesn't evolve with schema changes and cannot be easily integrated into Active Record queries, scopes, and associations.
20
+ - **Event systems** that are used to fill gaps in the data model and gradually take on responsibilities that are implementation details with no business relevance.
21
+
22
+ Temporal databases solve these problems by providing a simple and coherent data model to reach for whenever historical data is needed.
23
+
24
+ This can be a versioning strategy that operates automatically at the database level or one where versioning is used up front as the default method for all CRUD operations on a table.
25
+
26
+ ## Requirements
27
+
28
+ - Active Record >= 8
29
+ - PostgreSQL >= 13
30
+
31
+ ## Quick Start
32
+
33
+ ```ruby
34
+ # Gemfile
35
+
36
+ gem "activerecord-temporal"
37
+ ```
38
+
39
+ ### Create a System Versioned Table
40
+
41
+ Create your regular `employees` table. For the `employees_history` table, add the `system_period` column and include it in the table's primary key. `#create_versioning_hook` is what enables system versioning.
42
+
43
+ ```ruby
44
+ class CreateEmployees < ActiveRecord::Migration[8.1]
45
+ def change
46
+ enable_extension :btree_gist
47
+
48
+ create_table :employees do |t|
49
+ t.string :name
50
+ t.integer :wage
51
+ end
52
+
53
+ create_table :employees_history, primary_key: [:id, :system_period] do |t|
54
+ t.bigserial :id, null: false
55
+ t.string :name
56
+ t.integer :wage
57
+ t.tstzrange :system_period, null: false
58
+ t.exclusion_constraint "id WITH =, system_period WITH &&", using: :gist
59
+ end
60
+
61
+ create_versioning_hook :employees, :employees_history
62
+ end
63
+ end
64
+ ```
65
+
66
+ Create the namespace that all history models will exist in. If you're using Rails, I suggest you put this somewhere where it can be reloaded by Zeitwerk.
67
+
68
+ ```ruby
69
+ module History
70
+ include ActiveRecord::Temporal::HistoryModelNamespace
71
+ end
72
+ ```
73
+
74
+ Include `ActiveRecord::Temporal` and enable system versioning.
75
+
76
+ ```ruby
77
+ class ApplicationRecord < ActiveRecord::Base
78
+ primary_abstract_class
79
+
80
+ include ActiveRecord::Temporal
81
+
82
+ system_versioning
83
+ end
84
+ ```
85
+
86
+ Call `system_versioned` on the model that now has a system versioned table.
87
+
88
+ ```ruby
89
+ class Employee < ApplicationRecord
90
+ system_versioned
91
+ end
92
+ ```
93
+
94
+ Manipulate data as normal and use the time-travel query interface to read data as it was at any time in the past.
95
+
96
+ ```ruby
97
+ Employee.create(name: "Sam", wage: 75) # Executed on 1999-12-31
98
+ bob = Employee.create(name: "Bob", wage: 100) # Executed on 2000-01-07
99
+ bob.update(wage: 200) # Executed on 2000-01-14
100
+ bob.destroy # Executed on 2000-01-28
101
+
102
+ Employee.history
103
+ # => [
104
+ # #<History::Employee id: 1, name: "Sam", wage: 75, system_period: 1999-12-31...>,
105
+ # #<History::Employee id: 2, name: "Bob", wage: 100, system_period: 2000-01-07...2000-01-14>,
106
+ # #<History::Employee id: 2, name: "Bob", wage: 200, system_period: 2000-01-14...2000-01-28>
107
+ # ]
108
+
109
+ Employee.history.as_of(Time.parse("2000-01-10"))
110
+ # => [
111
+ #<History::Employee id: 1, name: "Sam", wage: 75, system_period: 1999-12-31...>,
112
+ #<History::Employee id: 2, name: "Bob", wage: 100, system_period: 2000-01-07...2000-01-14>
113
+ # ]
114
+ ```
115
+
116
+ #### Read more
117
+ - [Time-travel Queries Interface](#time-travel-queries-interface)
118
+ - [System Versioning](#system-versioning)
119
+ - [History Model Namespace](#history-model-namespace)
120
+
121
+ ### Create an Application Versioned Table
122
+
123
+ Create an `employees` table with a `version` column in the primary key and a `tstzrange` column to be the time dimension.
124
+
125
+ ```ruby
126
+ class CreateEmployees < ActiveRecord::Migration[8.1]
127
+ def change
128
+ enable_extension :btree_gist
129
+
130
+ create_table :employees, primary_key: [:id, :version] do |t|
131
+ t.bigserial :id, null: false
132
+ t.bigint :version, null: false, default: 1
133
+ t.string :name
134
+ t.integer :wage
135
+ t.tstzrange :validity, null: false
136
+ t.exclusion_constraint "id WITH =, validity WITH &&", using: :gist
137
+ end
138
+ end
139
+ end
140
+ ```
141
+
142
+ Include `ActiveRecord::Temporal` and enable application versioning for the column you're using as the time dimension.
143
+
144
+ ```ruby
145
+ class ApplicationRecord < ActiveRecord::Base
146
+ primary_abstract_class
147
+
148
+ include ActiveRecord::Temporal
149
+
150
+ application_versioning dimensions: :validity
151
+ end
152
+ ```
153
+
154
+ Call `application_versioned` on the model that is application versioned.
155
+
156
+ ```ruby
157
+ class Employee < ActiveRecord::Base
158
+ application_versioned
159
+ end
160
+ ```
161
+
162
+ `::originate_at`, `#revise_at` and `#inactive_at` are the versioning equivalents of `::create`, `#update`, `#destroy`. `::original_at` and `#revision_at` are the non-saving variants.
163
+
164
+ ```ruby
165
+ travel_to Time.parse("2000-01-01")
166
+
167
+ Employee.originate_at(1.month.from_now).with(wage: 75)
168
+ Employee.originate_at(1.month.from_now).with(wage: 100)
169
+ employee = Employee.last
170
+ new_version = employee.revise_at(2.months.from_now).with(wage: 200)
171
+ new_version.inactive_at(1.year.from_now)
172
+
173
+ Employee.all
174
+ # => [
175
+ # #<Employee id: 1, version: 1, wage: 75, validity: 2000-02-01...>,
176
+ # #<Employee id: 2, version: 1, wage: 100, validity: 2000-02-01...2000-03-01>,
177
+ # #<Employee id: 2, version: 2, wage: 200, validity: 2000-03-01...2001-01-01>
178
+ # ]
179
+
180
+ Employee.as_of(Time.parse("2000-02-15"))
181
+ # => [
182
+ # #<Employee id: 1, version: 1, wage: 75, validity: 2000-02-01...>,
183
+ # #<Employee id: 2, version: 1, wage: 100, validity: 2000-02-01...2000-03-01>
184
+ #]
185
+ ```
186
+
187
+ #### Read more
188
+ - [Time-travel Queries Interface](#time-travel-queries-interface)
189
+ - [Application Versioning](#application-versioning)
190
+ - [Foreign Key Constraints](#foreign-key-constraints)
191
+
192
+ ### Make Time-travel Queries
193
+
194
+ This interface works the same with system versioning and application. But this example assumes at least the `Product` and `Order` models are system versioned:
195
+
196
+ ```ruby
197
+ product = Product.create(price: 50)
198
+ order = Order.create(placed_at: Time.current)
199
+ order.line_items.create(product: product)
200
+
201
+ Product.first.update(price: 100) # Product catalogue changed
202
+
203
+ # Get the order's original price
204
+ order = Order.first
205
+ order.products.first # => #<Product price: 100>
206
+ order.as_of(order.placed_at).products.first # => #<History::Product price: 50>
207
+
208
+ products = Product
209
+ .as_of(10.months.ago)
210
+ .includes(line_items: :order)
211
+ .where(line_items: {quantity: 1}) # => [#<History::Product>, #<History::Product>]
212
+ ```
213
+
214
+ Records from time-travel queried are tagged with the time passed to `#as_of` and will propagate the time-travel query to subsequent associations.
215
+
216
+ ```ruby
217
+ products.first.categories.first # => The product's category as it was 10 months ago
218
+ ```
219
+
220
+ `temporal_scoping` implicitly sets all queries in the block to be as of the given time.
221
+
222
+ ```ruby
223
+ include ActiveRecord::Temporal::Scoping
224
+
225
+ temporal_scoping.at 1.year.ago do
226
+ products = Product.all # => All products as of 1 year ago
227
+ products = Product.as_of(Time.current) # Opt-in to ignore the scope's default time
228
+ end
229
+ ```
230
+
231
+ #### Read more
232
+ - [Time-travel Queries Interface](#time-travel-queries-interface)
233
+ - [Temporal Associations](#temporal-associations)
234
+
235
+ ## System Versioning
236
+
237
+ The temporal model of this gem is based on the SQL specification. It's also roughly the same model used by RDMSs like [MariaDB](https://mariadb.com/docs/server/reference/sql-structure/temporal-tables/system-versioned-tables) and [Microsoft SQL Server](https://learn.microsoft.com/en-us/sql/relational-databases/tables/temporal-tables?view=sql-server-ver17). It's also used by the [Temporal Table](https://github.com/arkhipov/temporal_tables) PostgreSQL extension. The triggers used in this gem are inspired by [PL/pgSQL version of Temporal Tables](https://github.com/nearform/temporal_tables).
238
+
239
+ Rows in the history table (or partition, view, etc.) represent rows that existed in the source table over a particular period of time. For PostgreSQL implementations this period of time is typically stored in a `tstzrange` column that this gem calls `system_period`.
240
+
241
+ ### Inserts
242
+
243
+ Rows inserted into the source table will be also inserted into the history table with `system_period` beginning at the current time and ending at infinity.
244
+
245
+ ```sql
246
+ -- Transaction start time: 2000-01-01
247
+
248
+ INSERT INTO products (name, price) VALUES ('Glow & Go Set', 29900), ('Zepbound', 34900)
249
+
250
+ /* products
251
+ ┌────┬───────────────┬───────┐
252
+ │ id │ name │ price │
253
+ ├────┼───────────────┼───────┤
254
+ │ 1 │ Glow & Go Set │ 29900 │
255
+ │ 2 │ Zepbound │ 34900 │
256
+ └────┴───────────────┴───────┘*/
257
+
258
+ /* products_history
259
+ ┌────┬───────────────┬───────┬──────────────────────────────────┐
260
+ │ id │ name │ price │ system_period │
261
+ ├────┼───────────────┼───────┼──────────────────────────────────┤
262
+ │ 1 │ Glow & Go Set │ 29900 │ ["2000-01-01 00:00:00",infinity) │
263
+ │ 2 │ Zepbound │ 34900 │ ["2000-01-01 00:00:00",infinity) │
264
+ └────┴───────────────┴───────┴──────────────────────────────────┘*/
265
+ ```
266
+
267
+ ### Updates
268
+
269
+ Rows updated in the source table will:
270
+
271
+ 1. Update the matching row in the history table by ending `system_period` with the current time.
272
+ 2. Insert a row into the history table that matches the new state in the source table and beginning `system_period` at the current time and ending at infinity.
273
+
274
+ ```sql
275
+ -- Transaction start time: 2000-01-02
276
+
277
+ UPDATE products SET price = 14900 WHERE id = 1
278
+
279
+ /* products
280
+ ┌────┬───────────────┬───────┐
281
+ │ id │ name │ price │
282
+ ├────┼───────────────┼───────┤
283
+ │ 1 │ Glow & Go Set │ 14900 │
284
+ │ 2 │ Zepbound │ 34900 │
285
+ └────┴───────────────┴───────┘*/
286
+
287
+ /* products_history
288
+ ┌────┬───────────────┬───────┬───────────────────────────────────────────────┐
289
+ │ id │ name │ price │ system_period │
290
+ ├────┼───────────────┼───────┼───────────────────────────────────────────────┤
291
+ │ 1 │ Glow & Go Set │ 29900 │ ["2000-01-01 00:00:00","2000-01-02 00:00:00") │
292
+ │ 2 │ Zepbound │ 34900 │ ["2000-01-01 00:00:00",infinity) │
293
+ │ 1 │ Glow & Go Set │ 14900 │ ["2000-01-02 00:00:00",infinity) │
294
+ └────┴───────────────┴───────┴───────────────────────────────────────────────┘*/
295
+ ```
296
+
297
+ ### Deletes
298
+
299
+ Rows deleted in the source table will update the matching row in the history table by ending `system_period` with the current time.
300
+
301
+ ```sql
302
+ -- Transaction start time: 2000-01-03
303
+
304
+ DELETE FROM products WHERE id = 2
305
+
306
+ /* products
307
+ ┌────┬───────────────┬───────┐
308
+ │ id │ name │ price │
309
+ ├────┼───────────────┼───────┤
310
+ │ 1 │ Glow & Go Set │ 14900 │
311
+ └────┴───────────────┴───────┘*/
312
+
313
+ /* products_history
314
+ ┌────┬───────────────┬───────┬───────────────────────────────────────────────┐
315
+ │ id │ name │ price │ system_period │
316
+ ├────┼───────────────┼───────┼───────────────────────────────────────────────┤
317
+ │ 1 │ Glow & Go Set │ 29900 │ ["2000-01-01 00:00:00","2000-01-02 00:00:00") │
318
+ │ 2 │ Zepbound │ 34900 │ ["2000-01-01 00:00:00","2000-01-03 00:00:00") │
319
+ │ 1 │ Glow & Go Set │ 14900 │ ["2000-01-02 00:00:00",infinity) │
320
+ └────┴───────────────┴───────┴───────────────────────────────────────────────┘*/
321
+ ```
322
+
323
+ ### Schema Migrations
324
+
325
+ ```ruby
326
+ class CreateProducts < ActiveRecord::Migration[8.1]
327
+ def change
328
+ enable_extension :btree_gist
329
+
330
+ create_table :products do |t|
331
+ t.string :name, null: false
332
+ t.index :sku, unique: true
333
+ t.integer :price
334
+ end
335
+
336
+ create_table :products_history, primary_key: [:id, :system_period] do |t|
337
+ t.bigint :id, null: false
338
+ t.string :name
339
+ t.integer :price
340
+ t.tstzrange :system_period, null: false
341
+ t.exclusion_constraint "id WITH =, system_period WITH &&", using: :gist
342
+ end
343
+
344
+ create_versioning_hook :products, # Enables system versioning for all columns
345
+ :products_history # in the source table
346
+
347
+ create_versioning_hook :products, # But the history table doesn't track `sku` so
348
+ :products_history, # we need explicitly set the columns to
349
+ columns: [:id, :name, :price] # exclude it
350
+
351
+ add_column :products_history, :sku, :string # We can add `sku` to the history table later
352
+
353
+ change_versioning_hook :products, # And update the triggers to start tracking it
354
+ :products_history,
355
+ add_columns: [:sku]
356
+
357
+ change_versioning_hook :products, # Keep the `name` column, but stop tracking it
358
+ :products_history,
359
+ remove_columns: [:name]
360
+
361
+ drop_versioning_hook :products, # Keep the table, but disable system versioning
362
+ :products_history
363
+
364
+ drop_versioning_hook :products, # Include options to make it reversible
365
+ :products_history,
366
+ columns: [:id, :sku, :price]
367
+
368
+ drop_table :products_history # Drop history table like any other table
369
+
370
+ create_versioning_hook :products, # If the products table used something other
371
+ :products_history, # than `id` for the primary key
372
+ columns: [:id, :name, :price]
373
+ primary_key: [:uuid]
374
+ end
375
+ end
376
+ ```
377
+
378
+ The only strict requirements for a history table are:
379
+ 1. It must have a `tstzrange` column called `system_period`
380
+ 2. Its primary key must contain all primary key columns of the source table plus `system_period`
381
+ 3. All columns shared by the two tables must have the same type
382
+
383
+ Very likely though you'll also want to make sure that it doesn't have any unique indexes or non-temporal foreign key constraints.
384
+
385
+ Enabling the `btree_gist` extension allows you to use an efficient exclusion constraint to prevent records with the same ID from having overlapping `system_period` columns.
386
+
387
+ `#create_versioning_hook` enables system versioning by creating three triggers that automatically updating the history table whenever the source table changes.
388
+
389
+ ### History Model Namespace
390
+
391
+ System versioning works by creating a parallel hierarchy of history models for your regular models. This applies to all models in the hierarchy whether they're system versioned or not and allows you to make queries that join multiple tables.
392
+
393
+ ```ruby
394
+ class ApplicationRecord < ActiveRecord::Base
395
+ primary_abstract_class
396
+
397
+ include ActiveRecord::Temporal
398
+
399
+ system_versioning
400
+ end
401
+
402
+ # ✅ System versioned
403
+ class Product < ApplicationRecord
404
+ system_versioned
405
+
406
+ has_many :line_items
407
+ end
408
+
409
+ # ❌ Not system versioned
410
+ class LineItem < ApplicationRecord
411
+ belongs_to :product
412
+ end
413
+
414
+ module History
415
+ include Temporal::SystemVersioningNamespace
416
+ end
417
+
418
+ History::Product # => History::Product(id: integer, system_period: tstzrange, name: string)
419
+ History::LineItem # => History::LineItem(id: integer, product_id: integer, order_id: integer)
420
+
421
+ History::Product.table_name # => "products_history"
422
+ History::LineItem.table_name # => "line_items"
423
+
424
+ History::Product.primary_key # => ["id", "system_period"]
425
+ History::LineItem.primary_key # => "id"
426
+
427
+ Product.history # [History::Product, ...]
428
+ LineItem.history # [LineItem::Product, ...]
429
+
430
+ products = Product.history.as_of(Time.parse("2027-12-23"))
431
+ product = products.first # => #<History::Product id: 70, system_period: 2027-11-07...2027-12-28, name: "Toy">
432
+ product.name # => "Toy"
433
+ product.line_items # => []
434
+
435
+ products = Product.history.as_of(Time.parse("2028-01-03"))
436
+ product = products.first # => #<History::Product id: 1, system_period: 2027-12-28..., name: "Toy (NEW!)">
437
+ product.name # => "Toy (NEW!)"
438
+ product.line_items # => [#<History::LineItem id: 1, product_id: 70, order_id: 4>]
439
+ ```
440
+
441
+ By default, calling `system_versioning` will look for a namespace called `History`. But this can be configured.
442
+
443
+ ```ruby
444
+ module Versions
445
+ include Temporal::SystemVersioningNamespace
446
+ end
447
+
448
+ class ApplicationRecord < ActiveRecord::Base
449
+ primary_abstract_class
450
+
451
+ include ActiveRecord::Temporal
452
+
453
+ system_versioning
454
+
455
+ def self.history_model_namespace
456
+ Versions
457
+ end
458
+ end
459
+ ```
460
+
461
+ By default, the namespace will only provide history models for models in the root namespace that descend from the root model where `system_versioning` was called (`ApplicationRecord` in this case).
462
+
463
+ ```ruby
464
+ module History
465
+ include Temporal::SystemVersioningNamespace
466
+
467
+ namespace "Tenant"
468
+
469
+ namespace "Backend" do
470
+ namespace "Admin"
471
+ end
472
+ end
473
+
474
+ Tenant::Product.history # => [History::Tenant::Product, ...]
475
+ Backend::Config.history # => [History::Backend::Config, ...]
476
+ Backend::Admin::Customer.history # => [History::Backend::Admin::Customer, ...]
477
+ ```
478
+
479
+ ## Application Versioning
480
+
481
+ ```ruby
482
+ class CreateEmployees < ActiveRecord::Migration[8.1]
483
+ def change
484
+ enable_extension :btree_gist
485
+
486
+ create_table :employees, primary_key: [:id, :version] do |t|
487
+ t.bigserial :id, null: false
488
+ t.bigint :version, null: false, default: 1
489
+ t.string :name
490
+ t.integer :price
491
+ t.tstzrange :validity, null: false
492
+ t.exclusion_constraint "id WITH =, validity WITH &&", using: :gist
493
+ end
494
+ end
495
+ end
496
+
497
+ class ApplicationRecord < ActiveRecord::Base
498
+ primary_abstract_class
499
+
500
+ include ActiveRecord::Temporal
501
+
502
+ application_versioning dimensions: :validity
503
+ end
504
+
505
+ class Product < ApplicationRecord
506
+ application_versioned
507
+ end
508
+ ```
509
+
510
+ The only strict requirements for a application versioned table are:
511
+ 1. It must have a `tstzrange` column (name doesn't matter)
512
+ 2. It must have a numeric `version` column with a default value
513
+
514
+ The `version` column will be automatically incremented when creating new versions in `#after_initialize_revision`.
515
+
516
+ This method can be defined in the model to for additional behaviour. Don't forget to call `super`.
517
+
518
+ ```ruby
519
+ class Product < ApplicationRecord
520
+ application_versioned
521
+
522
+ def after_initialize_revision(prev_version)
523
+ super
524
+
525
+ # Some custom post-initialization logic
526
+ end
527
+ end
528
+ ```
529
+
530
+ ### Versioning Interface
531
+
532
+ `::original_at` instantiates a first version at the given time.
533
+
534
+ `::originate_at` does the same, but also saves it.
535
+
536
+ ```ruby
537
+ travel_to Time.parse("2000-01-01") # Lock `Time.current` at 2000-01-01
538
+
539
+ prod_v1 = Product.original_at(1.year.from_now).with(price: 100)
540
+ # => #<Product id: nil, version: 1, price: 100, validity: 2001-01-01...>
541
+
542
+ prod_v1.persisted? # => false
543
+
544
+ prod_v1 = Product.originate_at(1.year.from_now).with(price: 100)
545
+ # => #<Product id: 1, version: 1, price: 55, validity: 2001-01-01...>
546
+
547
+ prod_v1.persisted? # => true
548
+ ```
549
+
550
+ `#revision_at` instantiates the next version of a record at the given time.
551
+
552
+ ```ruby
553
+ prod_v2 = prod_v1.revision_at(2.years.from_now).with(price: 250)
554
+ # => #<Product id: 1, version: 2, price: 250, validity: 2002-01-01...>
555
+
556
+ prod_v1
557
+ # => #<Product id: 1, version: 1, price: 100, validity: 2001-01-01...2001-01-01>
558
+
559
+ prod_v1.save # => true
560
+ prod_v2.save # => true
561
+ ```
562
+
563
+ `#revise_at` does the same thing, but also saves it.
564
+
565
+ ```ruby
566
+ prod_v3 = prod_v2.revise_at(3.years.from_now).with(price: 500)
567
+ # => #<Product id: 1, version: 3, price: 500, validity: 2003-01-01...>
568
+
569
+ prod_v2
570
+ # => #<Product id: 1, version: 2, price: 250, validity: 2002-01-01...2003-01-01>
571
+
572
+ prod_v2.persisted? # => true
573
+ prod_v3.persisted? # => true
574
+ ```
575
+
576
+ `#inactive_at` closes the record's time dimension at the given time, making it the last version.
577
+
578
+ ```ruby
579
+ prod_v3.inactivate_at(4.years.from_now)
580
+ # => #<Product id: 1, version: 3, price: 500, validity: 2003-01-01...2004-01-01>
581
+ ```
582
+
583
+ All the above methods have a counterpart without `_at` that default to the current time or the time of enclosing scoped block.
584
+
585
+ ```ruby
586
+ travel_to Time.parse("2030-01-01") # Lock `Time.current` at 2030-01-01
587
+
588
+ prod_v1 = Product.find_by(id: 1, version: 1)
589
+
590
+ prod_v2 = prod_v1.revise.with(price: 1000)
591
+ # => #<Product id: 1, version: 2, price: 1000, validity: 2030-01-01...>
592
+
593
+ include ActiveRecord::Temporal::Scoping
594
+
595
+ temporal_scoping.at 5.years.from_now do
596
+ prod_v2.inactivate
597
+ end
598
+ # => #<Product id: 1, version: 2, price: 1000, validity: 2030-01-01...2035-01-01>
599
+ ```
600
+
601
+ ## Time-Travel Queries Interface
602
+
603
+ The time-travel query interface behaves the same for application and system versioned models.
604
+
605
+ `at_time` is an Active Record scope that filters rows by time. It applies to the base model as well as all preloaded/joined associations.
606
+
607
+ ```ruby
608
+ Product.at_time(Time.parse("2025-01-01"))
609
+ ```
610
+ ```sql
611
+ SELECT products.* FROM products WHERE products.validity @> '2025-01-01 00:00:00'::timestamptz
612
+ ```
613
+
614
+ ```ruby
615
+ Product.at_time(Time.parse("2025-01-01"))
616
+ .includes(line_items: :order)
617
+ .where(orders: {status: "shipped"})
618
+ ```
619
+ ```sql
620
+ SELECT products.* FROM products
621
+ JOIN line_items ON line_items.product_id = products.id
622
+ AND line_items.validity @> '2025-01-01 00:00:00'::timestamptz
623
+ JOIN orders ON orders.id = line_items.order_id
624
+ AND orders.validity @> '2025-01-01 00:00:00'::timestamptz
625
+ WHERE products.validity @> '2025-01-01 00:00:00'::timestamptz AND orders.status = 'shipped'
626
+ ```
627
+
628
+ `as_of` is another Active Record scope. It applies the same filtering behaviour as `at_time` but also tags all loaded records with the time used such that any subsequent associations called on them will propagate the `as_of` scope.
629
+
630
+ ```ruby
631
+ product = Product.as_of(Time.parse("2025-01-01")).first
632
+ # => #<Product id: 1, version: 2, price: 1000, validity: 2030-01-01...>
633
+
634
+ product.time_tag # => 2025-01-01
635
+
636
+ product.line_items.first.order # => Order as it was at 2025-01-01
637
+ ```
638
+
639
+ `#as_of(time)` returns a new instance of a record at the given time. Returns nil if record does not exist at that time.
640
+
641
+ `#as_of!(time)` reloads the record to the version at the given time. Raises error if record does not exist at that time.
642
+
643
+ ```ruby
644
+ product = Product.first
645
+
646
+ product.time_tag # => nil
647
+ product.line_items # => [LineItem] as they are now
648
+
649
+ product.as_of!(Time.parse("2025-01-01"))
650
+
651
+ product.time_tag # => 2025-01-01
652
+ product.line_items # => [LineItem] as they were at 2025-01-01
653
+ ```
654
+
655
+ The time-travel query interface doesn't require any type of versioning at all. As long as a model has a `tstzrange` column, includes `ActiveRecord::Temporal::Querying` and declares the time dimension.
656
+
657
+ ```ruby
658
+ create_table :employees do |t|
659
+ t.tstzrange :effective_period
660
+ end
661
+
662
+ class Employee < ActiveRecord::Base
663
+ include ActiveRecord::Temporal::Querying
664
+
665
+ self.time_dimensions = :effective_period
666
+ end
667
+
668
+ Employee.as_of(Time.current) # => [Employee, Employee]
669
+ ```
670
+
671
+ ### Scoped Blocks
672
+
673
+ Inside of a time-scoped block all query will by default have the `at_time` scope applied. It can be overwritten.
674
+
675
+ ```ruby
676
+ include ActiveRecord::Temporal::Scoping
677
+
678
+ temporal_scoping.at Time.parse("2011-04-30") do
679
+ Product.all # => All products as of 2011-04-30
680
+ Product.first.prices # => All associated prices as of 2011-04-30
681
+ Product.as_of(Time.current) # => All current products
682
+
683
+ temporal_scoping.at Time.parse("1990-06-07") do
684
+ Product.all # => All products as of 1990-06-07
685
+ end
686
+ end
687
+ ```
688
+
689
+ ### Temporal Associations
690
+
691
+ For `at_time` and `as_of` to filter associated models the associations between models must be passed the `temporal: true` option.
692
+
693
+ ```ruby
694
+ class Product < ApplicationRecord
695
+ application_versioned
696
+
697
+ has_many :line_items
698
+ has_many :orders, through: :line_items
699
+ end
700
+ ```
701
+
702
+ By default, this query will filter products by the time, but not the line items or orders.
703
+
704
+ ```ruby
705
+ Product.at_time(Time.parse("2025-01-01"))
706
+ .includes(line_items: :order)
707
+ .where(orders: {status: "shipped"})
708
+ ```
709
+
710
+ You must add `temporal: true` to the associations. Then the entire query will be temporal.
711
+
712
+ ```ruby
713
+ class Product < ApplicationRecord
714
+ application_versioned
715
+
716
+ has_many :line_items, temporal: true
717
+ has_many :orders, through: :line_items, temporal: true
718
+ end
719
+ ```
720
+
721
+ Associated models do not need to be application versioned or system versioned to use temporal associations. If they're used in a query with `at_time` or `as_of` they will behave as though all their rows have double unbounded time ranges equivalent to `nil...nil` in Ruby or `['-infinity','infinity')` PostgreSQL.
722
+
723
+ The history models automatically generated when using system versioning will automatically have all their associations temporalized whether they're backed by a history table or not.
724
+
725
+ #### Interaction with Scoped Blocks
726
+
727
+ By their nature, temporal associations will always filter associated records by the current time or the time of the scoped block.
728
+
729
+ ```ruby
730
+ Product.all # => All product versions, past, present, and future
731
+ LineItem.first.products # => associated products scoped to the current time
732
+ ```
733
+
734
+ If you typically only need current records, you can scope controller actions to `Time.current`, which roughly equates to the time when a request was received.
735
+
736
+ ```ruby
737
+ class ApplicationController < ActionController::Base
738
+ include ActiveRecord::Temporal::Scoping
739
+
740
+ around_action do |controller, action|
741
+ temporal_scoping.at(Time.current, &action)
742
+ end
743
+ end
744
+ ```
745
+
746
+ `default_scope` can also be used to achieve a similar effect.
747
+
748
+ ```ruby
749
+ class ApplicationRecord < ActiveRecord::Base
750
+ include ActiveRecord::Temporal
751
+
752
+ application_versioned
753
+
754
+ self.time_dimensions = :validity
755
+
756
+ default_scope -> { at_time(Time.current) }
757
+ end
758
+ ```
759
+
760
+ #### Compatibility with Existing Scopes
761
+
762
+ ```ruby
763
+ class Product < ActiveRecord::Base
764
+ application_versioned
765
+
766
+ has_one :price, -> { where(active: true) }, temporal: true
767
+ end
768
+ ```
769
+
770
+ Temporal associations are implemented as association scopes and will be merged with the association's non-temporal scope.
771
+
772
+ ## Foreign Key Constraints
773
+
774
+ Active Record models typically have a single column primary key called `id`. History tables must have a composite primary key, and though not a requirement it's recommended that application versioned tables do as well.
775
+
776
+ Furthermore, you probably don't want foreign key constraints to reference a single row in a versioned table. A book should belong to an author, not a specific version of that author. But standard foreign key constraints must reference columns that uniquely identify a row.
777
+
778
+ There are two options to get around this:
779
+ 1. Use the `WITHOUT OVERLAPS`/`PERIOD` feature added in PostgreSQL 18 that allows for temporal foreign key constraints
780
+ 2. Implement effective foreign key constraints using triggers