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