hoardable 0.2.0 → 0.5.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 +4 -0
- data/CHANGELOG.md +16 -0
- data/README.md +105 -39
- data/lib/generators/hoardable/templates/migration.rb.erb +1 -1
- data/lib/generators/hoardable/templates/migration_6.rb.erb +1 -1
- data/lib/hoardable/associations.rb +34 -0
- data/lib/hoardable/hoardable.rb +9 -7
- data/lib/hoardable/model.rb +20 -6
- data/lib/hoardable/source_model.rb +30 -11
- data/lib/hoardable/tableoid.rb +19 -4
- data/lib/hoardable/version.rb +1 -1
- data/lib/hoardable/version_model.rb +35 -20
- data/lib/hoardable.rb +1 -0
- data/sig/hoardable.rbs +4 -3
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4c4bc9bc4cdd73263a6afe5551b59f085cbf7dc3877e472df8abdba62f5fcbd6
|
|
4
|
+
data.tar.gz: 7aa8ea828b2f6083a80c93e34a1fcc5756508db15f2a317dc95bfea2ae8b1e28
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 915cba36e937b34667b2ad31ae8dd780224a417669a6bb3c1abdfb2c8a19c10525e7d29a0a4f264aa475362f9b823104bd5a40d71bfc01848db2eb6a463f7c1d
|
|
7
|
+
data.tar.gz: ab0faaab94b03c5b6452b7aad505e16b4bb98538ae8649cfd65e17d397e3d917e6e61c752ed39b820dc30d72503e993ec5d6fbe353391c29025759a4bedeff74
|
data/.rubocop.yml
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
AllCops:
|
|
2
2
|
TargetRubyVersion: 2.6
|
|
3
3
|
NewCops: enable
|
|
4
|
+
SuggestExtensions: false
|
|
4
5
|
|
|
5
6
|
Layout/LineLength:
|
|
6
7
|
Max: 120
|
|
@@ -8,3 +9,6 @@ Layout/LineLength:
|
|
|
8
9
|
Metrics/ClassLength:
|
|
9
10
|
Exclude:
|
|
10
11
|
- 'test/**/*.rb'
|
|
12
|
+
|
|
13
|
+
Style/DocumentDynamicEvalDefinition:
|
|
14
|
+
Enabled: false
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.5.0] - 2022-09-25
|
|
4
|
+
|
|
5
|
+
- **Breaking Change** - Untrashing a version will now insert a version for the untrash event with
|
|
6
|
+
it's own temporal timespan. This simplifies the ability to query versions temporarily for when
|
|
7
|
+
they were trashed or not. This changes, but corrects, temporal query results using `.at`.
|
|
8
|
+
|
|
9
|
+
- **Breaking Change** - Because of the above, a new operation enum value of "insert" was added. If
|
|
10
|
+
you already have the `hoardable_operation` enum in your PostgreSQL schema, you can add it by
|
|
11
|
+
executing the following SQL in a new migration: `ALTER TYPE hoardable_operation ADD VALUE
|
|
12
|
+
'insert';`.
|
|
13
|
+
|
|
14
|
+
## [0.4.0] - 2022-09-24
|
|
15
|
+
|
|
16
|
+
- **Breaking Change** - Trashed versions now pull from the same postgres sequenced used by the
|
|
17
|
+
source model’s table.
|
|
18
|
+
|
|
3
19
|
## [0.1.0] - 2022-07-23
|
|
4
20
|
|
|
5
21
|
- Initial release
|
data/README.md
CHANGED
|
@@ -69,7 +69,7 @@ Rails 7.
|
|
|
69
69
|
### Overview
|
|
70
70
|
|
|
71
71
|
Once you include `Hoardable::Model` into a model, it will dynamically generate a "Version" subclass
|
|
72
|
-
of that model. As we continue our example above
|
|
72
|
+
of that model. As we continue our example from above:
|
|
73
73
|
|
|
74
74
|
```
|
|
75
75
|
$ irb
|
|
@@ -79,8 +79,8 @@ $ irb
|
|
|
79
79
|
=> PostVersion(id: integer, body: text, user_id: integer, created_at: datetime, _data: jsonb, _during: tsrange, post_id: integer)
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
-
A `Post` now `has_many :versions`.
|
|
83
|
-
|
|
82
|
+
A `Post` now `has_many :versions`. With the default configuration, whenever an update and deletion
|
|
83
|
+
of a `Post` occurs, a version is created:
|
|
84
84
|
|
|
85
85
|
```ruby
|
|
86
86
|
post = Post.create!(title: "Title")
|
|
@@ -97,9 +97,29 @@ Post.find(post.id) # raises ActiveRecord::RecordNotFound
|
|
|
97
97
|
Each `PostVersion` has access to the same attributes, relationships, and other model behavior that
|
|
98
98
|
`Post` has, but as a read-only record.
|
|
99
99
|
|
|
100
|
-
If you ever need to revert to a specific version, you can call `version.revert!` on it.
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
If you ever need to revert to a specific version, you can call `version.revert!` on it.
|
|
101
|
+
|
|
102
|
+
``` ruby
|
|
103
|
+
post = Post.create!(title: "Title")
|
|
104
|
+
post.update!(title: "Whoops")
|
|
105
|
+
post.versions.last.revert!
|
|
106
|
+
post.title # => "Title"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
If you would like to untrash a specific version, you can call `version.untrash!` on it. This will
|
|
110
|
+
re-insert the model in the parent class’s table with it’s original primary key.
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
post = Post.create!(title: "Title")
|
|
114
|
+
post.id # => 1
|
|
115
|
+
post.destroy!
|
|
116
|
+
post.versions.size # => 1
|
|
117
|
+
Post.find(post.id) # raises ActiveRecord::RecordNotFound
|
|
118
|
+
trashed_post = post.versions.trashed.last
|
|
119
|
+
trashed_post.id # => 2
|
|
120
|
+
trashed_post.untrash!
|
|
121
|
+
Post.find(post.id) # #<Post>
|
|
122
|
+
```
|
|
103
123
|
|
|
104
124
|
### Querying and Temporal Lookup
|
|
105
125
|
|
|
@@ -112,20 +132,30 @@ post.versions.where(user_id: Current.user.id, body: "Cool!")
|
|
|
112
132
|
If you want to look-up the version of a record at a specific time, you can use the `.at` method:
|
|
113
133
|
|
|
114
134
|
```ruby
|
|
115
|
-
post.at(1.day.ago) # => #<PostVersion
|
|
116
|
-
# or
|
|
117
|
-
PostVersion.at(1.day.ago).find_by(post_id: post.id) # => #<PostVersion
|
|
135
|
+
post.at(1.day.ago) # => #<PostVersion>
|
|
136
|
+
# or you can use the scope on the version model class
|
|
137
|
+
PostVersion.at(1.day.ago).find_by(post_id: post.id) # => #<PostVersion>
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The source model class also has an `.at` method:
|
|
141
|
+
|
|
142
|
+
``` ruby
|
|
143
|
+
Post.at(1.day.ago) # => [#<Post>, #<Post>]
|
|
118
144
|
```
|
|
119
145
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
`created_at` timestamp field.
|
|
146
|
+
This will return an ActiveRecord scoped query of all `Posts` and `PostVersions` that were valid at
|
|
147
|
+
that time, all cast as instances of `Post`.
|
|
123
148
|
|
|
124
|
-
|
|
125
|
-
|
|
149
|
+
_Note:_ A `Version` is not created upon initial parent model creation. To accurately track the
|
|
150
|
+
beginning of the first temporal period, you will need to ensure the source model table has a
|
|
151
|
+
`created_at` timestamp column.
|
|
152
|
+
|
|
153
|
+
By default, `hoardable` will keep copies of records you have destroyed. You can query them
|
|
154
|
+
specifically with:
|
|
126
155
|
|
|
127
156
|
```ruby
|
|
128
157
|
PostVersion.trashed
|
|
158
|
+
Post.version_class.trashed # <- same thing as above
|
|
129
159
|
```
|
|
130
160
|
|
|
131
161
|
_Note:_ Creating an inherited table does not copy over the indexes from the parent table. If you
|
|
@@ -146,7 +176,10 @@ choosing.
|
|
|
146
176
|
One convenient way to assign contextual data to these is by defining a proc in an initializer, i.e.:
|
|
147
177
|
|
|
148
178
|
```ruby
|
|
179
|
+
# config/initializers/hoardable.rb
|
|
149
180
|
Hoardable.whodunit = -> { Current.user&.id }
|
|
181
|
+
|
|
182
|
+
# somewhere in your app code
|
|
150
183
|
Current.user = User.find(123)
|
|
151
184
|
post.update!(status: 'live')
|
|
152
185
|
post.versions.last.hoardable_whodunit # => 123
|
|
@@ -174,7 +207,7 @@ class ApplicationController < ActionController::Base
|
|
|
174
207
|
Hoardable.with(whodunit: current_user.id, meta: { request_uuid: request.uuid }) do
|
|
175
208
|
yield
|
|
176
209
|
end
|
|
177
|
-
# `Hoardable.whodunit`
|
|
210
|
+
# `Hoardable.whodunit` and `Hoardable.meta` are back to nil or their previously set values
|
|
178
211
|
end
|
|
179
212
|
end
|
|
180
213
|
```
|
|
@@ -224,12 +257,13 @@ end
|
|
|
224
257
|
|
|
225
258
|
### Configuration
|
|
226
259
|
|
|
227
|
-
|
|
260
|
+
The configurable options are:
|
|
228
261
|
|
|
229
262
|
```ruby
|
|
230
263
|
Hoardable.enabled # => default true
|
|
231
264
|
Hoardable.version_updates # => default true
|
|
232
265
|
Hoardable.save_trash # => default true
|
|
266
|
+
Hoardable.return_everything # => default false
|
|
233
267
|
```
|
|
234
268
|
|
|
235
269
|
`Hoardable.enabled` controls whether versions will be ever be created.
|
|
@@ -239,6 +273,10 @@ Hoardable.save_trash # => default true
|
|
|
239
273
|
`Hoardable.save_trash` controls whether to create versions upon record deletion. When this is set to
|
|
240
274
|
`false`, all versions of a record will be deleted when the record is destroyed.
|
|
241
275
|
|
|
276
|
+
`Hoardable.return_everything` controls whether to include versions when doing queries for source
|
|
277
|
+
models. This is typically only useful to set around a block, as explained below in
|
|
278
|
+
[Relationships](#relationships).
|
|
279
|
+
|
|
242
280
|
If you would like to temporarily set a config setting, you can use `Hoardable.with`:
|
|
243
281
|
|
|
244
282
|
```ruby
|
|
@@ -247,37 +285,49 @@ Hoardable.with(enabled: false) do
|
|
|
247
285
|
end
|
|
248
286
|
```
|
|
249
287
|
|
|
250
|
-
You can also configure these variables per `ActiveRecord` class as well using `
|
|
288
|
+
You can also configure these variables per `ActiveRecord` class as well using `hoardable_config`:
|
|
251
289
|
|
|
252
290
|
```ruby
|
|
253
291
|
class Comment < ActiveRecord::Base
|
|
254
292
|
include Hoardable::Model
|
|
255
|
-
|
|
293
|
+
hoardable_config version_updates: false
|
|
294
|
+
end
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
If you want to temporarily set the `hoardable_config` for a specific model, you can use
|
|
298
|
+
`with_hoardable_config`:
|
|
299
|
+
|
|
300
|
+
```ruby
|
|
301
|
+
Comment.with_hoardable_config(version_updates: true) do
|
|
302
|
+
comment.update!(text: "Edited")
|
|
256
303
|
end
|
|
257
304
|
```
|
|
258
305
|
|
|
259
|
-
If
|
|
260
|
-
|
|
306
|
+
If a model-level option exists, it will use that. Otherwise, it will fall back to the global
|
|
307
|
+
`Hoardable` config.
|
|
261
308
|
|
|
262
309
|
### Relationships
|
|
263
310
|
|
|
264
|
-
As in life, sometimes relationships can be hard
|
|
265
|
-
|
|
311
|
+
As in life, sometimes relationships can be hard, but here are some pointers on handling associations
|
|
312
|
+
with `Hoardable` considerations.
|
|
266
313
|
|
|
267
|
-
Sometimes you’ll have a record that belongs to a record that you’ll trash. Now the child
|
|
268
|
-
foreign key will point to the non-existent trashed version of the parent. If you would like
|
|
269
|
-
`belongs_to`
|
|
270
|
-
|
|
314
|
+
Sometimes you’ll have a record that belongs to a parent record that you’ll trash. Now the child
|
|
315
|
+
record’s foreign key will point to the non-existent trashed version of the parent. If you would like
|
|
316
|
+
to have `belongs_to` resolve to the trashed parent model in this case, you can use
|
|
317
|
+
`belongs_to_trashable` in place of `belongs_to`:
|
|
271
318
|
|
|
272
319
|
```ruby
|
|
273
|
-
|
|
320
|
+
class Comment
|
|
321
|
+
include Hoardable::Associations # <- This includes is not required if this model already includes `Hoardable::Model`
|
|
322
|
+
belongs_to_trashable :post, -> { where(status: 'published') }, class_name: 'Article' # <- Accepts normal `belongs_to` arguments
|
|
323
|
+
end
|
|
274
324
|
```
|
|
275
325
|
|
|
276
326
|
Sometimes you’ll trash something that `has_many :children, dependent: :destroy` and both the parent
|
|
277
327
|
and child model classes include `Hoardable::Model`. Whenever a hoardable version is created in a
|
|
278
328
|
database transaction, it will create or re-use a unique event UUID for that transaction and tag all
|
|
279
|
-
versions created with it. That way, when you `untrash!` a
|
|
280
|
-
|
|
329
|
+
versions created with it. That way, when you `untrash!` a record, you can find and `untrash!`
|
|
330
|
+
records that were trashed with it:
|
|
281
331
|
|
|
282
332
|
```ruby
|
|
283
333
|
class Post < ActiveRecord::Base
|
|
@@ -295,20 +345,36 @@ class Post < ActiveRecord::Base
|
|
|
295
345
|
end
|
|
296
346
|
```
|
|
297
347
|
|
|
348
|
+
If there are models that might be related to versions that are trashed or otherwise, and/or might
|
|
349
|
+
trashed themselves, you can bypass the inherited tables query handling altogether by using the
|
|
350
|
+
`return_everything` configuration variable in `Hoardable.with`. This will ensure that you always see
|
|
351
|
+
all records, including update and trashed versions.
|
|
352
|
+
|
|
353
|
+
```ruby
|
|
354
|
+
post.destroy!
|
|
355
|
+
|
|
356
|
+
Hoardable.with(return_everything: true) do
|
|
357
|
+
post = Post.find(post.id) # returns the trashed post as if it was not
|
|
358
|
+
post.comments # returns the trashed comments as well
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
post.reload # raises ActiveRecord::RecordNotFound
|
|
362
|
+
```
|
|
363
|
+
|
|
298
364
|
## Gem Comparison
|
|
299
365
|
|
|
300
|
-
###
|
|
366
|
+
### [`paper_trail`](https://github.com/paper-trail-gem/paper_trail)
|
|
301
367
|
|
|
302
368
|
`paper_trail` is maybe the most popular and fully featured gem in this space. It works for other
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
369
|
+
database types than PostgeSQL and (by default) stores all versions of all versioned models in a
|
|
370
|
+
single `versions` table. It stores changes in a `text`, `json`, or `jsonb` column. In order to
|
|
371
|
+
efficiently query the `versions` table, a `jsonb` column should be used, which takes up a lot of
|
|
372
|
+
space to index. Unless you customize your configuration, all `versions` for all models types are
|
|
373
|
+
in the same table which is inefficient if you are only interested in querying versions of a single
|
|
374
|
+
model. By contrast, `hoardable` stores versions in smaller, isolated and inherited tables with the
|
|
375
|
+
same database columns as their parents, which are more efficient for querying as well as auditing
|
|
376
|
+
for truncating and dropping. The concept of a `temporal` time-frame does not exist for a single
|
|
377
|
+
version since there is only a `created_at` timestamp.
|
|
312
378
|
|
|
313
379
|
### [`audited`](https://github.com/collectiveidea/audited)
|
|
314
380
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
class Create<%= class_name.singularize %>Versions < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
4
4
|
def change
|
|
5
|
-
create_enum :hoardable_operation, %w[update delete]
|
|
5
|
+
create_enum :hoardable_operation, %w[update delete insert]
|
|
6
6
|
create_table :<%= singularized_table_name %>_versions, id: false, options: 'INHERITS (<%= table_name %>)' do |t|
|
|
7
7
|
t.jsonb :_data
|
|
8
8
|
t.tsrange :_during, null: false
|
|
@@ -11,7 +11,7 @@ class Create<%= class_name.singularize %>Versions < ActiveRecord::Migration[<%=
|
|
|
11
11
|
SELECT 1 FROM pg_type t
|
|
12
12
|
WHERE t.typname = 'hoardable_operation'
|
|
13
13
|
) THEN
|
|
14
|
-
CREATE TYPE hoardable_operation AS ENUM ('update', 'delete');
|
|
14
|
+
CREATE TYPE hoardable_operation AS ENUM ('update', 'delete', 'insert');
|
|
15
15
|
END IF;
|
|
16
16
|
END
|
|
17
17
|
$$;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hoardable
|
|
4
|
+
# This concern contains +ActiveRecord+ association considerations for {SourceModel}. It is
|
|
5
|
+
# included by {Model} but can be included on it’s own for models that +belongs_to+ a Hoardable
|
|
6
|
+
# {Model}.
|
|
7
|
+
module Associations
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
class_methods do
|
|
11
|
+
# A wrapper for +ActiveRecord+’s +belongs_to+ that allows for falling back to the most recent
|
|
12
|
+
# trashed +version+, in the case that the related source has been trashed.
|
|
13
|
+
def belongs_to_trashable(name, scope = nil, **options)
|
|
14
|
+
belongs_to(name, scope, **options)
|
|
15
|
+
|
|
16
|
+
trashable_relationship_name = "trashable_#{name}"
|
|
17
|
+
|
|
18
|
+
define_method(trashable_relationship_name) do
|
|
19
|
+
source_reflection = self.class.reflections[name.to_s]
|
|
20
|
+
version_class = source_reflection.klass.version_class
|
|
21
|
+
version_class.trashed.only_most_recent.find_by(
|
|
22
|
+
version_class.hoardable_source_foreign_key => source_reflection.foreign_key
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class_eval <<-RUBY, __FILE__, __LINE__ + 1
|
|
27
|
+
def #{name}
|
|
28
|
+
super || #{trashable_relationship_name}
|
|
29
|
+
end
|
|
30
|
+
RUBY
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
data/lib/hoardable/hoardable.rb
CHANGED
|
@@ -5,22 +5,23 @@ 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
7
|
DATA_KEYS = %i[meta whodunit note event_uuid].freeze
|
|
8
|
+
|
|
8
9
|
# Symbols for use with setting {Hoardable} configuration. See {file:README.md#configuration
|
|
9
10
|
# README} for more.
|
|
10
|
-
CONFIG_KEYS = %i[enabled version_updates save_trash].freeze
|
|
11
|
+
CONFIG_KEYS = %i[enabled version_updates save_trash return_everything].freeze
|
|
11
12
|
|
|
12
|
-
# @!visibility private
|
|
13
13
|
VERSION_CLASS_SUFFIX = 'Version'
|
|
14
|
+
private_constant :VERSION_CLASS_SUFFIX
|
|
14
15
|
|
|
15
|
-
# @!visibility private
|
|
16
16
|
VERSION_TABLE_SUFFIX = "_#{VERSION_CLASS_SUFFIX.tableize}"
|
|
17
|
+
private_constant :VERSION_TABLE_SUFFIX
|
|
17
18
|
|
|
18
|
-
# @!visibility private
|
|
19
19
|
DURING_QUERY = '_during @> ?::timestamp'
|
|
20
|
+
private_constant :DURING_QUERY
|
|
20
21
|
|
|
21
22
|
@context = {}
|
|
22
23
|
@config = CONFIG_KEYS.to_h do |key|
|
|
23
|
-
[key,
|
|
24
|
+
[key, key != :return_everything]
|
|
24
25
|
end
|
|
25
26
|
|
|
26
27
|
class << self
|
|
@@ -44,9 +45,10 @@ module Hoardable
|
|
|
44
45
|
end
|
|
45
46
|
end
|
|
46
47
|
|
|
47
|
-
# This is a general use method for setting {
|
|
48
|
+
# This is a general use method for setting {file:README.md#tracking-contextual-data Contextual
|
|
49
|
+
# Data} or {file:README.md#configuration Configuration} around a block.
|
|
48
50
|
#
|
|
49
|
-
# @param hash [Hash]
|
|
51
|
+
# @param hash [Hash] config and contextual data to set within a block
|
|
50
52
|
def with(hash)
|
|
51
53
|
current_config = @config
|
|
52
54
|
current_context = @context
|
data/lib/hoardable/model.rb
CHANGED
|
@@ -10,7 +10,7 @@ module Hoardable
|
|
|
10
10
|
|
|
11
11
|
class_methods do
|
|
12
12
|
# @!visibility private
|
|
13
|
-
attr_reader :
|
|
13
|
+
attr_reader :_hoardable_config
|
|
14
14
|
|
|
15
15
|
# If called with a hash, this will set the model-level +Hoardable+ configuration variables. If
|
|
16
16
|
# called without an argument it will return the computed +Hoardable+ configuration considering
|
|
@@ -19,19 +19,33 @@ module Hoardable
|
|
|
19
19
|
# @param hash [Hash] The +Hoardable+ configuration for the model. Keys must be present in
|
|
20
20
|
# {CONFIG_KEYS}
|
|
21
21
|
# @return [Hash]
|
|
22
|
-
def
|
|
22
|
+
def hoardable_config(hash = nil)
|
|
23
23
|
if hash
|
|
24
|
-
@
|
|
24
|
+
@_hoardable_config = hash.slice(*CONFIG_KEYS)
|
|
25
25
|
else
|
|
26
|
-
@
|
|
27
|
-
|
|
28
|
-
[key,
|
|
26
|
+
@_hoardable_config ||= {}
|
|
27
|
+
CONFIG_KEYS.to_h do |key|
|
|
28
|
+
[key, @_hoardable_config.key?(key) ? @_hoardable_config[key] : Hoardable.send(key)]
|
|
29
29
|
end
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
|
+
|
|
33
|
+
# Set the model-level +Hoardable+ configuration variables around a block. The configuration
|
|
34
|
+
# will be reset to it’s previous value afterwards.
|
|
35
|
+
#
|
|
36
|
+
# @param hash [Hash] The +Hoardable+ configuration for the model. Keys must be present in
|
|
37
|
+
# {CONFIG_KEYS}
|
|
38
|
+
def with_hoardable_config(hash)
|
|
39
|
+
current_config = @_hoardable_config
|
|
40
|
+
@_hoardable_config = hash.slice(*CONFIG_KEYS)
|
|
41
|
+
yield
|
|
42
|
+
ensure
|
|
43
|
+
@_hoardable_config = current_config
|
|
44
|
+
end
|
|
32
45
|
end
|
|
33
46
|
|
|
34
47
|
included do
|
|
48
|
+
include Associations
|
|
35
49
|
define_model_callbacks :versioned
|
|
36
50
|
define_model_callbacks :reverted, only: :after
|
|
37
51
|
define_model_callbacks :untrashed, only: :after
|
|
@@ -34,18 +34,33 @@ module Hoardable
|
|
|
34
34
|
|
|
35
35
|
# Returns all +versions+ in ascending order of their temporal timeframes.
|
|
36
36
|
has_many(
|
|
37
|
-
:versions, -> { order(
|
|
37
|
+
:versions, -> { order('UPPER(_during) ASC') },
|
|
38
38
|
dependent: nil,
|
|
39
39
|
class_name: version_class.to_s,
|
|
40
40
|
inverse_of: model_name.i18n_key
|
|
41
41
|
)
|
|
42
|
+
|
|
43
|
+
# @!scope class
|
|
44
|
+
# @!method at
|
|
45
|
+
# @return [ActiveRecord<Object>]
|
|
46
|
+
#
|
|
47
|
+
# Returns instances of the source model and versions that were valid at the supplied
|
|
48
|
+
# +datetime+ or +time+, all cast as instances of the source model.
|
|
49
|
+
scope :at, lambda { |datetime|
|
|
50
|
+
versioned = version_class.at(datetime)
|
|
51
|
+
trashed = version_class.trashed_at(datetime)
|
|
52
|
+
foreign_key = version_class.hoardable_source_foreign_key
|
|
53
|
+
include_versions.where(id: versioned.select('id')).or(
|
|
54
|
+
where.not(id: versioned.select(foreign_key)).where.not(id: trashed.select(foreign_key))
|
|
55
|
+
)
|
|
56
|
+
}
|
|
42
57
|
end
|
|
43
58
|
|
|
44
59
|
# Returns a boolean of whether the record is actually a trashed +version+.
|
|
45
60
|
#
|
|
46
61
|
# @return [Boolean]
|
|
47
62
|
def trashed?
|
|
48
|
-
versions.trashed.
|
|
63
|
+
versions.trashed.only_most_recent.first&.hoardable_source_foreign_id == id
|
|
49
64
|
end
|
|
50
65
|
|
|
51
66
|
# Returns the +version+ at the supplied +datetime+ or +time+. It will return +self+ if there is
|
|
@@ -71,27 +86,31 @@ module Hoardable
|
|
|
71
86
|
private
|
|
72
87
|
|
|
73
88
|
def hoardable_callbacks_enabled
|
|
74
|
-
self.class.
|
|
89
|
+
self.class.hoardable_config[:enabled] && !self.class.name.end_with?(VERSION_CLASS_SUFFIX)
|
|
75
90
|
end
|
|
76
91
|
|
|
77
92
|
def hoardable_save_trash
|
|
78
|
-
self.class.
|
|
93
|
+
self.class.hoardable_config[:save_trash]
|
|
79
94
|
end
|
|
80
95
|
|
|
81
96
|
def hoardable_version_updates
|
|
82
|
-
self.class.
|
|
97
|
+
self.class.hoardable_config[:version_updates]
|
|
83
98
|
end
|
|
84
99
|
|
|
85
100
|
def insert_hoardable_version_on_update(&block)
|
|
86
|
-
insert_hoardable_version('update',
|
|
101
|
+
insert_hoardable_version('update', &block)
|
|
87
102
|
end
|
|
88
103
|
|
|
89
104
|
def insert_hoardable_version_on_destroy(&block)
|
|
90
|
-
insert_hoardable_version('delete',
|
|
105
|
+
insert_hoardable_version('delete', &block)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def insert_hoardable_version_on_untrashed
|
|
109
|
+
initialize_hoardable_version('insert').save(validate: false, touch: false)
|
|
91
110
|
end
|
|
92
111
|
|
|
93
|
-
def insert_hoardable_version(operation
|
|
94
|
-
@hoardable_version = initialize_hoardable_version(operation
|
|
112
|
+
def insert_hoardable_version(operation)
|
|
113
|
+
@hoardable_version = initialize_hoardable_version(operation)
|
|
95
114
|
run_callbacks(:versioned) do
|
|
96
115
|
yield
|
|
97
116
|
hoardable_version.save(validate: false, touch: false)
|
|
@@ -102,9 +121,9 @@ module Hoardable
|
|
|
102
121
|
Thread.current[:hoardable_event_uuid] ||= ActiveRecord::Base.connection.query('SELECT gen_random_uuid();')[0][0]
|
|
103
122
|
end
|
|
104
123
|
|
|
105
|
-
def initialize_hoardable_version(operation
|
|
124
|
+
def initialize_hoardable_version(operation)
|
|
106
125
|
versions.new(
|
|
107
|
-
|
|
126
|
+
attributes_before_type_cast.without('id').merge(
|
|
108
127
|
changes.transform_values { |h| h[0] },
|
|
109
128
|
{
|
|
110
129
|
_event_uuid: find_or_initialize_hoardable_event_uuid,
|
data/lib/hoardable/tableoid.rb
CHANGED
|
@@ -5,21 +5,28 @@ module Hoardable
|
|
|
5
5
|
module Tableoid
|
|
6
6
|
extend ActiveSupport::Concern
|
|
7
7
|
|
|
8
|
-
# @!visibility private
|
|
9
8
|
TABLEOID_AREL_CONDITIONS = lambda do |arel_table, condition|
|
|
10
9
|
arel_table[:tableoid].send(
|
|
11
10
|
condition,
|
|
12
11
|
Arel::Nodes::NamedFunction.new('CAST', [Arel::Nodes::Quoted.new(arel_table.name).as('regclass')])
|
|
13
12
|
)
|
|
14
13
|
end.freeze
|
|
14
|
+
private_constant :TABLEOID_AREL_CONDITIONS
|
|
15
15
|
|
|
16
16
|
included do
|
|
17
17
|
# @!visibility private
|
|
18
18
|
attr_writer :tableoid
|
|
19
19
|
|
|
20
|
-
# By default
|
|
21
|
-
#
|
|
22
|
-
|
|
20
|
+
# By default {Hoardable} only returns instances of the parent table, and not the +versions+ in
|
|
21
|
+
# the inherited table. This can be bypassed by using the {.include_versions} scope or wrapping
|
|
22
|
+
# the code in a `Hoardable.with(return_everything: true)` block.
|
|
23
|
+
default_scope do
|
|
24
|
+
if hoardable_config[:return_everything]
|
|
25
|
+
where(nil)
|
|
26
|
+
else
|
|
27
|
+
exclude_versions
|
|
28
|
+
end
|
|
29
|
+
end
|
|
23
30
|
|
|
24
31
|
# @!scope class
|
|
25
32
|
# @!method include_versions
|
|
@@ -36,6 +43,14 @@ module Hoardable
|
|
|
36
43
|
# Returns only +versions+ of the parent +ActiveRecord+ class, cast as instances of the source
|
|
37
44
|
# model’s class.
|
|
38
45
|
scope :versions, -> { include_versions.where(TABLEOID_AREL_CONDITIONS.call(arel_table, :not_eq)) }
|
|
46
|
+
|
|
47
|
+
# @!scope class
|
|
48
|
+
# @!method exclude_versions
|
|
49
|
+
# @return [ActiveRecord<Object>]
|
|
50
|
+
#
|
|
51
|
+
# Excludes +versions+ of the parent +ActiveRecord+ class. This is included by default in the
|
|
52
|
+
# source model’s +default_scope+.
|
|
53
|
+
scope :exclude_versions, -> { where(TABLEOID_AREL_CONDITIONS.call(arel_table, :eq)) }
|
|
39
54
|
end
|
|
40
55
|
|
|
41
56
|
private
|
data/lib/hoardable/version.rb
CHANGED
|
@@ -6,6 +6,13 @@ module Hoardable
|
|
|
6
6
|
module VersionModel
|
|
7
7
|
extend ActiveSupport::Concern
|
|
8
8
|
|
|
9
|
+
class_methods do
|
|
10
|
+
# Returns the foreign column that holds the reference to the source model of the version.
|
|
11
|
+
def hoardable_source_foreign_key
|
|
12
|
+
@hoardable_source_foreign_key ||= "#{superclass.model_name.i18n_key}_id"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
9
16
|
included do
|
|
10
17
|
hoardable_source_key = superclass.model_name.i18n_key
|
|
11
18
|
|
|
@@ -13,7 +20,7 @@ module Hoardable
|
|
|
13
20
|
belongs_to hoardable_source_key, inverse_of: :versions
|
|
14
21
|
alias_method :hoardable_source, hoardable_source_key
|
|
15
22
|
|
|
16
|
-
self.table_name = "#{table_name.singularize}#{
|
|
23
|
+
self.table_name = "#{table_name.singularize}#{VERSION_TABLE_SUFFIX}"
|
|
17
24
|
|
|
18
25
|
alias_method :readonly?, :persisted?
|
|
19
26
|
alias_attribute :hoardable_operation, :_operation
|
|
@@ -26,7 +33,7 @@ module Hoardable
|
|
|
26
33
|
# @!method trashed
|
|
27
34
|
# @return [ActiveRecord<Object>]
|
|
28
35
|
#
|
|
29
|
-
# Returns only trashed +versions+ that are orphans.
|
|
36
|
+
# Returns only trashed +versions+ that are currently orphans.
|
|
30
37
|
scope :trashed, lambda {
|
|
31
38
|
left_outer_joins(hoardable_source_key)
|
|
32
39
|
.where(superclass.table_name => { id: nil })
|
|
@@ -38,7 +45,14 @@ module Hoardable
|
|
|
38
45
|
# @return [ActiveRecord<Object>]
|
|
39
46
|
#
|
|
40
47
|
# Returns +versions+ that were valid at the supplied +datetime+ or +time+.
|
|
41
|
-
scope :at, ->(datetime) { where(DURING_QUERY, datetime) }
|
|
48
|
+
scope :at, ->(datetime) { where(_operation: %w[delete update]).where(DURING_QUERY, datetime) }
|
|
49
|
+
|
|
50
|
+
# @!scope class
|
|
51
|
+
# @!method trashed_at
|
|
52
|
+
# @return [ActiveRecord<Object>]
|
|
53
|
+
#
|
|
54
|
+
# Returns +versions+ that were trashed at the supplied +datetime+ or +time+.
|
|
55
|
+
scope :trashed_at, ->(datetime) { where(_operation: 'insert').where(DURING_QUERY, datetime) }
|
|
42
56
|
|
|
43
57
|
# @!scope class
|
|
44
58
|
# @!method with_hoardable_event_uuid
|
|
@@ -47,6 +61,13 @@ module Hoardable
|
|
|
47
61
|
# Returns all +versions+ that were created as part of the same +ActiveRecord+ database
|
|
48
62
|
# transaction of the supplied +event_uuid+. Useful in +reverted+ and +untrashed+ callbacks.
|
|
49
63
|
scope :with_hoardable_event_uuid, ->(event_uuid) { where(_event_uuid: event_uuid) }
|
|
64
|
+
|
|
65
|
+
# @!scope class
|
|
66
|
+
# @!method only_most_recent
|
|
67
|
+
# @return [ActiveRecord<Object>]
|
|
68
|
+
#
|
|
69
|
+
# Returns a limited +ActiveRecord+ scope of only the most recent version.
|
|
70
|
+
scope :only_most_recent, -> { limit(1).reorder('UPPER(_during) DESC') }
|
|
50
71
|
end
|
|
51
72
|
|
|
52
73
|
# Reverts the parent +ActiveRecord+ instance to the saved attributes of this +version+. Raises
|
|
@@ -70,8 +91,9 @@ module Hoardable
|
|
|
70
91
|
|
|
71
92
|
transaction do
|
|
72
93
|
superscope = self.class.superclass.unscoped
|
|
73
|
-
superscope.insert(
|
|
94
|
+
superscope.insert(hoardable_source_attributes.merge('id' => hoardable_source_foreign_id))
|
|
74
95
|
superscope.find(hoardable_source_foreign_id).tap do |untrashed|
|
|
96
|
+
untrashed.send('insert_hoardable_version_on_untrashed')
|
|
75
97
|
untrashed.instance_variable_set(:@hoardable_version, self)
|
|
76
98
|
untrashed.run_callbacks(:untrashed)
|
|
77
99
|
end
|
|
@@ -91,13 +113,14 @@ module Hoardable
|
|
|
91
113
|
_data&.dig('changes')
|
|
92
114
|
end
|
|
93
115
|
|
|
116
|
+
# Returns the foreign reference that represents the source model of the version.
|
|
117
|
+
def hoardable_source_foreign_id
|
|
118
|
+
@hoardable_source_foreign_id ||= public_send(hoardable_source_foreign_key)
|
|
119
|
+
end
|
|
120
|
+
|
|
94
121
|
private
|
|
95
122
|
|
|
96
|
-
|
|
97
|
-
hoardable_source_attributes.merge('id' => hoardable_source_foreign_id).tap do |hash|
|
|
98
|
-
hash['updated_at'] = Time.now if self.class.column_names.include?('updated_at')
|
|
99
|
-
end
|
|
100
|
-
end
|
|
123
|
+
delegate :hoardable_source_foreign_key, to: :class
|
|
101
124
|
|
|
102
125
|
def hoardable_source_attributes
|
|
103
126
|
@hoardable_source_attributes ||=
|
|
@@ -106,16 +129,8 @@ module Hoardable
|
|
|
106
129
|
.reject { |k, _v| k.start_with?('_') }
|
|
107
130
|
end
|
|
108
131
|
|
|
109
|
-
def hoardable_source_foreign_key
|
|
110
|
-
@hoardable_source_foreign_key ||= "#{self.class.superclass.model_name.i18n_key}_id"
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
def hoardable_source_foreign_id
|
|
114
|
-
@hoardable_source_foreign_id ||= public_send(hoardable_source_foreign_key)
|
|
115
|
-
end
|
|
116
|
-
|
|
117
132
|
def previous_temporal_tsrange_end
|
|
118
|
-
hoardable_source.versions.
|
|
133
|
+
hoardable_source.versions.only_most_recent.pluck('_during').first&.end
|
|
119
134
|
end
|
|
120
135
|
|
|
121
136
|
def assign_temporal_tsrange
|
|
@@ -124,10 +139,10 @@ module Hoardable
|
|
|
124
139
|
if hoardable_source.class.column_names.include?('created_at')
|
|
125
140
|
hoardable_source.created_at
|
|
126
141
|
else
|
|
127
|
-
Time.at(0)
|
|
142
|
+
Time.at(0).utc
|
|
128
143
|
end
|
|
129
144
|
)
|
|
130
|
-
self._during = (range_start..Time.now)
|
|
145
|
+
self._during = (range_start..Time.now.utc)
|
|
131
146
|
end
|
|
132
147
|
end
|
|
133
148
|
end
|
data/lib/hoardable.rb
CHANGED
data/sig/hoardable.rbs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
module Hoardable
|
|
2
2
|
VERSION: String
|
|
3
3
|
DATA_KEYS: [:meta, :whodunit, :note, :event_uuid]
|
|
4
|
-
CONFIG_KEYS: [:enabled, :version_updates, :save_trash]
|
|
4
|
+
CONFIG_KEYS: [:enabled, :version_updates, :save_trash, :return_everything]
|
|
5
5
|
VERSION_CLASS_SUFFIX: String
|
|
6
6
|
VERSION_TABLE_SUFFIX: String
|
|
7
7
|
DURING_QUERY: String
|
|
@@ -71,8 +71,9 @@ module Hoardable
|
|
|
71
71
|
include VersionModel
|
|
72
72
|
include SourceModel
|
|
73
73
|
|
|
74
|
-
attr_reader
|
|
75
|
-
def
|
|
74
|
+
attr_reader _hoardable_config: Hash[untyped, untyped]
|
|
75
|
+
def hoardable_config: (?nil hash) -> untyped
|
|
76
|
+
def with_hoardable_config: (untyped hash) -> untyped
|
|
76
77
|
end
|
|
77
78
|
|
|
78
79
|
class MigrationGenerator
|
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.5.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: 2022-
|
|
11
|
+
date: 2022-09-25 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activerecord
|
|
@@ -108,6 +108,7 @@ files:
|
|
|
108
108
|
- lib/generators/hoardable/templates/migration.rb.erb
|
|
109
109
|
- lib/generators/hoardable/templates/migration_6.rb.erb
|
|
110
110
|
- lib/hoardable.rb
|
|
111
|
+
- lib/hoardable/associations.rb
|
|
111
112
|
- lib/hoardable/error.rb
|
|
112
113
|
- lib/hoardable/hoardable.rb
|
|
113
114
|
- lib/hoardable/model.rb
|