hoardable 0.12.9 → 0.14.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/.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 
|
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
|