hoardable 0.14.2 → 0.16.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.streerc +1 -0
- data/.tool-versions +2 -2
- data/CHANGELOG.md +19 -0
- data/Gemfile +9 -10
- data/README.md +198 -177
- data/Rakefile +22 -8
- data/lib/generators/hoardable/install_generator.rb +25 -26
- data/lib/generators/hoardable/migration_generator.rb +17 -8
- data/lib/generators/hoardable/templates/install.rb.erb +2 -25
- data/lib/generators/hoardable/templates/migration.rb.erb +7 -1
- data/lib/hoardable/arel_visitors.rb +57 -0
- data/lib/hoardable/database_client.rb +41 -23
- data/lib/hoardable/engine.rb +32 -33
- data/lib/hoardable/error.rb +4 -7
- data/lib/hoardable/finder_methods.rb +1 -3
- data/lib/hoardable/has_many.rb +6 -10
- data/lib/hoardable/has_one.rb +3 -3
- data/lib/hoardable/has_rich_text.rb +14 -7
- data/lib/hoardable/model.rb +19 -16
- data/lib/hoardable/schema_dumper.rb +25 -0
- data/lib/hoardable/schema_statements.rb +33 -0
- data/lib/hoardable/scopes.rb +22 -29
- data/lib/hoardable/source_model.rb +6 -5
- data/lib/hoardable/version.rb +1 -1
- data/lib/hoardable/version_model.rb +30 -31
- data/lib/hoardable.rb +21 -18
- data/sig/hoardable.rbs +37 -12
- metadata +14 -29
- data/.rubocop.yml +0 -21
data/README.md
CHANGED
@@ -1,21 +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
|
4
|
-
and soft-deletion of records through the use of _uni-temporal inherited tables_.
|
3
|
+
Hoardable is an ActiveRecord extension for Ruby 3+, Rails 7+, and PostgreSQL 9+ that allows for
|
4
|
+
versioning 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
|
-
row of a table contains data along with one or more time ranges. In the case of this gem,
|
8
|
-
has a time range that represents the row’s valid time range - hence
|
6
|
+
[Temporal tables](https://en.wikipedia.org/wiki/Temporal_database) are a database design pattern
|
7
|
+
where each row of a table contains data along with one or more time ranges. In the case of this gem,
|
8
|
+
each database row has a time range that represents the row’s valid time range - hence
|
9
|
+
"uni-temporal".
|
9
10
|
|
10
|
-
[Table inheritance](https://www.postgresql.org/docs/
|
11
|
-
allows
|
12
|
-
its parent
|
13
|
-
descendants.
|
11
|
+
[Table inheritance](https://www.postgresql.org/docs/current/ddl-inherit.html) is a feature of
|
12
|
+
PostgreSQL that allows one table to inherit all columns from a parent. The descendant table’s schema
|
13
|
+
will stay in sync with its parent; if a new column is added to or removed from the parent, the
|
14
|
+
schema change is reflected on its descendants.
|
14
15
|
|
15
|
-
With these concepts combined, `hoardable` offers a
|
16
|
-
Versions of records are stored in separate, inherited tables along with their valid time
|
17
|
-
contextual data.
|
18
|
-
and obvious on the lower database level, while still familiar and convenient to use within Ruby on Rails.
|
16
|
+
With these concepts combined, `hoardable` offers a model versioning and soft deletion system for
|
17
|
+
Rails. Versions of records are stored in separate, inherited tables along with their valid time
|
18
|
+
ranges and contextual data.
|
19
19
|
|
20
20
|
[👉 Documentation](https://www.rubydoc.info/gems/hoardable)
|
21
21
|
|
@@ -24,7 +24,7 @@ and obvious on the lower database level, while still familiar and convenient to
|
|
24
24
|
Add this line to your application's Gemfile:
|
25
25
|
|
26
26
|
```ruby
|
27
|
-
gem
|
27
|
+
gem "hoardable"
|
28
28
|
```
|
29
29
|
|
30
30
|
Run `bundle install`, and then run:
|
@@ -34,12 +34,9 @@ bin/rails g hoardable:install
|
|
34
34
|
bin/rails db:migrate
|
35
35
|
```
|
36
36
|
|
37
|
-
|
38
|
-
`config.active_record.schema_format = :sql` in `application.rb` if you are using Rails < 7.
|
37
|
+
### Model installation
|
39
38
|
|
40
|
-
|
41
|
-
|
42
|
-
You must include `Hoardable::Model` into an ActiveRecord model that you would like to hoard versions of:
|
39
|
+
Include `Hoardable::Model` into an ActiveRecord model you would like to hoard versions of:
|
43
40
|
|
44
41
|
```ruby
|
45
42
|
class Post < ActiveRecord::Base
|
@@ -48,38 +45,31 @@ class Post < ActiveRecord::Base
|
|
48
45
|
end
|
49
46
|
```
|
50
47
|
|
51
|
-
|
48
|
+
Run the generator command to create a database migration and migrate it:
|
52
49
|
|
53
50
|
```
|
54
51
|
bin/rails g hoardable:migration Post
|
55
52
|
bin/rails db:migrate
|
56
53
|
```
|
57
54
|
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
```
|
62
|
-
bin/rails g hoardable:migration Post --foreign-key-type uuid
|
63
|
-
```
|
64
|
-
|
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.
|
55
|
+
_*Note*:_ Creating an inherited table does not inherit the indexes from the parent table. If you
|
56
|
+
need to query versions often, you should add appropriate indexes to the `_versions` tables.
|
67
57
|
|
68
58
|
## Usage
|
69
59
|
|
70
60
|
### Overview
|
71
61
|
|
72
|
-
Once you include `Hoardable::Model` into a model, it will dynamically generate a "Version" subclass
|
73
|
-
model. As we continue our example from above:
|
62
|
+
Once you include `Hoardable::Model` into a model, it will dynamically generate a "Version" subclass
|
63
|
+
of that model. As we continue our example from above:
|
74
64
|
|
75
65
|
```ruby
|
76
|
-
Post #=> Post(id: integer,
|
77
|
-
PostVersion #=> PostVersion(id: integer,
|
66
|
+
Post #=> Post(id: integer, ..., hoardable_id: integer)
|
67
|
+
PostVersion #=> PostVersion(id: integer, ..., hoardable_id: integer, _data: jsonb, _during: tsrange, _event_uuid: uuid, _operation: enum)
|
78
68
|
Post.version_class #=> same as `PostVersion`
|
79
69
|
```
|
80
70
|
|
81
|
-
A `Post` now `has_many :versions`. With the default configuration, whenever an update
|
82
|
-
`
|
71
|
+
A `Post` now `has_many :versions`. With the default configuration, whenever an update or deletion of
|
72
|
+
a `post` occurs, a version is created:
|
83
73
|
|
84
74
|
```ruby
|
85
75
|
post = Post.create!(title: "Title")
|
@@ -96,7 +86,7 @@ Post.find(post.id) # raises ActiveRecord::RecordNotFound
|
|
96
86
|
Each `PostVersion` has access to the same attributes, relationships, and other model behavior that
|
97
87
|
`Post` has, but as a read-only record:
|
98
88
|
|
99
|
-
```
|
89
|
+
```ruby
|
100
90
|
post.versions.last.update!(title: "Rewrite history") #=> raises ActiveRecord::ReadOnlyRecord
|
101
91
|
```
|
102
92
|
|
@@ -105,31 +95,60 @@ If you ever need to revert to a specific version, you can call `version.revert!`
|
|
105
95
|
```ruby
|
106
96
|
post = Post.create!(title: "Title")
|
107
97
|
post.update!(title: "Whoops")
|
108
|
-
post.reload.versions.last
|
98
|
+
version = post.reload.versions.last
|
99
|
+
version.title # -> "Title"
|
100
|
+
version.revert!
|
109
101
|
post.title # => "Title"
|
110
102
|
```
|
111
103
|
|
112
|
-
If you would like to untrash a specific version of a record you deleted, you can call
|
113
|
-
it. This will re-insert the model in the parent class’s table with the
|
104
|
+
If you would like to untrash a specific version of a record you deleted, you can call
|
105
|
+
`version.untrash!` on it. This will re-insert the model in the parent class’s table with the
|
106
|
+
original primary key.
|
114
107
|
|
115
108
|
```ruby
|
116
109
|
post = Post.create!(title: "Title")
|
117
|
-
post.id # => 1
|
118
110
|
post.destroy!
|
119
111
|
post.versions.size # => 1
|
120
112
|
Post.find(post.id) # raises ActiveRecord::RecordNotFound
|
121
113
|
trashed_post = post.versions.trashed.last
|
122
|
-
trashed_post.id # => 2
|
123
114
|
trashed_post.untrash!
|
124
115
|
Post.find(post.id) # #<Post>
|
125
116
|
```
|
126
117
|
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
118
|
+
Source and version records pull from the same ID sequence. This allows for uniquely identifying
|
119
|
+
records from each other. Both source record and version have an automatically managed `hoardable_id`
|
120
|
+
attribute that always represents the primary key value of the original source record:
|
121
|
+
|
122
|
+
```ruby
|
123
|
+
post = Post.create!(title: "Title")
|
124
|
+
post.id # => 1
|
125
|
+
post.hoardable_id # => 1
|
126
|
+
post.version? # => false
|
127
|
+
post.update!(title: "New Title")
|
128
|
+
version = post.reload.versions.last
|
129
|
+
version.id # => 2
|
130
|
+
version.hoardable_id # => 1
|
131
|
+
version.version? # => true
|
132
|
+
```
|
133
|
+
|
134
|
+
### Querying and temporal lookup
|
135
|
+
|
136
|
+
Including `Hoardable::Model` into your source model modifies `default_scope` to make sure you only
|
137
|
+
ever query the parent table and not the inherited ones:
|
138
|
+
|
139
|
+
```ruby
|
140
|
+
Post.where(state: :draft).to_sql # => SELECT posts.* FROM ONLY posts WHERE posts.status = 'draft'
|
141
|
+
```
|
142
|
+
|
143
|
+
Note the `FROM ONLY` above. If you are executing raw SQL, you will need to include the `ONLY`
|
144
|
+
keyword if you do not wish to return versions in your results. This includes `JOIN`-ing on this
|
145
|
+
table as well.
|
146
|
+
|
147
|
+
```ruby
|
148
|
+
User.joins(:posts).to_sql # => SELECT users.* FROM users INNER JOIN ONLY posts ON posts.user_id = users.id
|
149
|
+
```
|
131
150
|
|
132
|
-
|
151
|
+
Learn more about table inheritance in [the PostgreSQL documentation](https://www.postgresql.org/docs/current/ddl-inherit.html).
|
133
152
|
|
134
153
|
Since a `PostVersion` is an `ActiveRecord` class, you can query them like another model resource:
|
135
154
|
|
@@ -137,6 +156,14 @@ Since a `PostVersion` is an `ActiveRecord` class, you can query them like anothe
|
|
137
156
|
post.versions.where(state: :draft)
|
138
157
|
```
|
139
158
|
|
159
|
+
By default, `hoardable` will keep copies of records you have destroyed. You can query them
|
160
|
+
specifically with:
|
161
|
+
|
162
|
+
```ruby
|
163
|
+
PostVersion.trashed.where(user_id: user.id)
|
164
|
+
Post.version_class.trashed.where(user_id: user.id) # <- same as above
|
165
|
+
```
|
166
|
+
|
140
167
|
If you want to look-up the version of a record at a specific time, you can use the `.at` method:
|
141
168
|
|
142
169
|
```ruby
|
@@ -152,32 +179,22 @@ The source model class also has an `.at` method:
|
|
152
179
|
Post.at(1.day.ago) # => [#<Post>, #<Post>]
|
153
180
|
```
|
154
181
|
|
155
|
-
This will return an ActiveRecord scoped query of all `Post` and `PostVersion` records that were
|
156
|
-
time, all cast as instances of `Post`.
|
157
|
-
|
158
|
-
There is also an `at` method on `Hoardable` itself for more complex and experimental temporal resource
|
159
|
-
querying. See [Relationships](#relationships) for more.
|
160
|
-
|
161
|
-
By default, `hoardable` will keep copies of records you have destroyed. You can query them specifically with:
|
162
|
-
|
163
|
-
```ruby
|
164
|
-
PostVersion.trashed
|
165
|
-
Post.version_class.trashed # <- same as above
|
166
|
-
```
|
182
|
+
This will return an ActiveRecord scoped query of all `Post` and `PostVersion` records that were
|
183
|
+
valid at that time, all cast as instances of `Post`. Updates to the versions table are forbidden in
|
184
|
+
this case by a database trigger.
|
167
185
|
|
168
|
-
|
169
|
-
|
170
|
-
column. If this is missing, an error will be raised.
|
186
|
+
There is also `Hoardable.at` for more complex and experimental temporal resource querying. See
|
187
|
+
[Relationships](#relationships) for more.
|
171
188
|
|
172
|
-
### Tracking
|
189
|
+
### Tracking contextual data
|
173
190
|
|
174
|
-
You’ll often want to track contextual data about the creation of a version. There are 2 options that
|
175
|
-
provided for tracking
|
191
|
+
You’ll often want to track contextual data about the creation of a version. There are 2 options that
|
192
|
+
can be provided for tracking this:
|
176
193
|
|
177
|
-
- `:whodunit` - an identifier for who is responsible for creating the version
|
194
|
+
- `:whodunit` - an identifier for who/what is responsible for creating the version
|
178
195
|
- `:meta` - any other contextual information you’d like to store along with the version
|
179
196
|
|
180
|
-
This information is stored in a `jsonb` column. Each
|
197
|
+
This information is stored in a `jsonb` column. Each value can be the data type of your choosing.
|
181
198
|
|
182
199
|
One convenient way to assign contextual data to these is by defining a proc in an initializer, i.e.:
|
183
200
|
|
@@ -186,22 +203,14 @@ One convenient way to assign contextual data to these is by defining a proc in a
|
|
186
203
|
Hoardable.whodunit = -> { Current.user&.id }
|
187
204
|
|
188
205
|
# somewhere in your app code
|
189
|
-
Current.user
|
190
|
-
post.update!(status:
|
191
|
-
post.reload.versions.last.hoardable_whodunit # => 123
|
192
|
-
|
193
|
-
|
194
|
-
You can also set this context manually as well:
|
195
|
-
|
196
|
-
```ruby
|
197
|
-
Hoardable.meta = { note: "reverting due to accidental deletion" }
|
198
|
-
post.update!(title: "We’re back!")
|
199
|
-
Hoardable.meta = nil
|
200
|
-
post.reload.versions.last.hoardable_meta['note'] # => "reverting due to accidental deletion"
|
206
|
+
Current.set(user: User.find(123)) do
|
207
|
+
post.update!(status: :live)
|
208
|
+
post.reload.versions.last.hoardable_whodunit # => 123
|
209
|
+
end
|
201
210
|
```
|
202
211
|
|
203
|
-
|
204
|
-
could have the following in your `ApplicationController`:
|
212
|
+
Another useful pattern would be to use `Hoardable.with` to set the context around a block. For
|
213
|
+
example, you could have the following in your `ApplicationController`:
|
205
214
|
|
206
215
|
```ruby
|
207
216
|
class ApplicationController < ActionController::Base
|
@@ -213,30 +222,27 @@ class ApplicationController < ActionController::Base
|
|
213
222
|
Hoardable.with(whodunit: current_user.id, meta: { request_uuid: request.uuid }) do
|
214
223
|
yield
|
215
224
|
end
|
216
|
-
# `Hoardable.whodunit` and `Hoardable.meta` are back to nil or their previously set values
|
217
225
|
end
|
218
226
|
end
|
219
227
|
```
|
220
228
|
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
transaction with a shared and unique `event_uuid` for that transaction. These are available as:
|
229
|
+
[ActiveRecord changes](https://api.rubyonrails.org/classes/ActiveModel/Dirty.html#method-i-changes)
|
230
|
+
are also automatically captured along with the `operation` that caused the version (`update` or
|
231
|
+
`delete`). These values are available as:
|
225
232
|
|
226
233
|
```ruby
|
227
|
-
version.changes
|
228
|
-
version.hoardable_operation
|
229
|
-
version.hoardable_event_uuid
|
234
|
+
version.changes # => { "title"=> ["Title", "New Title"] }
|
235
|
+
version.hoardable_operation # => "update"
|
230
236
|
```
|
231
237
|
|
232
238
|
### Model Callbacks
|
233
239
|
|
234
|
-
Sometimes you might want to do something with a version after it gets inserted to the database. You
|
235
|
-
access it in `after_versioned` callbacks on the source record as `hoardable_version`. These
|
236
|
-
`ActiveRecord
|
240
|
+
Sometimes you might want to do something with a version after it gets inserted to the database. You
|
241
|
+
can access it in `after_versioned` callbacks on the source record as `hoardable_version`. These
|
242
|
+
happen within `ActiveRecord#save`'s transaction.
|
237
243
|
|
238
|
-
There are also `after_reverted` and `after_untrashed` callbacks available as well, which are called
|
239
|
-
source record after a version is reverted or untrashed.
|
244
|
+
There are also `after_reverted` and `after_untrashed` callbacks available as well, which are called
|
245
|
+
on the source record after a version is reverted or untrashed.
|
240
246
|
|
241
247
|
```ruby
|
242
248
|
class User
|
@@ -266,23 +272,24 @@ end
|
|
266
272
|
The configurable options are:
|
267
273
|
|
268
274
|
```ruby
|
269
|
-
Hoardable.enabled # =>
|
270
|
-
Hoardable.version_updates # =>
|
271
|
-
Hoardable.save_trash # =>
|
275
|
+
Hoardable.enabled # => true
|
276
|
+
Hoardable.version_updates # => true
|
277
|
+
Hoardable.save_trash # => true
|
272
278
|
```
|
273
279
|
|
274
280
|
`Hoardable.enabled` globally controls whether versions will be ever be created.
|
275
281
|
|
276
282
|
`Hoardable.version_updates` globally controls whether versions get created on record updates.
|
277
283
|
|
278
|
-
`Hoardable.save_trash` globally controls whether to create versions upon record deletion.
|
279
|
-
`false`, all versions of a record will be deleted when the record is
|
284
|
+
`Hoardable.save_trash` globally controls whether to create versions upon source record deletion.
|
285
|
+
When this is set to `false`, all versions of a source record will be deleted when the record is
|
286
|
+
destroyed.
|
280
287
|
|
281
|
-
If you would like to temporarily set a config
|
288
|
+
If you would like to temporarily set a config value, you can use `Hoardable.with`:
|
282
289
|
|
283
290
|
```ruby
|
284
291
|
Hoardable.with(enabled: false) do
|
285
|
-
post.update!(title:
|
292
|
+
post.update!(title: "replace title without creating a version")
|
286
293
|
end
|
287
294
|
```
|
288
295
|
|
@@ -304,30 +311,40 @@ Comment.with_hoardable_config(version_updates: true) do
|
|
304
311
|
end
|
305
312
|
```
|
306
313
|
|
307
|
-
|
308
|
-
config.
|
314
|
+
Model-level configuration overrides global configuration.
|
309
315
|
|
310
316
|
## Relationships
|
311
317
|
|
312
|
-
###
|
318
|
+
### `belongs_to`
|
313
319
|
|
314
|
-
Sometimes you’ll have a record that belongs to a parent record that you’ll trash. Now the child
|
315
|
-
foreign key will point to the non-existent trashed version of the parent. If you would like
|
316
|
-
`belongs_to` resolve to the trashed parent model in this case, you can give it the option of
|
317
|
-
true`:
|
320
|
+
Sometimes you’ll have a record that belongs to a parent record that you’ll trash. Now the child
|
321
|
+
record’s foreign key will point to the non-existent trashed version of the parent. If you would like
|
322
|
+
to have `belongs_to` resolve to the trashed parent model in this case, you can give it the option of
|
323
|
+
`trashable: true`:
|
318
324
|
|
319
325
|
```ruby
|
326
|
+
class Post
|
327
|
+
include Hoardable::Model
|
328
|
+
has_many :comments, dependent: nil
|
329
|
+
end
|
330
|
+
|
320
331
|
class Comment
|
321
332
|
include Hoardable::Associations # <- This includes is not required if this model already includes `Hoardable::Model`
|
322
333
|
belongs_to :post, trashable: true
|
323
334
|
end
|
335
|
+
|
336
|
+
post = Post.create!(title: "Title")
|
337
|
+
comment = post.comments.create!(body: "Comment")
|
338
|
+
post.destroy!
|
339
|
+
comment.post # => #<PostVersion>
|
324
340
|
```
|
325
341
|
|
326
|
-
###
|
342
|
+
### `has_many` & `has_one`
|
327
343
|
|
328
|
-
Sometimes you'll have a Hoardable record that `has_one` or `has_many` other Hoardable records and
|
329
|
-
want to know the state of both the parent record and the children at a certain point in time.
|
330
|
-
this by adding `hoardable: true` to the `has_many` relationship and using the
|
344
|
+
Sometimes you'll have a Hoardable record that `has_one` or `has_many` other Hoardable records and
|
345
|
+
you’ll want to know the state of both the parent record and the children at a certain point in time.
|
346
|
+
You can accomplish this by adding `hoardable: true` to the `has_many` relationship and using the
|
347
|
+
`Hoardable.at` method:
|
331
348
|
|
332
349
|
```ruby
|
333
350
|
class Post
|
@@ -335,53 +352,48 @@ class Post
|
|
335
352
|
has_many :comments, hoardable: true
|
336
353
|
end
|
337
354
|
|
338
|
-
|
355
|
+
class Comment
|
339
356
|
include Hoardable::Model
|
340
357
|
end
|
341
358
|
|
342
|
-
post = Post.create!(title:
|
343
|
-
comment1 = post.comments.create!(body:
|
344
|
-
comment2 = post.comments.create!(body:
|
359
|
+
post = Post.create!(title: "Title")
|
360
|
+
comment1 = post.comments.create!(body: "Comment")
|
361
|
+
comment2 = post.comments.create!(body: "Comment")
|
345
362
|
datetime = DateTime.current
|
363
|
+
|
346
364
|
comment2.destroy!
|
347
|
-
post.update!(title:
|
365
|
+
post.update!(title: "New Title")
|
348
366
|
post_id = post.id # 1
|
349
367
|
|
350
368
|
Hoardable.at(datetime) do
|
351
369
|
post = Post.find(post_id)
|
352
|
-
post.title # =>
|
370
|
+
post.title # => "Title"
|
353
371
|
post.comments.size # => 2
|
354
|
-
post.id # => 2
|
355
372
|
post.version? # => true
|
373
|
+
post.id # => 2
|
356
374
|
post.hoardable_id # => 1
|
357
375
|
end
|
358
376
|
```
|
359
377
|
|
360
|
-
|
361
|
-
|
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.
|
366
|
-
|
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`.
|
369
|
-
|
370
|
-
_Note:_ `Hoardable.at` is still very experimental and is potentially not very performant for querying large
|
371
|
-
data sets.
|
378
|
+
_*Note*:_ `Hoardable.at` is experimental and potentially not performant for querying very large data
|
379
|
+
sets.
|
372
380
|
|
373
381
|
### Cascading Untrashing
|
374
382
|
|
375
|
-
Sometimes you’ll trash something that `has_many :children, dependent: :destroy` and if you untrash
|
376
|
-
record, you’ll want to also untrash the children. Whenever a hoardable
|
377
|
-
|
378
|
-
|
379
|
-
|
383
|
+
Sometimes you’ll trash something that `has_many :children, dependent: :destroy` and if you untrash
|
384
|
+
the parent record, you’ll want to also untrash the children. Whenever a hoardable versions are
|
385
|
+
created, it will share a unique event UUID for all other versions created in the same database
|
386
|
+
transaction. That way, when you `untrash!` a record, you could find and `untrash!` records that were
|
387
|
+
trashed with it:
|
380
388
|
|
381
389
|
```ruby
|
390
|
+
class Comment < ActiveRecord::Base
|
391
|
+
include Hoardable::Model
|
392
|
+
end
|
393
|
+
|
382
394
|
class Post < ActiveRecord::Base
|
383
395
|
include Hoardable::Model
|
384
|
-
has_many :comments, hoardable: true, dependent: :destroy
|
396
|
+
has_many :comments, hoardable: true, dependent: :destroy
|
385
397
|
|
386
398
|
after_untrashed do
|
387
399
|
Comment
|
@@ -395,20 +407,21 @@ end
|
|
395
407
|
|
396
408
|
### Action Text
|
397
409
|
|
398
|
-
Hoardable provides support for ActiveRecord models with `has_rich_text`. First, you must create a
|
399
|
-
table for `ActionText::RichText`:
|
410
|
+
Hoardable provides support for ActiveRecord models with `has_rich_text`. First, you must create a
|
411
|
+
temporal table for `ActionText::RichText`:
|
400
412
|
|
401
413
|
```
|
402
414
|
bin/rails g hoardable:migration ActionText::RichText
|
403
415
|
bin/rails db:migrate
|
404
416
|
```
|
405
417
|
|
406
|
-
Then in your model include `Hoardable::Model` and provide the `hoardable: true` keyword to
|
418
|
+
Then in your model include `Hoardable::Model` and provide the `hoardable: true` keyword to
|
419
|
+
`has_rich_text`:
|
407
420
|
|
408
421
|
```ruby
|
409
422
|
class Post < ActiveRecord::Base
|
410
423
|
include Hoardable::Model # or `Hoardable::Associations` if you don't need `PostVersion`
|
411
|
-
has_rich_text :content, hoardable: true
|
424
|
+
has_rich_text :content, hoardable: true # or `has_hoardable_rich_text :content`
|
412
425
|
end
|
413
426
|
```
|
414
427
|
|
@@ -425,65 +438,73 @@ Hoardable.at(datetime) do
|
|
425
438
|
end
|
426
439
|
```
|
427
440
|
|
428
|
-
## Known
|
441
|
+
## Known gotchas
|
429
442
|
|
430
|
-
### Rails
|
443
|
+
### Rails fixtures
|
431
444
|
|
432
445
|
Rails uses a method called
|
433
446
|
[`disable_referential_integrity`](https://github.com/rails/rails/blob/06e9fbd954ab113108a7982357553fdef285bff1/activerecord/lib/active_record/connection_adapters/postgresql/referential_integrity.rb#L7)
|
434
|
-
when inserting fixtures into the database. This disables PostgreSQL triggers, which Hoardable relies
|
435
|
-
assigning `hoardable_id` from the primary key’s value. If you would still like to use
|
436
|
-
specify the primary key’s value and `hoardable_id` to the same identifier value
|
437
|
-
|
438
|
-
[`world_factory`](https://github.com/FutureProofRetail/world_factory) however.
|
447
|
+
when inserting fixtures into the database. This disables PostgreSQL triggers, which Hoardable relies
|
448
|
+
on for assigning `hoardable_id` from the primary key’s value. If you would still like to use
|
449
|
+
fixtures, you must specify the primary key’s value and `hoardable_id` to the same identifier value
|
450
|
+
in the fixture.
|
439
451
|
|
440
|
-
## Gem
|
452
|
+
## Gem comparison
|
441
453
|
|
442
454
|
#### [`paper_trail`](https://github.com/paper-trail-gem/paper_trail)
|
443
455
|
|
444
|
-
`paper_trail` is maybe the most popular and fully featured gem in this space. It works for other
|
445
|
-
types than PostgeSQL
|
446
|
-
table. It stores changes in a `text`, `json`, or `jsonb` column. In order to
|
447
|
-
table, a `jsonb` column should be used, which
|
448
|
-
configuration, all `versions` for all models types are in
|
449
|
-
|
450
|
-
|
451
|
-
|
452
|
-
|
456
|
+
`paper_trail` is maybe the most popular and fully featured gem in this space. It works for other
|
457
|
+
database types than PostgeSQL. Bby default it stores all versions of all versioned models in a
|
458
|
+
single `versions` table. It stores changes in a `text`, `json`, or `jsonb` column. In order to
|
459
|
+
efficiently query the `versions` table, a `jsonb` column should be used, which can take up a lot of
|
460
|
+
space to index. Unless you customize your configuration, all `versions` for all models types are in
|
461
|
+
the same table which is inefficient if you are only interested in querying versions of a single
|
462
|
+
model. By contrast, `hoardable` stores versions in smaller, isolated, inherited tables with the same
|
463
|
+
database columns as their parents, which are more efficient for querying as well as auditing for
|
464
|
+
truncating and dropping. The concept of a temporal timeframe does not exist for a single version
|
465
|
+
since there is only a `created_at` timestamp.
|
453
466
|
|
454
467
|
#### [`audited`](https://github.com/collectiveidea/audited)
|
455
468
|
|
456
|
-
`audited` works in a similar manner as `paper_trail`. It stores all versions for all model types in
|
457
|
-
table, you must opt into using `jsonb` as the column type to store "changes", in case you
|
458
|
-
and there is no concept of a
|
459
|
-
contextual data requirements and stores them as top level data types on
|
469
|
+
`audited` works in a similar manner as `paper_trail`. It stores all versions for all model types in
|
470
|
+
a single table, you must opt into using `jsonb` as the column type to store "changes", in case you
|
471
|
+
want to query them, and there is no concept of a temporal timeframe for a single version. It makes
|
472
|
+
opinionated decisions about contextual data requirements and stores them as top level data types on
|
473
|
+
the `audited` table.
|
460
474
|
|
461
475
|
#### [`discard`](https://github.com/jhawthorn/discard)
|
462
476
|
|
463
|
-
`discard` only covers soft-deletion. The act of "soft deleting" a record is only captured through
|
464
|
-
time-stamping of a `discarded_at` column on the records table
|
465
|
-
caused the soft deletion unless you implement it yourself. Once the "discarded"
|
466
|
-
previous "discarded" awareness is lost. Since "discarded" records exist in
|
467
|
-
records, you must explicitly omit the discarded records from queries
|
468
|
-
leaking in.
|
477
|
+
`discard` only covers soft-deletion. The act of "soft deleting" a record is only captured through
|
478
|
+
the time-stamping of a `discarded_at` column on the records table. There is no other capturing of
|
479
|
+
the event that caused the soft deletion unless you implement it yourself. Once the "discarded"
|
480
|
+
record is restored, the previous "discarded" awareness is lost. Since "discarded" records exist in
|
481
|
+
the same table as "undiscarded" records, you must explicitly omit the discarded records from queries
|
482
|
+
across your app to keep them from leaking in.
|
469
483
|
|
470
484
|
#### [`paranoia`](https://github.com/rubysherpas/paranoia)
|
471
485
|
|
472
|
-
`paranoia` also only covers soft-deletion. In their README, they recommend using `discard` instead
|
473
|
-
`paranoia` because of the fact they override ActiveRecord’s `delete` and `destroy` methods.
|
474
|
-
employs callbacks to create trashed versions instead of overriding methods. Otherwise,
|
475
|
-
similarly to `discard` in that it keeps deleted records in the same table and tags
|
476
|
-
timestamp. No other information about the soft-deletion event is stored.
|
486
|
+
`paranoia` also only covers soft-deletion. In their README, they recommend using `discard` instead
|
487
|
+
of `paranoia` because of the fact they override ActiveRecord’s `delete` and `destroy` methods.
|
488
|
+
`hoardable` employs callbacks to create trashed versions instead of overriding methods. Otherwise,
|
489
|
+
`paranoia` works similarly to `discard` in that it keeps deleted records in the same table and tags
|
490
|
+
them with a `deleted_at` timestamp. No other information about the soft-deletion event is stored.
|
477
491
|
|
478
492
|
#### [`logidze`](https://github.com/palkan/logidze)
|
479
493
|
|
480
|
-
`logidze` is an interesting versioning alternative that leverages the power of PostgreSQL triggers.
|
481
|
-
of storing the previous versions or changes in a separate table, it stores them in a
|
482
|
-
directly on the database row of the record itself. If does not support soft
|
494
|
+
`logidze` is an interesting versioning alternative that leverages the power of PostgreSQL triggers.
|
495
|
+
Instead of storing the previous versions or changes in a separate table, it stores them in a
|
496
|
+
proprietary JSON format directly on the database row of the record itself. If does not support soft
|
497
|
+
deletion.
|
483
498
|
|
484
|
-
##
|
499
|
+
## Testing
|
485
500
|
|
486
|
-
|
501
|
+
Hoardable is tested against a matrix of Ruby 3 versions and Rails 7 & 8. To run tests locally, run:
|
502
|
+
|
503
|
+
```
|
504
|
+
rake
|
505
|
+
```
|
506
|
+
|
507
|
+
## Contributing
|
487
508
|
|
488
509
|
Bug reports and pull requests are welcome on GitHub at https://github.com/waymondo/hoardable.
|
489
510
|
|
data/Rakefile
CHANGED
@@ -1,16 +1,30 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require
|
4
|
-
require
|
3
|
+
require "bundler/gem_tasks"
|
4
|
+
require "rake/testtask"
|
5
|
+
require "syntax_tree/rake_tasks"
|
5
6
|
|
6
7
|
Rake::TestTask.new(:test) do |t|
|
7
|
-
t.libs <<
|
8
|
-
t.libs <<
|
9
|
-
t.test_files = FileList[
|
8
|
+
t.libs << "test"
|
9
|
+
t.libs << "lib"
|
10
|
+
t.test_files = FileList["test/**/test_*.rb"]
|
10
11
|
end
|
11
12
|
|
12
|
-
|
13
|
+
SOURCE_FILES = %w[test/**/*.rb lib/**/*.rb Rakefile Gemfile bin/console hoardable.gemspec]
|
13
14
|
|
14
|
-
|
15
|
+
SyntaxTree::Rake::CheckTask.new(:check) do |t|
|
16
|
+
t.source_files = SOURCE_FILES
|
17
|
+
t.print_width = 100
|
18
|
+
end
|
19
|
+
|
20
|
+
SyntaxTree::Rake::WriteTask.new(:write) do |t|
|
21
|
+
t.source_files = SOURCE_FILES
|
22
|
+
t.print_width = 100
|
23
|
+
end
|
24
|
+
|
25
|
+
task :typeprof do
|
26
|
+
`typeprof lib/hoardable.rb`
|
27
|
+
end
|
15
28
|
|
16
|
-
task default: %i[test
|
29
|
+
task default: %i[check test]
|
30
|
+
task pre_commit: %i[write typeprof]
|