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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +775 -7
- data/lib/activerecord/temporal/application_versioning/application_versioned.rb +122 -0
- data/lib/activerecord/temporal/application_versioning/command_recorder.rb +14 -0
- data/lib/activerecord/temporal/application_versioning/migration.rb +25 -0
- data/lib/activerecord/temporal/application_versioning/schema_statements.rb +33 -0
- data/lib/activerecord/temporal/application_versioning.rb +3 -69
- data/lib/activerecord/temporal/patches/association_reflection.rb +10 -3
- data/lib/activerecord/temporal/patches/command_recorder.rb +23 -0
- data/lib/activerecord/temporal/patches/join_dependency.rb +3 -0
- data/lib/activerecord/temporal/patches/merger.rb +1 -1
- data/lib/activerecord/temporal/patches/relation.rb +10 -6
- data/lib/activerecord/temporal/patches/through_association.rb +4 -1
- data/lib/activerecord/temporal/{as_of_query → querying}/association_macros.rb +1 -1
- data/lib/activerecord/temporal/querying/association_scope.rb +55 -0
- data/lib/activerecord/temporal/{as_of_query → querying}/association_walker.rb +1 -1
- data/lib/activerecord/temporal/querying/predicate_builder/contains_handler.rb +24 -0
- data/lib/activerecord/temporal/querying/predicate_builder/handlers.rb +31 -0
- data/lib/activerecord/temporal/querying/query_methods.rb +37 -0
- data/lib/activerecord/temporal/querying/scope_registry.rb +95 -0
- data/lib/activerecord/temporal/querying/scoping.rb +70 -0
- data/lib/activerecord/temporal/{as_of_query → querying}/time_dimensions.rb +13 -3
- data/lib/activerecord/temporal/querying/where_clause_refinement.rb +17 -0
- data/lib/activerecord/temporal/querying.rb +95 -0
- data/lib/activerecord/temporal/scoping.rb +7 -0
- data/lib/activerecord/temporal/system_versioning/command_recorder.rb +27 -1
- data/lib/activerecord/temporal/system_versioning/history_model.rb +47 -0
- data/lib/activerecord/temporal/system_versioning/history_model_namespace.rb +45 -0
- data/lib/activerecord/temporal/system_versioning/history_models.rb +29 -0
- data/lib/activerecord/temporal/system_versioning/migration.rb +35 -0
- data/lib/activerecord/temporal/system_versioning/schema_creation.rb +2 -2
- data/lib/activerecord/temporal/system_versioning/schema_statements.rb +80 -8
- data/lib/activerecord/temporal/system_versioning/system_versioned.rb +13 -0
- data/lib/activerecord/temporal/system_versioning.rb +6 -18
- data/lib/activerecord/temporal/version.rb +1 -1
- data/lib/activerecord/temporal.rb +75 -30
- metadata +27 -14
- data/lib/activerecord/temporal/as_of_query/association_scope.rb +0 -54
- data/lib/activerecord/temporal/as_of_query/query_methods.rb +0 -24
- data/lib/activerecord/temporal/as_of_query/scope_registry.rb +0 -38
- data/lib/activerecord/temporal/as_of_query.rb +0 -109
- data/lib/activerecord/temporal/system_versioning/model.rb +0 -37
- 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
|
-
|
|
3
|
+
This gem is an Active Record plugin for temporal data modeling in PostgreSQL.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
##
|
|
7
|
+
## Why Temporal Data?
|
|
10
8
|
|
|
11
|
-
|
|
12
|
-
|
|
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
|