hoardable 0.12.9 → 0.14.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.rubocop.yml +1 -1
- data/.tool-versions +1 -1
- data/CHANGELOG.md +4 -2
- data/README.md +142 -143
- data/lib/generators/hoardable/functions/hoardable_prevent_update_id.sql +8 -0
- data/lib/generators/hoardable/functions/hoardable_source_set_id.sql +18 -0
- data/lib/generators/hoardable/functions/hoardable_version_prevent_update.sql +6 -0
- data/lib/generators/hoardable/install_generator.rb +16 -2
- data/lib/generators/hoardable/migration_generator.rb +19 -1
- data/lib/generators/hoardable/templates/install.rb.erb +28 -59
- data/lib/generators/hoardable/templates/migration.rb.erb +7 -26
- data/lib/generators/hoardable/triggers/prevent_update_hoardable_id.sql +3 -0
- data/lib/generators/hoardable/triggers/set_hoardable_id.sql +3 -0
- data/lib/generators/hoardable/triggers/versions_prevent_update.sql +3 -0
- data/lib/hoardable/database_client.rb +1 -1
- data/lib/hoardable/engine.rb +1 -1
- data/lib/hoardable/scopes.rb +1 -1
- data/lib/hoardable/version.rb +1 -1
- data/lib/hoardable/version_model.rb +1 -1
- data/sig/hoardable.rbs +2 -2
- metadata +30 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 83017b15a9b37931d02d7f5d8b7b8d3e81cab4b4cc4691f522430ae2c9be77f8
|
4
|
+
data.tar.gz: 1774bb8cfe7628bbc2bdd8293928dac45dce20c4724aa5c4bc435a4c59a40f0d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: d0b7d0024a9b8eee5982ab92af174a9a7a134445108c1c3d97945c5ad967f234743fe047f0c42726366d38b7facacc44bac530a5de9a5369de0a9e7bc27342f9
|
7
|
+
data.tar.gz: fb4d0de48b7d798e70c948aea4ae8adb90eaa2836c4ca2d76e952e5c02d53951205f2628bc3e620cb1824f6ac49fcc692dc19130f907c932e0ed3b78fee217e8
|
data/.rubocop.yml
CHANGED
data/.tool-versions
CHANGED
@@ -1,2 +1,2 @@
|
|
1
|
-
ruby 3.1
|
1
|
+
ruby 3.2.1
|
2
2
|
postgres 14.4
|
data/CHANGELOG.md
CHANGED
data/README.md
CHANGED
@@ -1,23 +1,21 @@
|
|
1
1
|
# Hoardable ![gem version](https://img.shields.io/gem/v/hoardable?style=flat-square)
|
2
2
|
|
3
|
-
Hoardable is an ActiveRecord extension for Ruby 2.
|
4
|
-
|
3
|
+
Hoardable is an ActiveRecord extension for Ruby 2.7+, Rails 6.1+, and PostgreSQL that allows for versioning
|
4
|
+
and soft-deletion of records through the use of _uni-temporal inherited tables_.
|
5
5
|
|
6
|
-
[Temporal tables](https://en.wikipedia.org/wiki/Temporal_database) are a database design pattern
|
7
|
-
|
8
|
-
|
9
|
-
"uni-temporal".
|
6
|
+
[Temporal tables](https://en.wikipedia.org/wiki/Temporal_database) are a database design pattern where each
|
7
|
+
row of a table contains data along with one or more time ranges. In the case of this gem, each database row
|
8
|
+
has a time range that represents the row’s valid time range - hence "uni-temporal".
|
10
9
|
|
11
|
-
[Table inheritance](https://www.postgresql.org/docs/14/ddl-inherit.html) is a feature of PostgreSQL
|
12
|
-
|
13
|
-
|
14
|
-
|
10
|
+
[Table inheritance](https://www.postgresql.org/docs/14/ddl-inherit.html) is a feature of PostgreSQL that
|
11
|
+
allows a table to inherit all columns of a parent table. The descendant table’s schema will stay in sync with
|
12
|
+
its parent. If a new column is added to or removed from the parent, the schema change is reflected on its
|
13
|
+
descendants.
|
15
14
|
|
16
|
-
With these concepts combined, `hoardable` offers a simple and effective model versioning system for
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
within Ruby on Rails.
|
15
|
+
With these concepts combined, `hoardable` offers a simple and effective model versioning system for Rails.
|
16
|
+
Versions of records are stored in separate, inherited tables along with their valid time ranges and
|
17
|
+
contextual data. Compared to other Rails-oriented versioning systems, this gem strives to be more explicit
|
18
|
+
and obvious on the lower database level, while still familiar and convenient to use within Ruby on Rails.
|
21
19
|
|
22
20
|
[👉 Documentation](https://www.rubydoc.info/gems/hoardable)
|
23
21
|
|
@@ -36,19 +34,16 @@ bin/rails g hoardable:install
|
|
36
34
|
bin/rails db:migrate
|
37
35
|
```
|
38
36
|
|
39
|
-
This will generate PostgreSQL functions, an
|
40
|
-
= :sql` in `application.rb
|
37
|
+
This will generate PostgreSQL functions, an enum and an initiailzer. It will also set
|
38
|
+
`config.active_record.schema_format = :sql` in `application.rb` if you are using Rails < 7.
|
41
39
|
|
42
40
|
### Model Installation
|
43
41
|
|
44
|
-
You must include `Hoardable::Model` into an ActiveRecord model that you would like to hoard versions
|
45
|
-
of:
|
42
|
+
You must include `Hoardable::Model` into an ActiveRecord model that you would like to hoard versions of:
|
46
43
|
|
47
44
|
```ruby
|
48
45
|
class Post < ActiveRecord::Base
|
49
46
|
include Hoardable::Model
|
50
|
-
belongs_to :user
|
51
|
-
has_many :comments, dependent: :destroy
|
52
47
|
...
|
53
48
|
end
|
54
49
|
```
|
@@ -60,34 +55,31 @@ bin/rails g hoardable:migration Post
|
|
60
55
|
bin/rails db:migrate
|
61
56
|
```
|
62
57
|
|
63
|
-
By default, it will
|
64
|
-
|
65
|
-
explicitly, you can do so:
|
58
|
+
By default, it will guess the foreign key type for the `_versions` table based on the primary key of the
|
59
|
+
model specified in the migration generator above. If you want/need to specify this explicitly, you can do so:
|
66
60
|
|
67
61
|
```
|
68
62
|
bin/rails g hoardable:migration Post --foreign-key-type uuid
|
69
63
|
```
|
70
64
|
|
71
|
-
_Note:_ Creating an inherited table does not
|
72
|
-
|
65
|
+
_Note:_ Creating an inherited table does not inherit the indexes from the parent table. If you need to query
|
66
|
+
versions often, you should add appropriate indexes to the `_versions` tables.
|
73
67
|
|
74
68
|
## Usage
|
75
69
|
|
76
70
|
### Overview
|
77
71
|
|
78
|
-
Once you include `Hoardable::Model` into a model, it will dynamically generate a "Version" subclass
|
79
|
-
|
72
|
+
Once you include `Hoardable::Model` into a model, it will dynamically generate a "Version" subclass of that
|
73
|
+
model. As we continue our example from above:
|
80
74
|
|
81
|
-
```
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
>> PostVersion
|
86
|
-
=> PostVersion(id: integer, body: text, user_id: integer, created_at: datetime, hoardable_id: integer, _data: jsonb, _during: tsrange)
|
75
|
+
```ruby
|
76
|
+
Post #=> Post(id: integer, created_at: datetime, updated_at: datetime, hoardable_id: integer)
|
77
|
+
PostVersion #=> PostVersion(id: integer, created_at: datetime, updated_at: datetime, hoardable_id: integer, _data: jsonb, _during: tsrange, _event_uuid: uuid, _operation: enum)
|
78
|
+
Post.version_class #=> same as `PostVersion`
|
87
79
|
```
|
88
80
|
|
89
|
-
A `Post` now `has_many :versions`. With the default configuration, whenever an update and deletion
|
90
|
-
|
81
|
+
A `Post` now `has_many :versions`. With the default configuration, whenever an update and deletion of a
|
82
|
+
`Post` occurs, a version is created:
|
91
83
|
|
92
84
|
```ruby
|
93
85
|
post = Post.create!(title: "Title")
|
@@ -102,7 +94,11 @@ Post.find(post.id) # raises ActiveRecord::RecordNotFound
|
|
102
94
|
```
|
103
95
|
|
104
96
|
Each `PostVersion` has access to the same attributes, relationships, and other model behavior that
|
105
|
-
`Post` has, but as a read-only record
|
97
|
+
`Post` has, but as a read-only record:
|
98
|
+
|
99
|
+
``` ruby
|
100
|
+
post.versions.last.update!(title: "Rewrite history") #=> raises ActiveRecord::ReadOnlyRecord
|
101
|
+
```
|
106
102
|
|
107
103
|
If you ever need to revert to a specific version, you can call `version.revert!` on it.
|
108
104
|
|
@@ -113,8 +109,8 @@ post.reload.versions.last.revert!
|
|
113
109
|
post.title # => "Title"
|
114
110
|
```
|
115
111
|
|
116
|
-
If you would like to untrash a specific version, you can call `version.untrash!` on
|
117
|
-
re-insert the model in the parent class’s table with the original primary key.
|
112
|
+
If you would like to untrash a specific version of a record you deleted, you can call `version.untrash!` on
|
113
|
+
it. This will re-insert the model in the parent class’s table with the original primary key.
|
118
114
|
|
119
115
|
```ruby
|
120
116
|
post = Post.create!(title: "Title")
|
@@ -128,12 +124,17 @@ trashed_post.untrash!
|
|
128
124
|
Post.find(post.id) # #<Post>
|
129
125
|
```
|
130
126
|
|
127
|
+
_Note:_ You will notice above that both `posts` and `post_versions` pull from the same ID sequence. This
|
128
|
+
allows for uniquely identifying source records and versions when results are mixed together. Both a source
|
129
|
+
record and versions have an automatically managed `hoardable_id` that always represents the primary key value
|
130
|
+
of the original source record.
|
131
|
+
|
131
132
|
### Querying and Temporal Lookup
|
132
133
|
|
133
134
|
Since a `PostVersion` is an `ActiveRecord` class, you can query them like another model resource:
|
134
135
|
|
135
136
|
```ruby
|
136
|
-
post.versions.where(
|
137
|
+
post.versions.where(state: :draft)
|
137
138
|
```
|
138
139
|
|
139
140
|
If you want to look-up the version of a record at a specific time, you can use the `.at` method:
|
@@ -141,7 +142,8 @@ If you want to look-up the version of a record at a specific time, you can use t
|
|
141
142
|
```ruby
|
142
143
|
post.at(1.day.ago) # => #<PostVersion>
|
143
144
|
# or you can use the scope on the version model class
|
144
|
-
|
145
|
+
post.versions.at(1.day.ago) # => #<PostVersion>
|
146
|
+
PostVersion.at(1.day.ago).find_by(hoardable_id: post.id) # => same as above
|
145
147
|
```
|
146
148
|
|
147
149
|
The source model class also has an `.at` method:
|
@@ -150,35 +152,32 @@ The source model class also has an `.at` method:
|
|
150
152
|
Post.at(1.day.ago) # => [#<Post>, #<Post>]
|
151
153
|
```
|
152
154
|
|
153
|
-
This will return an ActiveRecord scoped query of all `Post` and `PostVersion` records that were
|
154
|
-
|
155
|
+
This will return an ActiveRecord scoped query of all `Post` and `PostVersion` records that were valid at that
|
156
|
+
time, all cast as instances of `Post`.
|
155
157
|
|
156
|
-
There is also an `at` method on `Hoardable` itself for more complex temporal resource
|
157
|
-
[Relationships](#relationships) for more.
|
158
|
+
There is also an `at` method on `Hoardable` itself for more complex and experimental temporal resource
|
159
|
+
querying. See [Relationships](#relationships) for more.
|
158
160
|
|
159
|
-
By default, `hoardable` will keep copies of records you have destroyed. You can query them
|
160
|
-
specifically with:
|
161
|
+
By default, `hoardable` will keep copies of records you have destroyed. You can query them specifically with:
|
161
162
|
|
162
163
|
```ruby
|
163
164
|
PostVersion.trashed
|
164
|
-
Post.version_class.trashed # <- same
|
165
|
+
Post.version_class.trashed # <- same as above
|
165
166
|
```
|
166
167
|
|
167
|
-
_Note:_ A `Version` is not created upon initial parent model creation. To accurately track the
|
168
|
-
|
169
|
-
|
168
|
+
_Note:_ A `Version` is not created upon initial parent model creation. To accurately track the beginning of
|
169
|
+
the first temporal period, you will need to ensure the source model table has a `created_at` timestamp
|
170
|
+
column. If this is missing, an error will be raised.
|
170
171
|
|
171
172
|
### Tracking Contextual Data
|
172
173
|
|
173
|
-
You’ll often want to track contextual data about the creation of a version. There are
|
174
|
-
|
174
|
+
You’ll often want to track contextual data about the creation of a version. There are 2 options that can be
|
175
|
+
provided for tracking contextual information:
|
175
176
|
|
176
177
|
- `:whodunit` - an identifier for who is responsible for creating the version
|
177
|
-
- `:note` - a description regarding the versioning
|
178
178
|
- `:meta` - any other contextual information you’d like to store along with the version
|
179
179
|
|
180
|
-
This information is stored in a `jsonb` column. Each key’s value can be in the format of your
|
181
|
-
choosing.
|
180
|
+
This information is stored in a `jsonb` column. Each key’s value can be in the format of your choosing.
|
182
181
|
|
183
182
|
One convenient way to assign contextual data to these is by defining a proc in an initializer, i.e.:
|
184
183
|
|
@@ -192,17 +191,17 @@ post.update!(status: 'live')
|
|
192
191
|
post.reload.versions.last.hoardable_whodunit # => 123
|
193
192
|
```
|
194
193
|
|
195
|
-
You can also set this context manually as well
|
194
|
+
You can also set this context manually as well:
|
196
195
|
|
197
196
|
```ruby
|
198
|
-
Hoardable.
|
197
|
+
Hoardable.meta = { note: "reverting due to accidental deletion" }
|
199
198
|
post.update!(title: "We’re back!")
|
200
|
-
Hoardable.
|
201
|
-
post.reload.versions.last.
|
199
|
+
Hoardable.meta = nil
|
200
|
+
post.reload.versions.last.hoardable_meta['note'] # => "reverting due to accidental deletion"
|
202
201
|
```
|
203
202
|
|
204
|
-
A more useful pattern is to use `Hoardable.with` to set the context around a block.
|
205
|
-
|
203
|
+
A more useful pattern however is to use `Hoardable.with` to set the context around a block. For example, you
|
204
|
+
could have the following in your `ApplicationController`:
|
206
205
|
|
207
206
|
```ruby
|
208
207
|
class ApplicationController < ActionController::Base
|
@@ -220,9 +219,9 @@ end
|
|
220
219
|
```
|
221
220
|
|
222
221
|
`hoardable` will also automatically capture the ActiveRecord
|
223
|
-
[changes](https://api.rubyonrails.org/classes/ActiveModel/Dirty.html#method-i-changes) hash, the
|
224
|
-
|
225
|
-
|
222
|
+
[changes](https://api.rubyonrails.org/classes/ActiveModel/Dirty.html#method-i-changes) hash, the `operation`
|
223
|
+
that cause the version (`update` or `delete`), and it will also tag all versions created in the same database
|
224
|
+
transaction with a shared and unique `event_uuid` for that transaction. These are available as:
|
226
225
|
|
227
226
|
```ruby
|
228
227
|
version.changes
|
@@ -232,12 +231,12 @@ version.hoardable_event_uuid
|
|
232
231
|
|
233
232
|
### Model Callbacks
|
234
233
|
|
235
|
-
Sometimes you might want to do something with a version after it gets inserted to the database. You
|
236
|
-
|
237
|
-
|
234
|
+
Sometimes you might want to do something with a version after it gets inserted to the database. You can
|
235
|
+
access it in `after_versioned` callbacks on the source record as `hoardable_version`. These happen within
|
236
|
+
`ActiveRecord`’s `.save`, which is enclosed in an ActiveRecord transaction.
|
238
237
|
|
239
|
-
There are also `after_reverted` and `after_untrashed` callbacks available as well, which are called
|
240
|
-
|
238
|
+
There are also `after_reverted` and `after_untrashed` callbacks available as well, which are called on the
|
239
|
+
source record after a version is reverted or untrashed.
|
241
240
|
|
242
241
|
```ruby
|
243
242
|
class User
|
@@ -272,11 +271,11 @@ Hoardable.version_updates # => default true
|
|
272
271
|
Hoardable.save_trash # => default true
|
273
272
|
```
|
274
273
|
|
275
|
-
`Hoardable.enabled` controls whether versions will be ever be created.
|
274
|
+
`Hoardable.enabled` globally controls whether versions will be ever be created.
|
276
275
|
|
277
|
-
`Hoardable.version_updates` controls whether versions get created on record updates.
|
276
|
+
`Hoardable.version_updates` globally controls whether versions get created on record updates.
|
278
277
|
|
279
|
-
`Hoardable.save_trash` controls whether to create versions upon record deletion. When this is set to
|
278
|
+
`Hoardable.save_trash` globally controls whether to create versions upon record deletion. When this is set to
|
280
279
|
`false`, all versions of a record will be deleted when the record is destroyed.
|
281
280
|
|
282
281
|
If you would like to temporarily set a config setting, you can use `Hoardable.with`:
|
@@ -287,7 +286,7 @@ Hoardable.with(enabled: false) do
|
|
287
286
|
end
|
288
287
|
```
|
289
288
|
|
290
|
-
You can also configure these
|
289
|
+
You can also configure these settings per `ActiveRecord` class using `hoardable_config`:
|
291
290
|
|
292
291
|
```ruby
|
293
292
|
class Comment < ActiveRecord::Base
|
@@ -305,18 +304,17 @@ Comment.with_hoardable_config(version_updates: true) do
|
|
305
304
|
end
|
306
305
|
```
|
307
306
|
|
308
|
-
If a model-level option exists, it will use that. Otherwise, it will fall back to the global
|
309
|
-
|
307
|
+
If a model-level option exists, it will use that. Otherwise, it will fall back to the global `Hoardable`
|
308
|
+
config.
|
310
309
|
|
311
|
-
|
310
|
+
## Relationships
|
312
311
|
|
313
|
-
|
314
|
-
with `Hoardable` considerations.
|
312
|
+
### Belongs To Trashable
|
315
313
|
|
316
|
-
Sometimes you’ll have a record that belongs to a parent record that you’ll trash. Now the child
|
317
|
-
|
318
|
-
|
319
|
-
|
314
|
+
Sometimes you’ll have a record that belongs to a parent record that you’ll trash. Now the child record’s
|
315
|
+
foreign key will point to the non-existent trashed version of the parent. If you would like to have
|
316
|
+
`belongs_to` resolve to the trashed parent model in this case, you can give it the option of `trashable:
|
317
|
+
true`:
|
320
318
|
|
321
319
|
```ruby
|
322
320
|
class Comment
|
@@ -325,10 +323,11 @@ class Comment
|
|
325
323
|
end
|
326
324
|
```
|
327
325
|
|
328
|
-
|
329
|
-
|
330
|
-
|
331
|
-
|
326
|
+
### Hoardable Has Many & Has One
|
327
|
+
|
328
|
+
Sometimes you'll have a Hoardable record that `has_one` or `has_many` other Hoardable records and you will
|
329
|
+
want to know the state of both the parent record and the children at a cetain point in time. You accomplish
|
330
|
+
this by adding `hoardable: true` to the `has_many` relationship and using the `Hoardable.at` method:
|
332
331
|
|
333
332
|
```ruby
|
334
333
|
class Post
|
@@ -358,22 +357,26 @@ Hoardable.at(datetime) do
|
|
358
357
|
end
|
359
358
|
```
|
360
359
|
|
361
|
-
There are some additional details to point out above. Firstly, it is important to note that the
|
362
|
-
|
363
|
-
|
364
|
-
|
365
|
-
|
366
|
-
|
360
|
+
There are some additional details to point out above. Firstly, it is important to note that the final
|
361
|
+
`post.id` yields a different value than the originally created `Post`. This is because the `post` within the
|
362
|
+
`#at` block is actually a temporal version, since it has been subsequently updated, but is reified as a
|
363
|
+
`Post` for the purposes of your business logic (serialization, rendering views, exporting, etc). Don’t fret -
|
364
|
+
you will not be able to commit any updates to the version, even though it is masquerading as a `Post` because
|
365
|
+
a database trigger won’t allow you to.
|
367
366
|
|
368
|
-
If you are ever unsure if a Hoardable record is a
|
369
|
-
|
370
|
-
`hoardable_id`.
|
367
|
+
If you are ever unsure if a Hoardable record is a source record or a version, you can be sure by calling
|
368
|
+
`version?` on it. If you want to get the true original source record ID, you can call `hoardable_id`.
|
371
369
|
|
372
|
-
|
373
|
-
|
374
|
-
|
375
|
-
|
376
|
-
|
370
|
+
_Note:_ `Hoardable.at` is still very experimental and is potentially not very performant for querying large
|
371
|
+
data sets.
|
372
|
+
|
373
|
+
### Cascading Untrashing
|
374
|
+
|
375
|
+
Sometimes you’ll trash something that `has_many :children, dependent: :destroy` and if you untrash the parent
|
376
|
+
record, you’ll want to also untrash the children. Whenever a hoardable version is created in a database
|
377
|
+
transaction, it will create or re-use a unique event UUID for the current database transaction and tag all
|
378
|
+
versions created with it. That way, when you `untrash!` a record, you could find and `untrash!` records that
|
379
|
+
were trashed with it:
|
377
380
|
|
378
381
|
```ruby
|
379
382
|
class Post < ActiveRecord::Base
|
@@ -392,18 +395,17 @@ end
|
|
392
395
|
|
393
396
|
### Action Text
|
394
397
|
|
395
|
-
Hoardable provides support for ActiveRecord models with `has_rich_text`. First, you must create a
|
396
|
-
|
398
|
+
Hoardable provides support for ActiveRecord models with `has_rich_text`. First, you must create a temporal
|
399
|
+
table for `ActionText::RichText`:
|
397
400
|
|
398
401
|
```
|
399
402
|
bin/rails g hoardable:migration ActionText::RichText
|
400
403
|
bin/rails db:migrate
|
401
404
|
```
|
402
405
|
|
403
|
-
Then in your model
|
404
|
-
`has_rich_text`:
|
406
|
+
Then in your model include `Hoardable::Model` and provide the `hoardable: true` keyword to `has_rich_text`:
|
405
407
|
|
406
|
-
```
|
408
|
+
```ruby
|
407
409
|
class Post < ActiveRecord::Base
|
408
410
|
include Hoardable::Model # or `Hoardable::Associations` if you don't need `PostVersion`
|
409
411
|
has_rich_text :content, hoardable: true
|
@@ -412,7 +414,7 @@ end
|
|
412
414
|
|
413
415
|
Now the `rich_text_content` relationship will be managed as a Hoardable `has_one` relationship:
|
414
416
|
|
415
|
-
```
|
417
|
+
```ruby
|
416
418
|
post = Post.create!(content: '<div>Hello World</div>')
|
417
419
|
datetime = DateTime.current
|
418
420
|
post.update!(content: '<div>Goodbye Cruel World</div>')
|
@@ -423,64 +425,61 @@ Hoardable.at(datetime) do
|
|
423
425
|
end
|
424
426
|
```
|
425
427
|
|
426
|
-
|
428
|
+
## Known Gotchas
|
427
429
|
|
428
|
-
|
430
|
+
### Rails Fixtures
|
429
431
|
|
430
432
|
Rails uses a method called
|
431
433
|
[`disable_referential_integrity`](https://github.com/rails/rails/blob/06e9fbd954ab113108a7982357553fdef285bff1/activerecord/lib/active_record/connection_adapters/postgresql/referential_integrity.rb#L7)
|
432
|
-
when inserting fixtures into the database. This disables PostgreSQL triggers, which Hoardable relies
|
433
|
-
|
434
|
-
|
435
|
-
|
434
|
+
when inserting fixtures into the database. This disables PostgreSQL triggers, which Hoardable relies on for
|
435
|
+
assigning `hoardable_id` from the primary key’s value. If you would still like to use fixtures, you must
|
436
|
+
specify the primary key’s value and `hoardable_id` to the same identifier value in the fixture. This is not
|
437
|
+
an issue with fixture replacement libraries like `factory_bot` or
|
436
438
|
[`world_factory`](https://github.com/FutureProofRetail/world_factory) however.
|
437
439
|
|
438
440
|
## Gem Comparison
|
439
441
|
|
440
442
|
#### [`paper_trail`](https://github.com/paper-trail-gem/paper_trail)
|
441
443
|
|
442
|
-
`paper_trail` is maybe the most popular and fully featured gem in this space. It works for other
|
443
|
-
|
444
|
-
|
445
|
-
|
446
|
-
|
447
|
-
in
|
448
|
-
|
449
|
-
|
450
|
-
for
|
451
|
-
version since there is only a `created_at` timestamp.
|
444
|
+
`paper_trail` is maybe the most popular and fully featured gem in this space. It works for other database
|
445
|
+
types than PostgeSQL and (by default) stores all versions of all versioned models in a single `versions`
|
446
|
+
table. It stores changes in a `text`, `json`, or `jsonb` column. In order to efficiently query the `versions`
|
447
|
+
table, a `jsonb` column should be used, which takes up a lot of space to index. Unless you customize your
|
448
|
+
configuration, all `versions` for all models types are in the same table which is inefficient if you are only
|
449
|
+
interested in querying versions of a single model. By contrast, `hoardable` stores versions in smaller,
|
450
|
+
isolated and inherited tables with the same database columns as their parents, which are more efficient for
|
451
|
+
querying as well as auditing for truncating and dropping. The concept of a `temporal` time-frame does not
|
452
|
+
exist for a single version since there is only a `created_at` timestamp.
|
452
453
|
|
453
454
|
#### [`audited`](https://github.com/collectiveidea/audited)
|
454
455
|
|
455
|
-
`audited` works in a similar manner as `paper_trail`. It stores all versions for all model types in
|
456
|
-
|
457
|
-
|
458
|
-
|
459
|
-
types on the `audited` table.
|
456
|
+
`audited` works in a similar manner as `paper_trail`. It stores all versions for all model types in a single
|
457
|
+
table, you must opt into using `jsonb` as the column type to store "changes", in case you want to query them,
|
458
|
+
and there is no concept of a `temporal` time-frame for a single version. It makes opinionated decisions about
|
459
|
+
contextual data requirements and stores them as top level data types on the `audited` table.
|
460
460
|
|
461
461
|
#### [`discard`](https://github.com/jhawthorn/discard)
|
462
462
|
|
463
|
-
`discard` only covers soft-deletion. The act of "soft deleting" a record is only captured through
|
464
|
-
|
465
|
-
|
466
|
-
|
467
|
-
|
468
|
-
|
463
|
+
`discard` only covers soft-deletion. The act of "soft deleting" a record is only captured through the
|
464
|
+
time-stamping of a `discarded_at` column on the records table; there is no other capturing of the event that
|
465
|
+
caused the soft deletion unless you implement it yourself. Once the "discarded" record is restored, the
|
466
|
+
previous "discarded" awareness is lost. Since "discarded" records exist in the same table as "undiscarded"
|
467
|
+
records, you must explicitly omit the discarded records from queries across your app to keep them from
|
468
|
+
leaking in.
|
469
469
|
|
470
470
|
#### [`paranoia`](https://github.com/rubysherpas/paranoia)
|
471
471
|
|
472
|
-
`paranoia` also only covers soft-deletion. In their README, they recommend using `discard` instead
|
473
|
-
|
474
|
-
|
475
|
-
|
476
|
-
|
472
|
+
`paranoia` also only covers soft-deletion. In their README, they recommend using `discard` instead of
|
473
|
+
`paranoia` because of the fact they override ActiveRecord’s `delete` and `destroy` methods. `hoardable`
|
474
|
+
employs callbacks to create trashed versions instead of overriding methods. Otherwise, `paranoia` works
|
475
|
+
similarly to `discard` in that it keeps deleted records in the same table and tags them with a `deleted_at`
|
476
|
+
timestamp. No other information about the soft-deletion event is stored.
|
477
477
|
|
478
478
|
#### [`logidze`](https://github.com/palkan/logidze)
|
479
479
|
|
480
|
-
`logidze` is an interesting versioning alternative that leverages the power of PostgreSQL triggers.
|
481
|
-
|
482
|
-
|
483
|
-
deletion.
|
480
|
+
`logidze` is an interesting versioning alternative that leverages the power of PostgreSQL triggers. Instead
|
481
|
+
of storing the previous versions or changes in a separate table, it stores them in a proprietary JSON format
|
482
|
+
directly on the database row of the record itself. If does not support soft deletion.
|
484
483
|
|
485
484
|
## Contributing
|
486
485
|
|
@@ -0,0 +1,18 @@
|
|
1
|
+
CREATE OR REPLACE FUNCTION hoardable_source_set_id() RETURNS trigger
|
2
|
+
LANGUAGE plpgsql AS
|
3
|
+
$$
|
4
|
+
DECLARE
|
5
|
+
_pk information_schema.constraint_column_usage.column_name%TYPE;
|
6
|
+
_id _pk%TYPE;
|
7
|
+
BEGIN
|
8
|
+
SELECT c.column_name
|
9
|
+
FROM information_schema.table_constraints t
|
10
|
+
JOIN information_schema.constraint_column_usage c
|
11
|
+
ON c.constraint_name = t.constraint_name
|
12
|
+
WHERE c.table_name = TG_TABLE_NAME AND t.constraint_type = 'PRIMARY KEY'
|
13
|
+
LIMIT 1
|
14
|
+
INTO _pk;
|
15
|
+
EXECUTE format('SELECT $1.%I', _pk) INTO _id USING NEW;
|
16
|
+
NEW.hoardable_id = _id;
|
17
|
+
RETURN NEW;
|
18
|
+
END;$$;
|
@@ -8,6 +8,7 @@ module Hoardable
|
|
8
8
|
class InstallGenerator < Rails::Generators::Base
|
9
9
|
source_root File.expand_path('templates', __dir__)
|
10
10
|
include Rails::Generators::Migration
|
11
|
+
delegate :supports_schema_enums?, to: :class
|
11
12
|
|
12
13
|
def create_initializer_file
|
13
14
|
create_file(
|
@@ -22,12 +23,25 @@ module Hoardable
|
|
22
23
|
)
|
23
24
|
end
|
24
25
|
|
26
|
+
def change_schema_format_to_sql
|
27
|
+
return if supports_schema_enums?
|
28
|
+
|
29
|
+
application 'config.active_record.schema_format = :sql'
|
30
|
+
end
|
31
|
+
|
25
32
|
def create_migration_file
|
26
33
|
migration_template 'install.rb.erb', 'db/migrate/install_hoardable.rb'
|
27
34
|
end
|
28
35
|
|
29
|
-
def
|
30
|
-
|
36
|
+
def create_functions
|
37
|
+
Dir.glob(File.join(__dir__, 'functions', '*.sql')).each do |file_path|
|
38
|
+
file_name = file_path.match(%r{([^/]+)\.sql})[1]
|
39
|
+
template file_path, "db/functions/#{file_name}_v01.sql"
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def self.supports_schema_enums?
|
44
|
+
ActiveRecord.version >= ::Gem::Version.new('7.0.0')
|
31
45
|
end
|
32
46
|
|
33
47
|
def self.next_migration_number(dir)
|
@@ -9,12 +9,30 @@ module Hoardable
|
|
9
9
|
class MigrationGenerator < ActiveRecord::Generators::Base
|
10
10
|
source_root File.expand_path('templates', __dir__)
|
11
11
|
include Rails::Generators::Migration
|
12
|
-
class_option
|
12
|
+
class_option(
|
13
|
+
:foreign_key_type,
|
14
|
+
type: :string,
|
15
|
+
optional: true,
|
16
|
+
desc: 'explictly set / override the foreign key type of the versions table'
|
17
|
+
)
|
13
18
|
|
14
19
|
def create_versions_table
|
15
20
|
migration_template 'migration.rb.erb', "db/migrate/create_#{singularized_table_name}_versions.rb"
|
16
21
|
end
|
17
22
|
|
23
|
+
def create_triggers
|
24
|
+
{
|
25
|
+
versions_prevent_update: singularized_table_name,
|
26
|
+
set_hoardable_id: table_name,
|
27
|
+
prevent_update_hoardable_id: table_name
|
28
|
+
}.each do |(trigger_name, trigger_table_name)|
|
29
|
+
template(
|
30
|
+
"../triggers/#{trigger_name}.sql",
|
31
|
+
"db/triggers/#{trigger_table_name}_#{trigger_name}_v01.sql"
|
32
|
+
)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
18
36
|
no_tasks do
|
19
37
|
def foreign_key_type
|
20
38
|
options[:foreign_key_type] ||
|
@@ -1,65 +1,34 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
class InstallHoardable < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
4
|
-
def
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
4
|
+
def change
|
5
|
+
create_function :hoardable_prevent_update_id
|
6
|
+
create_function :hoardable_source_set_id
|
7
|
+
create_function :hoardable_version_prevent_update
|
8
|
+
<% if supports_schema_enums? %>
|
9
|
+
create_enum :hoardable_operation, %w[update delete insert]
|
10
|
+
<% else %>
|
11
|
+
reversible do |dir|
|
12
|
+
dir.up do
|
13
|
+
execute(
|
14
|
+
<<~SQL.squish
|
15
|
+
DO $$
|
16
|
+
BEGIN
|
17
|
+
IF NOT EXISTS (
|
18
|
+
SELECT 1 FROM pg_type t WHERE t.typname = 'hoardable_operation'
|
19
|
+
) THEN
|
20
|
+
CREATE TYPE hoardable_operation AS ENUM ('update', 'delete', 'insert');
|
21
|
+
END IF;
|
22
|
+
END
|
23
|
+
$$;
|
24
|
+
SQL
|
25
|
+
)
|
26
|
+
end
|
16
27
|
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
_id _pk%TYPE;
|
23
|
-
BEGIN
|
24
|
-
SELECT c.column_name
|
25
|
-
FROM information_schema.table_constraints t
|
26
|
-
JOIN information_schema.constraint_column_usage c
|
27
|
-
ON c.constraint_name = t.constraint_name
|
28
|
-
WHERE c.table_name = TG_TABLE_NAME AND t.constraint_type = 'PRIMARY KEY'
|
29
|
-
LIMIT 1
|
30
|
-
INTO _pk;
|
31
|
-
EXECUTE format('SELECT $1.%I', _pk) INTO _id USING NEW;
|
32
|
-
NEW.hoardable_id = _id;
|
33
|
-
RETURN NEW;
|
34
|
-
END;$$;
|
35
|
-
|
36
|
-
CREATE OR REPLACE FUNCTION hoardable_prevent_update_id() RETURNS trigger
|
37
|
-
LANGUAGE plpgsql AS
|
38
|
-
$$BEGIN
|
39
|
-
IF NEW.hoardable_id <> OLD.hoardable_id THEN
|
40
|
-
RAISE EXCEPTION 'hoardable id cannot be updated';
|
41
|
-
END IF;
|
42
|
-
RETURN NEW;
|
43
|
-
END;$$;
|
44
|
-
|
45
|
-
CREATE OR REPLACE FUNCTION hoardable_version_prevent_update() RETURNS trigger
|
46
|
-
LANGUAGE plpgsql AS
|
47
|
-
$$BEGIN
|
48
|
-
RAISE EXCEPTION 'updating a version is not allowed';
|
49
|
-
RETURN NEW;
|
50
|
-
END;$$;
|
51
|
-
SQL
|
52
|
-
)
|
53
|
-
end
|
54
|
-
|
55
|
-
def down
|
56
|
-
execute(
|
57
|
-
<<~SQL.squish
|
58
|
-
DROP TYPE IF EXISTS hoardable_operation;
|
59
|
-
DROP FUNCTION IF EXISTS hoardable_version_prevent_update();
|
60
|
-
DROP FUNCTION IF EXISTS hoardable_source_set_id();
|
61
|
-
DROP FUNCTION IF EXISTS hoardable_prevent_update_id();
|
62
|
-
SQL
|
63
|
-
)
|
28
|
+
dir.down do
|
29
|
+
execute('DROP TYPE IF EXISTS hoardable_operation;')
|
30
|
+
end
|
31
|
+
end
|
32
|
+
<% end %>
|
64
33
|
end
|
65
34
|
end
|
@@ -12,34 +12,15 @@ class Create<%= class_name.singularize.delete(':') %>Versions < ActiveRecord::Mi
|
|
12
12
|
end
|
13
13
|
reversible do |dir|
|
14
14
|
dir.up do
|
15
|
-
execute(
|
16
|
-
<<~SQL
|
17
|
-
UPDATE <%= table_name %> SET hoardable_id = <%= primary_key %>;
|
18
|
-
CREATE TRIGGER <%= singularized_table_name %>_versions_prevent_update
|
19
|
-
BEFORE UPDATE ON <%= singularized_table_name %>_versions FOR EACH ROW
|
20
|
-
EXECUTE PROCEDURE hoardable_version_prevent_update();
|
21
|
-
CREATE TRIGGER <%= table_name %>_set_hoardable_id
|
22
|
-
BEFORE INSERT ON <%= table_name %> FOR EACH ROW
|
23
|
-
EXECUTE PROCEDURE hoardable_source_set_id();
|
24
|
-
CREATE TRIGGER <%= table_name %>_prevent_update_hoardable_id
|
25
|
-
BEFORE UPDATE ON <%= table_name %> FOR EACH ROW
|
26
|
-
EXECUTE PROCEDURE hoardable_prevent_update_id();
|
27
|
-
SQL
|
28
|
-
)
|
29
|
-
end
|
30
|
-
dir.down do
|
31
|
-
execute(
|
32
|
-
<<~SQL
|
33
|
-
DROP TRIGGER <%= singularized_table_name %>_versions_prevent_update
|
34
|
-
ON <%= singularized_table_name %>_versions;
|
35
|
-
DROP TRIGGER <%= table_name %>_set_hoardable_id
|
36
|
-
ON <%= table_name %>;
|
37
|
-
DROP TRIGGER <%= table_name %>_prevent_update_hoardable_id
|
38
|
-
ON <%= table_name %>;
|
39
|
-
SQL
|
40
|
-
)
|
15
|
+
execute('UPDATE <%= table_name %> SET hoardable_id = <%= primary_key %>;')
|
41
16
|
end
|
42
17
|
end
|
18
|
+
create_trigger(
|
19
|
+
:<%= singularized_table_name %>_versions_prevent_update,
|
20
|
+
on: :<%= singularized_table_name %>_versions
|
21
|
+
)
|
22
|
+
create_trigger :<%= table_name %>_set_hoardable_id, on: :<%= table_name %>
|
23
|
+
create_trigger :<%= table_name %>_prevent_update_hoardable_id, on: :<%= table_name %>
|
43
24
|
change_column_null :<%= table_name %>, :hoardable_id, false
|
44
25
|
add_index :<%= singularized_table_name %>_versions, :<%= primary_key %>, unique: true
|
45
26
|
add_index :<%= singularized_table_name %>_versions, :hoardable_id
|
@@ -55,7 +55,7 @@ module Hoardable
|
|
55
55
|
end
|
56
56
|
|
57
57
|
def source_attributes_without_primary_key
|
58
|
-
source_record.
|
58
|
+
source_record.attributes.without(source_primary_key, *generated_column_names).merge(
|
59
59
|
source_record.class.select(refreshable_column_names).find(source_record.id).slice(refreshable_column_names)
|
60
60
|
)
|
61
61
|
end
|
data/lib/hoardable/engine.rb
CHANGED
@@ -4,7 +4,7 @@
|
|
4
4
|
module Hoardable
|
5
5
|
# Symbols for use with setting contextual data, when creating versions. See
|
6
6
|
# {file:README.md#tracking-contextual-data README} for more.
|
7
|
-
DATA_KEYS = %i[meta whodunit
|
7
|
+
DATA_KEYS = %i[meta whodunit event_uuid].freeze
|
8
8
|
|
9
9
|
# Symbols for use with setting {Hoardable} configuration. See {file:README.md#configuration
|
10
10
|
# README} for more.
|
data/lib/hoardable/scopes.rb
CHANGED
@@ -73,7 +73,7 @@ module Hoardable
|
|
73
73
|
private
|
74
74
|
|
75
75
|
def tableoid
|
76
|
-
connection.execute("SELECT oid FROM pg_class WHERE relname = '#{table_name}'")[0]['oid']
|
76
|
+
@tableoid ||= connection.execute("SELECT oid FROM pg_class WHERE relname = '#{table_name}'")[0]['oid']
|
77
77
|
end
|
78
78
|
end
|
79
79
|
end
|
data/lib/hoardable/version.rb
CHANGED
@@ -130,7 +130,7 @@ module Hoardable
|
|
130
130
|
end
|
131
131
|
|
132
132
|
def hoardable_source_attributes
|
133
|
-
|
133
|
+
attributes.without(
|
134
134
|
(self.class.column_names - self.class.superclass.column_names) +
|
135
135
|
(SUPPORTS_VIRTUAL_COLUMNS ? self.class.columns.select(&:virtual?).map(&:name) : [])
|
136
136
|
)
|
data/sig/hoardable.rbs
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
module Hoardable
|
2
2
|
VERSION: String
|
3
|
-
DATA_KEYS: [:meta, :whodunit, :
|
3
|
+
DATA_KEYS: [:meta, :whodunit, :event_uuid]
|
4
4
|
CONFIG_KEYS: [:enabled, :version_updates, :save_trash]
|
5
5
|
VERSION_CLASS_SUFFIX: String
|
6
6
|
VERSION_TABLE_SUFFIX: String
|
@@ -57,7 +57,7 @@ module Hoardable
|
|
57
57
|
def source_attributes_without_primary_key: -> untyped
|
58
58
|
def initialize_temporal_range: -> Range
|
59
59
|
def initialize_hoardable_data: -> untyped
|
60
|
-
def assign_hoardable_context: (:event_uuid | :meta | :
|
60
|
+
def assign_hoardable_context: (:event_uuid | :meta | :whodunit key) -> nil
|
61
61
|
def unset_hoardable_version_and_event_uuid: -> nil
|
62
62
|
def previous_temporal_tsrange_end: -> untyped
|
63
63
|
def hoardable_source_epoch: -> untyped
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: hoardable
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.14.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- justin talbott
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2023-03-19 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|
@@ -70,6 +70,26 @@ dependencies:
|
|
70
70
|
- - "<"
|
71
71
|
- !ruby/object:Gem::Version
|
72
72
|
version: '8'
|
73
|
+
- !ruby/object:Gem::Dependency
|
74
|
+
name: fx
|
75
|
+
requirement: !ruby/object:Gem::Requirement
|
76
|
+
requirements:
|
77
|
+
- - ">="
|
78
|
+
- !ruby/object:Gem::Version
|
79
|
+
version: '0.8'
|
80
|
+
- - "<"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '1'
|
83
|
+
type: :runtime
|
84
|
+
prerelease: false
|
85
|
+
version_requirements: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0.8'
|
90
|
+
- - "<"
|
91
|
+
- !ruby/object:Gem::Version
|
92
|
+
version: '1'
|
73
93
|
- !ruby/object:Gem::Dependency
|
74
94
|
name: pg
|
75
95
|
requirement: !ruby/object:Gem::Requirement
|
@@ -104,10 +124,16 @@ files:
|
|
104
124
|
- LICENSE.txt
|
105
125
|
- README.md
|
106
126
|
- Rakefile
|
127
|
+
- lib/generators/hoardable/functions/hoardable_prevent_update_id.sql
|
128
|
+
- lib/generators/hoardable/functions/hoardable_source_set_id.sql
|
129
|
+
- lib/generators/hoardable/functions/hoardable_version_prevent_update.sql
|
107
130
|
- lib/generators/hoardable/install_generator.rb
|
108
131
|
- lib/generators/hoardable/migration_generator.rb
|
109
132
|
- lib/generators/hoardable/templates/install.rb.erb
|
110
133
|
- lib/generators/hoardable/templates/migration.rb.erb
|
134
|
+
- lib/generators/hoardable/triggers/prevent_update_hoardable_id.sql
|
135
|
+
- lib/generators/hoardable/triggers/set_hoardable_id.sql
|
136
|
+
- lib/generators/hoardable/triggers/versions_prevent_update.sql
|
111
137
|
- lib/hoardable.rb
|
112
138
|
- lib/hoardable/associations.rb
|
113
139
|
- lib/hoardable/belongs_to.rb
|
@@ -141,14 +167,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
141
167
|
requirements:
|
142
168
|
- - ">="
|
143
169
|
- !ruby/object:Gem::Version
|
144
|
-
version: 2.
|
170
|
+
version: 2.7.0
|
145
171
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
146
172
|
requirements:
|
147
173
|
- - ">="
|
148
174
|
- !ruby/object:Gem::Version
|
149
175
|
version: '0'
|
150
176
|
requirements: []
|
151
|
-
rubygems_version: 3.
|
177
|
+
rubygems_version: 3.4.6
|
152
178
|
signing_key:
|
153
179
|
specification_version: 4
|
154
180
|
summary: An ActiveRecord extension for versioning and soft-deletion of records in
|