hoardable 0.6.0 → 0.9.1
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 +3 -0
- data/CHANGELOG.md +33 -2
- data/Gemfile +1 -0
- data/README.md +81 -47
- data/lib/generators/hoardable/initializer_generator.rb +21 -0
- data/lib/generators/hoardable/templates/migration.rb.erb +17 -2
- data/lib/generators/hoardable/templates/migration_6.rb.erb +17 -2
- data/lib/hoardable/associations.rb +28 -2
- data/lib/hoardable/database_client.rb +98 -0
- data/lib/hoardable/hoardable.rb +13 -2
- data/lib/hoardable/model.rb +1 -1
- data/lib/hoardable/{tableoid.rb → scopes.rb} +18 -5
- data/lib/hoardable/source_model.rb +44 -75
- data/lib/hoardable/version.rb +1 -1
- data/lib/hoardable/version_model.rb +25 -73
- data/lib/hoardable.rb +3 -1
- data/sig/hoardable.rbs +56 -34
- metadata +5 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5b85dfaf658e447049fcb09281d34dd25188be79f948eda294398f47b19b8244
|
|
4
|
+
data.tar.gz: 67219612736259e2dbc542230cc1490a156a15bc679340176aca35b76aaf516c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cf3c5edfeac5526e7520dc2584caba8bb5399df43acf1ce6bd320b19f44981c154624b9b80c5b415aaeb840fc5d718f1999b4bbaf82eb3ff76149e09621ab0e5
|
|
7
|
+
data.tar.gz: 52112fb9bc55bec2009656cce9571887130333b6460d5478c1a0c113895ccb977ffcc80172954911c6e70d2012f136bf68c64b17bc69ffd7deeaa40a56c97646
|
data/.rubocop.yml
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,41 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
- Stability is coming.
|
|
4
|
+
|
|
5
|
+
## [0.9.0] - 2022-10-02
|
|
6
|
+
|
|
7
|
+
- **Breaking Change** - `Hoardable.return_everything` was removed in favor of the newly added
|
|
8
|
+
`Hoardable.at`.
|
|
9
|
+
|
|
10
|
+
## [0.8.0] - 2022-10-01
|
|
11
|
+
|
|
12
|
+
- **Breaking Change** - Due to the performance benefit of using `insert` for database injection of
|
|
13
|
+
versions, and a personal opinion that only an `after_versioned` hook might be needed, the
|
|
14
|
+
`before_versioned` and `around_versioned` ActiveRecord hooks are removed.
|
|
15
|
+
|
|
16
|
+
- **Breaking Change** - Another side effect of the performance benefit gained by using `insert` is
|
|
17
|
+
that a source model will need to be reloaded before a call to `versions` on it can access the
|
|
18
|
+
latest version after an `update` on the source record.
|
|
19
|
+
|
|
20
|
+
- **Breaking Change** - Previously the inherited `_versions` tables did not have a unique index on
|
|
21
|
+
the ID column, though it still pulled from the same sequence as the parent table. Prior to version
|
|
22
|
+
0.4.0 though, it was possible to have multiple trashed versions with the same ID. Adding unique
|
|
23
|
+
indexes to version tables prior to version 0.4.0 could result in issues.
|
|
24
|
+
|
|
25
|
+
## [0.7.0] - 2022-09-29
|
|
26
|
+
|
|
27
|
+
- **Breaking Change** - Continuing along with the change below, the `foreign_key` on the `_versions`
|
|
28
|
+
tables is now changed to `hoardable_source_id` instead of the i18n model name dervied foreign key.
|
|
29
|
+
The intent is to never leave room for conflict of foreign keys for existing relationships. This
|
|
30
|
+
can be resolved by renaming the foreign key columns from their i18n model name derived column
|
|
31
|
+
names to `hoardable_source_id`, i.e. `rename_column :post_versions, :post_id, :hoardable_source_id`.
|
|
32
|
+
|
|
3
33
|
## [0.6.0] - 2022-09-28
|
|
4
34
|
|
|
5
35
|
- **Breaking Change** - Previously, a source model would `has_many :versions` with an inverse
|
|
6
|
-
relationship
|
|
7
|
-
inverse_of :hoardable_source` to not potentially conflict with previously existing
|
|
36
|
+
relationship based on the i18n interpreted name of the source model. Now it simply `has_many
|
|
37
|
+
:versions, inverse_of :hoardable_source` to not potentially conflict with previously existing
|
|
38
|
+
relationships.
|
|
8
39
|
|
|
9
40
|
## [0.5.0] - 2022-09-25
|
|
10
41
|
|
data/Gemfile
CHANGED
data/README.md
CHANGED
|
@@ -31,6 +31,12 @@ gem 'hoardable'
|
|
|
31
31
|
|
|
32
32
|
And then execute `bundle install`.
|
|
33
33
|
|
|
34
|
+
If you would like to generate an initializer with the global [configuration](#configuration) options:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
rails g hoardable:initializer
|
|
38
|
+
```
|
|
39
|
+
|
|
34
40
|
### Model Installation
|
|
35
41
|
|
|
36
42
|
You must include `Hoardable::Model` into an ActiveRecord model that you would like to hoard versions
|
|
@@ -64,6 +70,9 @@ _Note:_ If you are on Rails 6.1, you might want to set `config.active_record.sch
|
|
|
64
70
|
in `application.rb`, so that the enum type is captured in your schema dump. This is not required in
|
|
65
71
|
Rails 7.
|
|
66
72
|
|
|
73
|
+
_Note:_ Creating an inherited table does not copy over the indexes from the parent table. If you
|
|
74
|
+
need to query versions often, you should add appropriate indexes to the `_versions` tables.
|
|
75
|
+
|
|
67
76
|
## Usage
|
|
68
77
|
|
|
69
78
|
### Overview
|
|
@@ -76,7 +85,7 @@ $ irb
|
|
|
76
85
|
>> Post
|
|
77
86
|
=> Post(id: integer, body: text, user_id: integer, created_at: datetime)
|
|
78
87
|
>> PostVersion
|
|
79
|
-
=> PostVersion(id: integer, body: text, user_id: integer, created_at: datetime, _data: jsonb, _during: tsrange,
|
|
88
|
+
=> PostVersion(id: integer, body: text, user_id: integer, created_at: datetime, _data: jsonb, _during: tsrange, hoardable_source_id: integer)
|
|
80
89
|
```
|
|
81
90
|
|
|
82
91
|
A `Post` now `has_many :versions`. With the default configuration, whenever an update and deletion
|
|
@@ -86,7 +95,7 @@ of a `Post` occurs, a version is created:
|
|
|
86
95
|
post = Post.create!(title: "Title")
|
|
87
96
|
post.versions.size # => 0
|
|
88
97
|
post.update!(title: "Revised Title")
|
|
89
|
-
post.versions.size # => 1
|
|
98
|
+
post.reload.versions.size # => 1
|
|
90
99
|
post.versions.first.title # => "Title"
|
|
91
100
|
post.destroy!
|
|
92
101
|
post.trashed? # true
|
|
@@ -99,15 +108,15 @@ Each `PostVersion` has access to the same attributes, relationships, and other m
|
|
|
99
108
|
|
|
100
109
|
If you ever need to revert to a specific version, you can call `version.revert!` on it.
|
|
101
110
|
|
|
102
|
-
```
|
|
111
|
+
```ruby
|
|
103
112
|
post = Post.create!(title: "Title")
|
|
104
113
|
post.update!(title: "Whoops")
|
|
105
|
-
post.versions.last.revert!
|
|
114
|
+
post.reload.versions.last.revert!
|
|
106
115
|
post.title # => "Title"
|
|
107
116
|
```
|
|
108
117
|
|
|
109
118
|
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
|
|
119
|
+
re-insert the model in the parent class’s table with the original primary key.
|
|
111
120
|
|
|
112
121
|
```ruby
|
|
113
122
|
post = Post.create!(title: "Title")
|
|
@@ -134,21 +143,20 @@ If you want to look-up the version of a record at a specific time, you can use t
|
|
|
134
143
|
```ruby
|
|
135
144
|
post.at(1.day.ago) # => #<PostVersion>
|
|
136
145
|
# or you can use the scope on the version model class
|
|
137
|
-
PostVersion.at(1.day.ago).find_by(
|
|
146
|
+
PostVersion.at(1.day.ago).find_by(hoardable_source_id: post.id) # => #<PostVersion>
|
|
138
147
|
```
|
|
139
148
|
|
|
140
149
|
The source model class also has an `.at` method:
|
|
141
150
|
|
|
142
|
-
```
|
|
151
|
+
```ruby
|
|
143
152
|
Post.at(1.day.ago) # => [#<Post>, #<Post>]
|
|
144
153
|
```
|
|
145
154
|
|
|
146
|
-
This will return an ActiveRecord scoped query of all `
|
|
147
|
-
that time, all cast as instances of `Post`.
|
|
155
|
+
This will return an ActiveRecord scoped query of all `Post` and `PostVersion` records that were
|
|
156
|
+
valid at that time, all cast as instances of `Post`.
|
|
148
157
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
`created_at` timestamp column.
|
|
158
|
+
There is also an `at` method on `Hoardable` itself for more complex temporal resource querying. See
|
|
159
|
+
[Relationships](#relationships) for more.
|
|
152
160
|
|
|
153
161
|
By default, `hoardable` will keep copies of records you have destroyed. You can query them
|
|
154
162
|
specifically with:
|
|
@@ -156,10 +164,12 @@ specifically with:
|
|
|
156
164
|
```ruby
|
|
157
165
|
PostVersion.trashed
|
|
158
166
|
Post.version_class.trashed # <- same thing as above
|
|
167
|
+
PostVersion.trashed.first.trashed? # <- true
|
|
159
168
|
```
|
|
160
169
|
|
|
161
|
-
_Note:_
|
|
162
|
-
|
|
170
|
+
_Note:_ A `Version` is not created upon initial parent model creation. To accurately track the
|
|
171
|
+
beginning of the first temporal period, you will need to ensure the source model table has a
|
|
172
|
+
`created_at` timestamp column.
|
|
163
173
|
|
|
164
174
|
### Tracking Contextual Data
|
|
165
175
|
|
|
@@ -182,7 +192,7 @@ Hoardable.whodunit = -> { Current.user&.id }
|
|
|
182
192
|
# somewhere in your app code
|
|
183
193
|
Current.user = User.find(123)
|
|
184
194
|
post.update!(status: 'live')
|
|
185
|
-
post.versions.last.hoardable_whodunit # => 123
|
|
195
|
+
post.reload.versions.last.hoardable_whodunit # => 123
|
|
186
196
|
```
|
|
187
197
|
|
|
188
198
|
You can also set this context manually as well, just remember to clear them afterwards.
|
|
@@ -191,7 +201,7 @@ You can also set this context manually as well, just remember to clear them afte
|
|
|
191
201
|
Hoardable.note = "reverting due to accidental deletion"
|
|
192
202
|
post.update!(title: "We’re back!")
|
|
193
203
|
Hoardable.note = nil
|
|
194
|
-
post.versions.last.hoardable_note # => "reverting due to accidental deletion"
|
|
204
|
+
post.reload.versions.last.hoardable_note # => "reverting due to accidental deletion"
|
|
195
205
|
```
|
|
196
206
|
|
|
197
207
|
A more useful pattern is to use `Hoardable.with` to set the context around a block. A good example
|
|
@@ -225,9 +235,9 @@ version.hoardable_event_uuid
|
|
|
225
235
|
|
|
226
236
|
### Model Callbacks
|
|
227
237
|
|
|
228
|
-
Sometimes you might want to do something with a version
|
|
229
|
-
|
|
230
|
-
|
|
238
|
+
Sometimes you might want to do something with a version after it gets inserted to the database. You
|
|
239
|
+
can access it in `after_versioned` callbacks on the source record as `hoardable_version`. These
|
|
240
|
+
happen within `ActiveRecord`’s `.save`, which is enclosed in an ActiveRecord transaction.
|
|
231
241
|
|
|
232
242
|
There are also `after_reverted` and `after_untrashed` callbacks available as well, which are called
|
|
233
243
|
on the source record after a version is reverted or untrashed.
|
|
@@ -235,14 +245,14 @@ on the source record after a version is reverted or untrashed.
|
|
|
235
245
|
```ruby
|
|
236
246
|
class User
|
|
237
247
|
include Hoardable::Model
|
|
238
|
-
|
|
248
|
+
after_versioned :track_versioned_event
|
|
239
249
|
after_reverted :track_reverted_event
|
|
240
250
|
after_untrashed :track_untrashed_event
|
|
241
251
|
|
|
242
252
|
private
|
|
243
253
|
|
|
244
|
-
def
|
|
245
|
-
hoardable_version
|
|
254
|
+
def track_versioned_event
|
|
255
|
+
track_event(:user_versioned, hoardable_version)
|
|
246
256
|
end
|
|
247
257
|
|
|
248
258
|
def track_reverted_event
|
|
@@ -263,7 +273,6 @@ The configurable options are:
|
|
|
263
273
|
Hoardable.enabled # => default true
|
|
264
274
|
Hoardable.version_updates # => default true
|
|
265
275
|
Hoardable.save_trash # => default true
|
|
266
|
-
Hoardable.return_everything # => default false
|
|
267
276
|
```
|
|
268
277
|
|
|
269
278
|
`Hoardable.enabled` controls whether versions will be ever be created.
|
|
@@ -273,10 +282,6 @@ Hoardable.return_everything # => default false
|
|
|
273
282
|
`Hoardable.save_trash` controls whether to create versions upon record deletion. When this is set to
|
|
274
283
|
`false`, all versions of a record will be deleted when the record is destroyed.
|
|
275
284
|
|
|
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
|
-
|
|
280
285
|
If you would like to temporarily set a config setting, you can use `Hoardable.with`:
|
|
281
286
|
|
|
282
287
|
```ruby
|
|
@@ -323,8 +328,54 @@ class Comment
|
|
|
323
328
|
end
|
|
324
329
|
```
|
|
325
330
|
|
|
326
|
-
Sometimes you
|
|
327
|
-
|
|
331
|
+
Sometimes you'll have a Hoardable record that `has_many` other Hoardable records and you will want
|
|
332
|
+
to know the state of both the parent record and the children at a cetain point in time. You
|
|
333
|
+
accomplish this by establishing a `has_many_hoardable` relationship and using the `Hoardable.at`
|
|
334
|
+
method:
|
|
335
|
+
|
|
336
|
+
```ruby
|
|
337
|
+
class Post
|
|
338
|
+
include Hoardable::Model
|
|
339
|
+
has_many_hoardable :comments
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def Comment
|
|
343
|
+
include Hoardable::Model
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
post = Post.create!(title: 'Title')
|
|
347
|
+
comment1 = post.comments.create!(body: 'Comment')
|
|
348
|
+
comment2 = post.comments.create!(body: 'Comment')
|
|
349
|
+
datetime = DateTime.current
|
|
350
|
+
comment2.destroy!
|
|
351
|
+
post.update!(title: 'New Title')
|
|
352
|
+
post_id = post.id # 1
|
|
353
|
+
|
|
354
|
+
Hoardable.at(datetime) do
|
|
355
|
+
post = Post.hoardable.find(post_id)
|
|
356
|
+
post.title # => 'Title'
|
|
357
|
+
post.comments.size # => 2
|
|
358
|
+
post.id # => 2
|
|
359
|
+
post.version? # => true
|
|
360
|
+
post.hoardable_source_id # => 1
|
|
361
|
+
end
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
There are some additional details to point out above. Firstly, it is important to note that the
|
|
365
|
+
final `post.id` yields a different value than the originally created `Post`. This is because the
|
|
366
|
+
`post` within the `#at` block is actually a temporal version, since it has been subsequently
|
|
367
|
+
updated, but it has been reified as a `Post` for the purposes of your business logic (serialization,
|
|
368
|
+
rendering views, exporting, etc). Don’t fret - you will not be able to commit any updates to the
|
|
369
|
+
version, even though it is masquerading as a `Post`.
|
|
370
|
+
|
|
371
|
+
If you are ever unsure if a Hoardable record is a "source" or a "version", you can be sure by
|
|
372
|
+
calling `version?` on it. If you want to get the true original source record ID, you can call
|
|
373
|
+
`hoardable_source_id`. Finally, if you prepend `.hoardable` to a `.find` call on the source model
|
|
374
|
+
class, you can always find the relevant source or temporal version record using just the original
|
|
375
|
+
source record’s id.
|
|
376
|
+
|
|
377
|
+
Sometimes you’ll trash something that `has_many_hoardable :children, dependent: :destroy` and want
|
|
378
|
+
to untrash everything in a similar dependent manner. Whenever a hoardable version is created in a
|
|
328
379
|
database transaction, it will create or re-use a unique event UUID for that transaction and tag all
|
|
329
380
|
versions created with it. That way, when you `untrash!` a record, you can find and `untrash!`
|
|
330
381
|
records that were trashed with it:
|
|
@@ -332,35 +383,18 @@ records that were trashed with it:
|
|
|
332
383
|
```ruby
|
|
333
384
|
class Post < ActiveRecord::Base
|
|
334
385
|
include Hoardable::Model
|
|
335
|
-
|
|
386
|
+
has_many_hoardable :comments, dependent: :destroy # `Comment` also includes `Hoardable::Model`
|
|
336
387
|
|
|
337
388
|
after_untrashed do
|
|
338
389
|
Comment
|
|
339
390
|
.version_class
|
|
340
391
|
.trashed
|
|
341
|
-
.where(post_id: id)
|
|
342
392
|
.with_hoardable_event_uuid(hoardable_event_uuid)
|
|
343
393
|
.find_each(&:untrash!)
|
|
344
394
|
end
|
|
345
395
|
end
|
|
346
396
|
```
|
|
347
397
|
|
|
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
|
-
|
|
364
398
|
## Gem Comparison
|
|
365
399
|
|
|
366
400
|
### [`paper_trail`](https://github.com/paper-trail-gem/paper_trail)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
|
|
5
|
+
module Hoardable
|
|
6
|
+
# Generates an initializer file for {Hoardable} configuration.
|
|
7
|
+
class InitializerGenerator < Rails::Generators::Base
|
|
8
|
+
def create_initializer_file
|
|
9
|
+
create_file(
|
|
10
|
+
'config/initializers/hoardable.rb',
|
|
11
|
+
<<~TEXT
|
|
12
|
+
# Hoardable configuration defaults are below. Learn more at https://github.com/waymondo/hoardable#configuration
|
|
13
|
+
#
|
|
14
|
+
# Hoardable.enabled = true
|
|
15
|
+
# Hoardable.version_updates = true
|
|
16
|
+
# Hoardable.save_trash = true
|
|
17
|
+
TEXT
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -8,11 +8,26 @@ class Create<%= class_name.singularize %>Versions < ActiveRecord::Migration[<%=
|
|
|
8
8
|
t.tsrange :_during, null: false
|
|
9
9
|
t.uuid :_event_uuid, null: false, index: true
|
|
10
10
|
t.enum :_operation, enum_type: 'hoardable_operation', null: false, index: true
|
|
11
|
-
t.<%= foreign_key_type %>
|
|
11
|
+
t.<%= foreign_key_type %> :hoardable_source_id, null: false, index: true
|
|
12
12
|
end
|
|
13
|
+
execute(
|
|
14
|
+
<<~SQL
|
|
15
|
+
CREATE OR REPLACE FUNCTION hoardable_version_prevent_update() RETURNS trigger
|
|
16
|
+
LANGUAGE plpgsql AS
|
|
17
|
+
$$BEGIN
|
|
18
|
+
RAISE EXCEPTION 'updating a version is not allowed';
|
|
19
|
+
RETURN NEW;
|
|
20
|
+
END;$$;
|
|
21
|
+
|
|
22
|
+
CREATE TRIGGER <%= singularized_table_name %>_versions_prevent_update
|
|
23
|
+
BEFORE UPDATE ON <%= singularized_table_name %>_versions FOR EACH ROW
|
|
24
|
+
EXECUTE PROCEDURE hoardable_version_prevent_update();
|
|
25
|
+
SQL
|
|
26
|
+
)
|
|
27
|
+
add_index(:<%= singularized_table_name %>_versions, :id, unique: true)
|
|
13
28
|
add_index(
|
|
14
29
|
:<%= singularized_table_name %>_versions,
|
|
15
|
-
%i[_during
|
|
30
|
+
%i[_during hoardable_source_id],
|
|
16
31
|
name: 'idx_<%= singularized_table_name %>_versions_temporally'
|
|
17
32
|
)
|
|
18
33
|
end
|
|
@@ -23,11 +23,26 @@ class Create<%= class_name.singularize %>Versions < ActiveRecord::Migration[<%=
|
|
|
23
23
|
t.tsrange :_during, null: false
|
|
24
24
|
t.uuid :_event_uuid, null: false, index: true
|
|
25
25
|
t.column :_operation, :hoardable_operation, null: false, index: true
|
|
26
|
-
t.<%= foreign_key_type %>
|
|
26
|
+
t.<%= foreign_key_type %> :hoardable_source_id, null: false, index: true
|
|
27
27
|
end
|
|
28
|
+
execute(
|
|
29
|
+
<<~SQL
|
|
30
|
+
CREATE OR REPLACE FUNCTION hoardable_version_prevent_update() RETURNS trigger
|
|
31
|
+
LANGUAGE plpgsql AS
|
|
32
|
+
$$BEGIN
|
|
33
|
+
RAISE EXCEPTION 'updating a version is not allowed';
|
|
34
|
+
RETURN NEW;
|
|
35
|
+
END;$$;
|
|
36
|
+
|
|
37
|
+
CREATE TRIGGER <%= singularized_table_name %>_versions_prevent_update
|
|
38
|
+
BEFORE UPDATE ON <%= singularized_table_name %>_versions FOR EACH ROW
|
|
39
|
+
EXECUTE PROCEDURE hoardable_version_prevent_update();
|
|
40
|
+
SQL
|
|
41
|
+
)
|
|
42
|
+
add_index(:<%= singularized_table_name %>_versions, :id, unique: true)
|
|
28
43
|
add_index(
|
|
29
44
|
:<%= singularized_table_name %>_versions,
|
|
30
|
-
%i[_during
|
|
45
|
+
%i[_during hoardable_source_id],
|
|
31
46
|
name: 'idx_<%= singularized_table_name %>_versions_temporally'
|
|
32
47
|
)
|
|
33
48
|
end
|
|
@@ -7,6 +7,26 @@ module Hoardable
|
|
|
7
7
|
module Associations
|
|
8
8
|
extend ActiveSupport::Concern
|
|
9
9
|
|
|
10
|
+
# An +ActiveRecord+ extension that allows looking up {VersionModel}s by +hoardable_source_id+ as
|
|
11
|
+
# if they were {SourceModel}s.
|
|
12
|
+
module HasManyScope
|
|
13
|
+
def scope
|
|
14
|
+
@scope ||= hoardable_scope
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def hoardable_scope
|
|
20
|
+
if Hoardable.instance_variable_get('@at') &&
|
|
21
|
+
(hoardable_source_id = @association.owner.hoardable_source_id)
|
|
22
|
+
@association.scope.rewhere(@association.reflection.foreign_key => hoardable_source_id)
|
|
23
|
+
else
|
|
24
|
+
@association.scope
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
private_constant :HasManyScope
|
|
29
|
+
|
|
10
30
|
class_methods do
|
|
11
31
|
# A wrapper for +ActiveRecord+’s +belongs_to+ that allows for falling back to the most recent
|
|
12
32
|
# trashed +version+, in the case that the related source has been trashed.
|
|
@@ -17,9 +37,9 @@ module Hoardable
|
|
|
17
37
|
|
|
18
38
|
define_method(trashable_relationship_name) do
|
|
19
39
|
source_reflection = self.class.reflections[name.to_s]
|
|
20
|
-
version_class = source_reflection.
|
|
40
|
+
version_class = source_reflection.version_class
|
|
21
41
|
version_class.trashed.only_most_recent.find_by(
|
|
22
|
-
|
|
42
|
+
hoardable_source_id: source_reflection.foreign_key
|
|
23
43
|
)
|
|
24
44
|
end
|
|
25
45
|
|
|
@@ -29,6 +49,12 @@ module Hoardable
|
|
|
29
49
|
end
|
|
30
50
|
RUBY
|
|
31
51
|
end
|
|
52
|
+
|
|
53
|
+
# A wrapper for +ActiveRecord+’s +has_many+ that allows for finding temporal versions of a
|
|
54
|
+
# record cast as instances of the {SourceModel}, when doing a {Hoardable#at} query.
|
|
55
|
+
def has_many_hoardable(name, scope = nil, **options)
|
|
56
|
+
has_many(name, scope, **options) { include HasManyScope }
|
|
57
|
+
end
|
|
32
58
|
end
|
|
33
59
|
end
|
|
34
60
|
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hoardable
|
|
4
|
+
# This is a private service class that manages the insertion of {VersionModel}s into the
|
|
5
|
+
# PostgreSQL database.
|
|
6
|
+
class DatabaseClient
|
|
7
|
+
attr_reader :source_record
|
|
8
|
+
|
|
9
|
+
def initialize(source_record)
|
|
10
|
+
@source_record = source_record
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
delegate :version_class, to: :source_record
|
|
14
|
+
|
|
15
|
+
def insert_hoardable_version(operation, &block)
|
|
16
|
+
version = version_class.insert(initialize_version_attributes(operation), returning: :id)
|
|
17
|
+
version_id = version[0]['id']
|
|
18
|
+
source_record.instance_variable_set('@hoardable_version', version_class.find(version_id))
|
|
19
|
+
source_record.run_callbacks(:versioned, &block)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def find_or_initialize_hoardable_event_uuid
|
|
23
|
+
Thread.current[:hoardable_event_uuid] ||= ActiveRecord::Base.connection.query('SELECT gen_random_uuid();')[0][0]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def hoardable_version_source_id
|
|
27
|
+
@hoardable_version_source_id ||= query_hoardable_version_source_id
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def query_hoardable_version_source_id
|
|
31
|
+
primary_key = source_record.class.primary_key
|
|
32
|
+
version_class.where(primary_key => source_record.read_attribute(primary_key)).pluck('hoardable_source_id')[0]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def initialize_version_attributes(operation)
|
|
36
|
+
source_record.attributes_before_type_cast.without('id').merge(
|
|
37
|
+
source_record.changes.transform_values { |h| h[0] },
|
|
38
|
+
{
|
|
39
|
+
'hoardable_source_id' => source_record.id,
|
|
40
|
+
'_event_uuid' => find_or_initialize_hoardable_event_uuid,
|
|
41
|
+
'_operation' => operation,
|
|
42
|
+
'_data' => initialize_hoardable_data.merge(changes: source_record.changes),
|
|
43
|
+
'_during' => initialize_temporal_range
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def initialize_temporal_range
|
|
49
|
+
((previous_temporal_tsrange_end || hoardable_source_epoch)..Time.now.utc)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def initialize_hoardable_data
|
|
53
|
+
DATA_KEYS.to_h do |key|
|
|
54
|
+
[key, assign_hoardable_context(key)]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def assign_hoardable_context(key)
|
|
59
|
+
return nil if (value = Hoardable.public_send(key)).nil?
|
|
60
|
+
|
|
61
|
+
value.is_a?(Proc) ? value.call : value
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def unset_hoardable_version_and_event_uuid
|
|
65
|
+
source_record.instance_variable_set('@hoardable_version', nil)
|
|
66
|
+
return if source_record.class.connection.transaction_open?
|
|
67
|
+
|
|
68
|
+
Thread.current[:hoardable_event_uuid] = nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def previous_temporal_tsrange_end
|
|
72
|
+
source_record.versions.only_most_recent.pluck('_during').first&.end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def hoardable_source_epoch
|
|
76
|
+
if source_record.class.column_names.include?('created_at')
|
|
77
|
+
source_record.created_at
|
|
78
|
+
else
|
|
79
|
+
maybe_warn_about_missing_created_at_column
|
|
80
|
+
Time.at(0).utc
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def maybe_warn_about_missing_created_at_column
|
|
85
|
+
return unless source_record.class.hoardable_config[:warn_on_missing_created_at_column]
|
|
86
|
+
|
|
87
|
+
source_table_name = source_record.class.table_name
|
|
88
|
+
Hoardable.logger.info(
|
|
89
|
+
<<~LOG
|
|
90
|
+
'#{source_table_name}' does not have a 'created_at' column, so the first version’s temporal period
|
|
91
|
+
will begin at the unix epoch instead. Add a 'created_at' column to '#{source_table_name}'
|
|
92
|
+
or set 'Hoardable.warn_on_missing_created_at_column = false' to disable this message.
|
|
93
|
+
LOG
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
private_constant :DatabaseClient
|
|
98
|
+
end
|
data/lib/hoardable/hoardable.rb
CHANGED
|
@@ -8,7 +8,7 @@ module Hoardable
|
|
|
8
8
|
|
|
9
9
|
# Symbols for use with setting {Hoardable} configuration. See {file:README.md#configuration
|
|
10
10
|
# README} for more.
|
|
11
|
-
CONFIG_KEYS = %i[enabled version_updates save_trash
|
|
11
|
+
CONFIG_KEYS = %i[enabled version_updates save_trash warn_on_missing_created_at_column].freeze
|
|
12
12
|
|
|
13
13
|
VERSION_CLASS_SUFFIX = 'Version'
|
|
14
14
|
private_constant :VERSION_CLASS_SUFFIX
|
|
@@ -36,7 +36,7 @@ module Hoardable
|
|
|
36
36
|
|
|
37
37
|
@context = {}
|
|
38
38
|
@config = CONFIG_KEYS.to_h do |key|
|
|
39
|
-
[key,
|
|
39
|
+
[key, true]
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
class << self
|
|
@@ -75,6 +75,17 @@ module Hoardable
|
|
|
75
75
|
@context = current_context
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
+
# Allows performing a query for record states at a certain time. Returned {SourceModel}
|
|
79
|
+
# instances within the block may be {SourceModel} or {VersionModel} records.
|
|
80
|
+
#
|
|
81
|
+
# @param datetime [DateTime, Time] the datetime or time to temporally query records at
|
|
82
|
+
def at(datetime)
|
|
83
|
+
@at = datetime
|
|
84
|
+
yield
|
|
85
|
+
ensure
|
|
86
|
+
@at = nil
|
|
87
|
+
end
|
|
88
|
+
|
|
78
89
|
# @!visibility private
|
|
79
90
|
def logger
|
|
80
91
|
@logger ||= ActiveSupport::TaggedLogging.new(Logger.new($stdout))
|
data/lib/hoardable/model.rb
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Hoardable
|
|
4
|
-
# This concern provides support for PostgreSQL’s tableoid system column to {SourceModel}
|
|
5
|
-
|
|
4
|
+
# This concern provides support for PostgreSQL’s tableoid system column to {SourceModel} and
|
|
5
|
+
# temporal +ActiveRecord+ scopes.
|
|
6
|
+
module Scopes
|
|
6
7
|
extend ActiveSupport::Concern
|
|
7
8
|
|
|
8
9
|
TABLEOID_AREL_CONDITIONS = lambda do |arel_table, condition|
|
|
@@ -19,10 +20,10 @@ module Hoardable
|
|
|
19
20
|
|
|
20
21
|
# By default {Hoardable} only returns instances of the parent table, and not the +versions+ in
|
|
21
22
|
# the inherited table. This can be bypassed by using the {.include_versions} scope or wrapping
|
|
22
|
-
# the code in a `Hoardable.
|
|
23
|
+
# the code in a `Hoardable.at(datetime)` block.
|
|
23
24
|
default_scope do
|
|
24
|
-
if
|
|
25
|
-
|
|
25
|
+
if (hoardable_at = Hoardable.instance_variable_get('@at'))
|
|
26
|
+
at(hoardable_at)
|
|
26
27
|
else
|
|
27
28
|
exclude_versions
|
|
28
29
|
end
|
|
@@ -51,6 +52,18 @@ module Hoardable
|
|
|
51
52
|
# Excludes +versions+ of the parent +ActiveRecord+ class. This is included by default in the
|
|
52
53
|
# source model’s +default_scope+.
|
|
53
54
|
scope :exclude_versions, -> { where(TABLEOID_AREL_CONDITIONS.call(arel_table, :eq)) }
|
|
55
|
+
|
|
56
|
+
# @!scope class
|
|
57
|
+
# @!method at
|
|
58
|
+
# @return [ActiveRecord<Object>]
|
|
59
|
+
#
|
|
60
|
+
# Returns instances of the source model and versions that were valid at the supplied
|
|
61
|
+
# +datetime+ or +time+, all cast as instances of the source model.
|
|
62
|
+
scope :at, lambda { |datetime|
|
|
63
|
+
include_versions.where(id: version_class.at(datetime).select('id')).or(
|
|
64
|
+
where.not(id: version_class.select(:hoardable_source_id).where(DURING_QUERY, datetime))
|
|
65
|
+
)
|
|
66
|
+
}
|
|
54
67
|
end
|
|
55
68
|
|
|
56
69
|
private
|
|
@@ -16,56 +16,69 @@ module Hoardable
|
|
|
16
16
|
# @return [String] The database operation that created the +version+ - either +update+ or +delete+.
|
|
17
17
|
delegate :hoardable_event_uuid, :hoardable_operation, to: :hoardable_version, allow_nil: true
|
|
18
18
|
|
|
19
|
+
# A module for overriding +ActiveRecord#find_one+’ in the case you are doing a temporal query
|
|
20
|
+
# and the current {SourceModel} record may in fact be a {VersionModel} record.
|
|
21
|
+
module FinderMethods
|
|
22
|
+
def find_one(id)
|
|
23
|
+
conditions = { primary_key => [id, *version_class.where(hoardable_source_id: id).select(primary_key).ids] }
|
|
24
|
+
find_by(conditions) || where(conditions).raise_record_not_found_exception!
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
private_constant :FinderMethods
|
|
28
|
+
|
|
19
29
|
class_methods do
|
|
20
30
|
# The dynamically generated +Version+ class for this model.
|
|
21
31
|
def version_class
|
|
22
32
|
"#{name}#{VERSION_CLASS_SUFFIX}".constantize
|
|
23
33
|
end
|
|
34
|
+
|
|
35
|
+
# Extends the current {SourceModel} scoping to include Hoardable’s {FinderMethods} overrides.
|
|
36
|
+
def hoardable
|
|
37
|
+
extending(FinderMethods)
|
|
38
|
+
end
|
|
24
39
|
end
|
|
25
40
|
|
|
26
41
|
included do
|
|
27
|
-
include
|
|
42
|
+
include Scopes
|
|
28
43
|
|
|
29
44
|
around_update(if: [HOARDABLE_CALLBACKS_ENABLED, HOARDABLE_VERSION_UPDATES]) do |_, block|
|
|
30
|
-
|
|
45
|
+
hoardable_client.insert_hoardable_version('update', &block)
|
|
31
46
|
end
|
|
32
47
|
|
|
33
48
|
around_destroy(if: [HOARDABLE_CALLBACKS_ENABLED, HOARDABLE_SAVE_TRASH]) do |_, block|
|
|
34
|
-
|
|
49
|
+
hoardable_client.insert_hoardable_version('delete', &block)
|
|
35
50
|
end
|
|
36
51
|
|
|
37
52
|
before_destroy(if: HOARDABLE_CALLBACKS_ENABLED, unless: HOARDABLE_SAVE_TRASH) do
|
|
38
53
|
versions.delete_all(:delete_all)
|
|
39
54
|
end
|
|
40
55
|
|
|
41
|
-
after_commit {
|
|
56
|
+
after_commit { hoardable_client.unset_hoardable_version_and_event_uuid }
|
|
42
57
|
|
|
43
58
|
# Returns all +versions+ in ascending order of their temporal timeframes.
|
|
44
59
|
has_many(
|
|
45
60
|
:versions, -> { order('UPPER(_during) ASC') },
|
|
46
61
|
dependent: nil,
|
|
47
62
|
class_name: version_class.to_s,
|
|
48
|
-
inverse_of: :hoardable_source
|
|
63
|
+
inverse_of: :hoardable_source,
|
|
64
|
+
foreign_key: :hoardable_source_id
|
|
49
65
|
)
|
|
50
|
-
|
|
51
|
-
# @!scope class
|
|
52
|
-
# @!method at
|
|
53
|
-
# @return [ActiveRecord<Object>]
|
|
54
|
-
#
|
|
55
|
-
# Returns instances of the source model and versions that were valid at the supplied
|
|
56
|
-
# +datetime+ or +time+, all cast as instances of the source model.
|
|
57
|
-
scope :at, lambda { |datetime|
|
|
58
|
-
include_versions.where(id: version_class.at(datetime).select('id')).or(
|
|
59
|
-
where.not(id: version_class.select(version_class.hoardable_source_foreign_key).where(DURING_QUERY, datetime))
|
|
60
|
-
)
|
|
61
|
-
}
|
|
62
66
|
end
|
|
63
67
|
|
|
64
|
-
# Returns a boolean of whether the record is actually a trashed +version
|
|
68
|
+
# Returns a boolean of whether the record is actually a trashed +version+ cast as an instance of the
|
|
69
|
+
# source model.
|
|
65
70
|
#
|
|
66
71
|
# @return [Boolean]
|
|
67
72
|
def trashed?
|
|
68
|
-
versions.trashed.only_most_recent.first&.
|
|
73
|
+
versions.trashed.only_most_recent.first&.hoardable_source_id == id
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Returns a boolean of whether the record is actually a +version+ cast as an instance of the
|
|
77
|
+
# source model.
|
|
78
|
+
#
|
|
79
|
+
# @return [Boolean]
|
|
80
|
+
def version?
|
|
81
|
+
!!hoardable_client.hoardable_version_source_id
|
|
69
82
|
end
|
|
70
83
|
|
|
71
84
|
# Returns the +version+ at the supplied +datetime+ or +time+. It will return +self+ if there is
|
|
@@ -85,68 +98,24 @@ module Hoardable
|
|
|
85
98
|
def revert_to!(datetime)
|
|
86
99
|
return unless (version = at(datetime))
|
|
87
100
|
|
|
88
|
-
version.is_a?(
|
|
101
|
+
version.is_a?(version_class) ? version.revert! : self
|
|
89
102
|
end
|
|
90
103
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
104
|
+
# Returns the +hoardable_source_id+ that represents the original {SourceModel} record’s ID. Will
|
|
105
|
+
# return nil if the current {SourceModel} record is not an instance of a {VersionModel} cast as
|
|
106
|
+
# {SourceModel}.
|
|
107
|
+
#
|
|
108
|
+
# @return [Integer, nil]
|
|
109
|
+
def hoardable_source_id
|
|
110
|
+
hoardable_client.hoardable_version_source_id || id
|
|
95
111
|
end
|
|
96
112
|
|
|
97
|
-
|
|
98
|
-
# {SourceModel} into the PostgreSQL database.
|
|
99
|
-
class Service
|
|
100
|
-
attr_reader :source_model
|
|
101
|
-
|
|
102
|
-
def initialize(source_model)
|
|
103
|
-
@source_model = source_model
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def insert_hoardable_version(operation)
|
|
107
|
-
source_model.instance_variable_set('@hoardable_version', initialize_hoardable_version(operation))
|
|
108
|
-
source_model.run_callbacks(:versioned) do
|
|
109
|
-
yield if block_given?
|
|
110
|
-
source_model.hoardable_version.save(validate: false, touch: false)
|
|
111
|
-
end
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def find_or_initialize_hoardable_event_uuid
|
|
115
|
-
Thread.current[:hoardable_event_uuid] ||= ActiveRecord::Base.connection.query('SELECT gen_random_uuid();')[0][0]
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def initialize_hoardable_version(operation)
|
|
119
|
-
source_model.versions.new(
|
|
120
|
-
source_model.attributes_before_type_cast.without('id').merge(
|
|
121
|
-
source_model.changes.transform_values { |h| h[0] },
|
|
122
|
-
{
|
|
123
|
-
_event_uuid: find_or_initialize_hoardable_event_uuid,
|
|
124
|
-
_operation: operation,
|
|
125
|
-
_data: initialize_hoardable_data.merge(changes: source_model.changes)
|
|
126
|
-
}
|
|
127
|
-
)
|
|
128
|
-
)
|
|
129
|
-
end
|
|
113
|
+
delegate :version_class, to: :class
|
|
130
114
|
|
|
131
|
-
|
|
132
|
-
DATA_KEYS.to_h do |key|
|
|
133
|
-
[key, assign_hoardable_context(key)]
|
|
134
|
-
end
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
def assign_hoardable_context(key)
|
|
138
|
-
return nil if (value = Hoardable.public_send(key)).nil?
|
|
139
|
-
|
|
140
|
-
value.is_a?(Proc) ? value.call : value
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
def unset_hoardable_version_and_event_uuid
|
|
144
|
-
source_model.instance_variable_set('@hoardable_version', nil)
|
|
145
|
-
return if source_model.class.connection.transaction_open?
|
|
115
|
+
private
|
|
146
116
|
|
|
147
|
-
|
|
148
|
-
|
|
117
|
+
def hoardable_client
|
|
118
|
+
@hoardable_client ||= DatabaseClient.new(self)
|
|
149
119
|
end
|
|
150
|
-
private_constant :Service
|
|
151
120
|
end
|
|
152
121
|
end
|
data/lib/hoardable/version.rb
CHANGED
|
@@ -7,9 +7,11 @@ module Hoardable
|
|
|
7
7
|
extend ActiveSupport::Concern
|
|
8
8
|
|
|
9
9
|
class_methods do
|
|
10
|
-
#
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
# This is needed to omit the pseudo row of 'tableoid' when using +ActiveRecord+’s +insert+.
|
|
11
|
+
#
|
|
12
|
+
# @!visibility private
|
|
13
|
+
def scope_attributes
|
|
14
|
+
super.without('tableoid')
|
|
13
15
|
end
|
|
14
16
|
end
|
|
15
17
|
|
|
@@ -18,8 +20,7 @@ module Hoardable
|
|
|
18
20
|
belongs_to(
|
|
19
21
|
:hoardable_source,
|
|
20
22
|
inverse_of: :versions,
|
|
21
|
-
class_name: superclass.model_name
|
|
22
|
-
foreign_key: hoardable_source_foreign_key
|
|
23
|
+
class_name: superclass.model_name
|
|
23
24
|
)
|
|
24
25
|
|
|
25
26
|
self.table_name = "#{table_name.singularize}#{VERSION_TABLE_SUFFIX}"
|
|
@@ -29,8 +30,6 @@ module Hoardable
|
|
|
29
30
|
alias_attribute :hoardable_event_uuid, :_event_uuid
|
|
30
31
|
alias_attribute :hoardable_during, :_during
|
|
31
32
|
|
|
32
|
-
before_create { hoardable_version_service.assign_temporal_tsrange }
|
|
33
|
-
|
|
34
33
|
# @!scope class
|
|
35
34
|
# @!method trashed
|
|
36
35
|
# @return [ActiveRecord<Object>]
|
|
@@ -79,7 +78,7 @@ module Hoardable
|
|
|
79
78
|
|
|
80
79
|
transaction do
|
|
81
80
|
hoardable_source.tap do |reverted|
|
|
82
|
-
reverted.update!(
|
|
81
|
+
reverted.update!(hoardable_source_attributes.without('id'))
|
|
83
82
|
reverted.instance_variable_set(:@hoardable_version, self)
|
|
84
83
|
reverted.run_callbacks(:reverted)
|
|
85
84
|
end
|
|
@@ -92,10 +91,11 @@ module Hoardable
|
|
|
92
91
|
raise(Error, 'Version is not trashed, cannot untrash') unless hoardable_operation == 'delete'
|
|
93
92
|
|
|
94
93
|
transaction do
|
|
95
|
-
|
|
96
|
-
untrashed.send('
|
|
97
|
-
|
|
98
|
-
|
|
94
|
+
insert_untrashed_source.tap do |untrashed|
|
|
95
|
+
untrashed.send('hoardable_client').insert_hoardable_version('insert') do
|
|
96
|
+
untrashed.instance_variable_set(:@hoardable_version, self)
|
|
97
|
+
untrashed.run_callbacks(:untrashed)
|
|
98
|
+
end
|
|
99
99
|
end
|
|
100
100
|
end
|
|
101
101
|
end
|
|
@@ -113,72 +113,24 @@ module Hoardable
|
|
|
113
113
|
_data&.dig('changes')
|
|
114
114
|
end
|
|
115
115
|
|
|
116
|
-
# Returns the
|
|
117
|
-
def
|
|
118
|
-
|
|
116
|
+
# Returns the ID of the {SourceModel} that created this {VersionModel}
|
|
117
|
+
def hoardable_source_id
|
|
118
|
+
read_attribute('hoardable_source_id')
|
|
119
119
|
end
|
|
120
120
|
|
|
121
|
-
|
|
121
|
+
private
|
|
122
122
|
|
|
123
|
-
def
|
|
124
|
-
|
|
123
|
+
def insert_untrashed_source
|
|
124
|
+
superscope = self.class.superclass.unscoped
|
|
125
|
+
superscope.insert(hoardable_source_attributes.merge('id' => hoardable_source_id))
|
|
126
|
+
superscope.find(hoardable_source_id)
|
|
125
127
|
end
|
|
126
128
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
def initialize(version_model)
|
|
133
|
-
@version_model = version_model
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
delegate :hoardable_source_foreign_id, :hoardable_source_foreign_key, :hoardable_source, to: :version_model
|
|
137
|
-
|
|
138
|
-
def insert_untrashed_source
|
|
139
|
-
superscope = version_model.class.superclass.unscoped
|
|
140
|
-
superscope.insert(hoardable_source_attributes.merge('id' => hoardable_source_foreign_id))
|
|
141
|
-
superscope.find(hoardable_source_foreign_id)
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
def hoardable_source_attributes
|
|
145
|
-
@hoardable_source_attributes ||=
|
|
146
|
-
version_model
|
|
147
|
-
.attributes_before_type_cast
|
|
148
|
-
.without(hoardable_source_foreign_key)
|
|
149
|
-
.reject { |k, _v| k.start_with?('_') }
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
def previous_temporal_tsrange_end
|
|
153
|
-
hoardable_source.versions.only_most_recent.pluck('_during').first&.end
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
def hoardable_source_epoch
|
|
157
|
-
if hoardable_source.class.column_names.include?('created_at')
|
|
158
|
-
hoardable_source.created_at
|
|
159
|
-
else
|
|
160
|
-
maybe_warn_about_missing_created_at_column
|
|
161
|
-
Time.at(0).utc
|
|
162
|
-
end
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
def assign_temporal_tsrange
|
|
166
|
-
version_model._during = ((previous_temporal_tsrange_end || hoardable_source_epoch)..Time.now.utc)
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
def maybe_warn_about_missing_created_at_column
|
|
170
|
-
return unless hoardable_source.class.hoardable_config[:warn_on_missing_created_at_column]
|
|
171
|
-
|
|
172
|
-
source_table_name = hoardable_source.class.table_name
|
|
173
|
-
Hoardable.logger.info(
|
|
174
|
-
<<~LOG
|
|
175
|
-
'#{source_table_name}' does not have a 'created_at' column, so the first version’s temporal period
|
|
176
|
-
will begin at the unix epoch instead. Add a 'created_at' column to '#{source_table_name}'
|
|
177
|
-
or set 'Hoardable.warn_on_missing_created_at_column = false' to disable this message.
|
|
178
|
-
LOG
|
|
179
|
-
)
|
|
180
|
-
end
|
|
129
|
+
def hoardable_source_attributes
|
|
130
|
+
@hoardable_source_attributes ||=
|
|
131
|
+
attributes_before_type_cast
|
|
132
|
+
.without('hoardable_source_id')
|
|
133
|
+
.reject { |k, _v| k.start_with?('_') }
|
|
181
134
|
end
|
|
182
|
-
private_constant :Service
|
|
183
135
|
end
|
|
184
136
|
end
|
data/lib/hoardable.rb
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative 'hoardable/version'
|
|
4
4
|
require_relative 'hoardable/hoardable'
|
|
5
|
-
require_relative 'hoardable/
|
|
5
|
+
require_relative 'hoardable/scopes'
|
|
6
6
|
require_relative 'hoardable/error'
|
|
7
|
+
require_relative 'hoardable/database_client'
|
|
7
8
|
require_relative 'hoardable/source_model'
|
|
8
9
|
require_relative 'hoardable/version_model'
|
|
9
10
|
require_relative 'hoardable/model'
|
|
10
11
|
require_relative 'hoardable/associations'
|
|
11
12
|
require_relative 'generators/hoardable/migration_generator'
|
|
13
|
+
require_relative 'generators/hoardable/initializer_generator'
|
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, :warn_on_missing_created_at_column]
|
|
5
5
|
VERSION_CLASS_SUFFIX: String
|
|
6
6
|
VERSION_TABLE_SUFFIX: String
|
|
7
7
|
DURING_QUERY: String
|
|
@@ -10,12 +10,14 @@ module Hoardable
|
|
|
10
10
|
HOARDABLE_VERSION_UPDATES: ^(untyped) -> untyped
|
|
11
11
|
self.@context: Hash[untyped, untyped]
|
|
12
12
|
self.@config: untyped
|
|
13
|
+
self.@at: nil
|
|
13
14
|
self.@logger: untyped
|
|
14
15
|
|
|
15
16
|
def self.with: (untyped hash) -> untyped
|
|
17
|
+
def self.at: (untyped datetime) -> untyped
|
|
16
18
|
def self.logger: -> untyped
|
|
17
19
|
|
|
18
|
-
module
|
|
20
|
+
module Scopes
|
|
19
21
|
TABLEOID_AREL_CONDITIONS: Proc
|
|
20
22
|
|
|
21
23
|
private
|
|
@@ -28,57 +30,62 @@ module Hoardable
|
|
|
28
30
|
class Error < StandardError
|
|
29
31
|
end
|
|
30
32
|
|
|
33
|
+
class DatabaseClient
|
|
34
|
+
@hoardable_version_source_id: untyped
|
|
35
|
+
|
|
36
|
+
attr_reader source_record: SourceModel
|
|
37
|
+
def initialize: (SourceModel source_record) -> void
|
|
38
|
+
def insert_hoardable_version: (untyped operation) -> untyped
|
|
39
|
+
def find_or_initialize_hoardable_event_uuid: -> untyped
|
|
40
|
+
def hoardable_version_source_id: -> untyped
|
|
41
|
+
def query_hoardable_version_source_id: -> untyped
|
|
42
|
+
def initialize_version_attributes: (untyped operation) -> untyped
|
|
43
|
+
def initialize_temporal_range: -> Range
|
|
44
|
+
def initialize_hoardable_data: -> untyped
|
|
45
|
+
def assign_hoardable_context: (:event_uuid | :meta | :note | :whodunit key) -> nil
|
|
46
|
+
def unset_hoardable_version_and_event_uuid: -> nil
|
|
47
|
+
def previous_temporal_tsrange_end: -> untyped
|
|
48
|
+
def hoardable_source_epoch: -> Time
|
|
49
|
+
def maybe_warn_about_missing_created_at_column: -> nil
|
|
50
|
+
end
|
|
51
|
+
|
|
31
52
|
module SourceModel
|
|
32
|
-
include
|
|
33
|
-
@
|
|
53
|
+
include Scopes
|
|
54
|
+
@hoardable_client: DatabaseClient
|
|
34
55
|
|
|
35
|
-
attr_reader hoardable_version:
|
|
56
|
+
attr_reader hoardable_version: untyped
|
|
36
57
|
def trashed?: -> untyped
|
|
58
|
+
def version?: -> untyped
|
|
37
59
|
def at: (untyped datetime) -> SourceModel
|
|
38
60
|
def revert_to!: (untyped datetime) -> SourceModel?
|
|
61
|
+
def hoardable_source_id: -> untyped
|
|
39
62
|
|
|
40
63
|
private
|
|
41
|
-
def
|
|
64
|
+
def hoardable_client: -> DatabaseClient
|
|
42
65
|
|
|
43
66
|
public
|
|
44
67
|
def version_class: -> untyped
|
|
68
|
+
def hoardable: -> untyped
|
|
45
69
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def initialize: (SourceModel source_model) -> void
|
|
49
|
-
def insert_hoardable_version: (untyped operation) -> untyped
|
|
50
|
-
def find_or_initialize_hoardable_event_uuid: -> untyped
|
|
51
|
-
def initialize_hoardable_version: (untyped operation) -> untyped
|
|
52
|
-
def initialize_hoardable_data: -> untyped
|
|
53
|
-
def assign_hoardable_context: (:event_uuid | :meta | :note | :whodunit key) -> nil
|
|
54
|
-
def unset_hoardable_version_and_event_uuid: -> nil
|
|
70
|
+
module FinderMethods
|
|
71
|
+
def find_one: (untyped id) -> untyped
|
|
55
72
|
end
|
|
56
73
|
end
|
|
57
74
|
|
|
58
75
|
module VersionModel
|
|
59
|
-
@
|
|
60
|
-
@hoardable_source_foreign_key: String
|
|
61
|
-
@hoardable_version_service: Service
|
|
76
|
+
@hoardable_source_attributes: untyped
|
|
62
77
|
|
|
63
78
|
def revert!: -> untyped
|
|
64
79
|
def untrash!: -> untyped
|
|
65
80
|
def changes: -> untyped
|
|
66
|
-
def
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def initialize: (VersionModel version_model) -> void
|
|
75
|
-
def insert_untrashed_source: -> untyped
|
|
76
|
-
def hoardable_source_attributes: -> untyped
|
|
77
|
-
def previous_temporal_tsrange_end: -> untyped
|
|
78
|
-
def hoardable_source_epoch: -> Time
|
|
79
|
-
def assign_temporal_tsrange: -> Range
|
|
80
|
-
def maybe_warn_about_missing_created_at_column: -> nil
|
|
81
|
-
end
|
|
81
|
+
def hoardable_source_id: -> untyped
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
def insert_untrashed_source: -> untyped
|
|
85
|
+
def hoardable_source_attributes: -> untyped
|
|
86
|
+
|
|
87
|
+
public
|
|
88
|
+
def scope_attributes: -> untyped
|
|
82
89
|
end
|
|
83
90
|
|
|
84
91
|
module Model
|
|
@@ -93,6 +100,17 @@ module Hoardable
|
|
|
93
100
|
|
|
94
101
|
module Associations
|
|
95
102
|
def belongs_to_trashable: (untyped name, ?nil scope, **untyped) -> untyped
|
|
103
|
+
def has_many_hoardable: (untyped name, ?nil scope, **untyped) -> untyped
|
|
104
|
+
|
|
105
|
+
module HasManyScope
|
|
106
|
+
@scope: untyped
|
|
107
|
+
@association: bot
|
|
108
|
+
|
|
109
|
+
def scope: -> untyped
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
def hoardable_scope: -> untyped
|
|
113
|
+
end
|
|
96
114
|
end
|
|
97
115
|
|
|
98
116
|
class MigrationGenerator
|
|
@@ -103,4 +121,8 @@ module Hoardable
|
|
|
103
121
|
def migration_template_name: -> String
|
|
104
122
|
def singularized_table_name: -> untyped
|
|
105
123
|
end
|
|
124
|
+
|
|
125
|
+
class InitializerGenerator
|
|
126
|
+
def create_initializer_file: -> untyped
|
|
127
|
+
end
|
|
106
128
|
end
|
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.9.1
|
|
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-10-03 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activerecord
|
|
@@ -104,16 +104,18 @@ files:
|
|
|
104
104
|
- LICENSE.txt
|
|
105
105
|
- README.md
|
|
106
106
|
- Rakefile
|
|
107
|
+
- lib/generators/hoardable/initializer_generator.rb
|
|
107
108
|
- lib/generators/hoardable/migration_generator.rb
|
|
108
109
|
- lib/generators/hoardable/templates/migration.rb.erb
|
|
109
110
|
- lib/generators/hoardable/templates/migration_6.rb.erb
|
|
110
111
|
- lib/hoardable.rb
|
|
111
112
|
- lib/hoardable/associations.rb
|
|
113
|
+
- lib/hoardable/database_client.rb
|
|
112
114
|
- lib/hoardable/error.rb
|
|
113
115
|
- lib/hoardable/hoardable.rb
|
|
114
116
|
- lib/hoardable/model.rb
|
|
117
|
+
- lib/hoardable/scopes.rb
|
|
115
118
|
- lib/hoardable/source_model.rb
|
|
116
|
-
- lib/hoardable/tableoid.rb
|
|
117
119
|
- lib/hoardable/version.rb
|
|
118
120
|
- lib/hoardable/version_model.rb
|
|
119
121
|
- sig/hoardable.rbs
|