hoardable 0.14.2 → 0.16.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/.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 
|
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]
|