hoardable 0.3.0 → 0.6.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 +8 -0
- data/CHANGELOG.md +22 -0
- data/README.md +57 -26
- 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 +27 -5
- data/lib/hoardable/model.rb +4 -3
- data/lib/hoardable/source_model.rb +79 -68
- data/lib/hoardable/tableoid.rb +10 -2
- data/lib/hoardable/version.rb +1 -1
- data/lib/hoardable/version_model.rb +90 -39
- data/lib/hoardable.rb +1 -0
- data/sig/hoardable.rbs +43 -23
- 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: dff71dd2ebbebaeedfdc9d6fdd8c56c8faaa5505814cfe41c146c4a565375ff1
|
4
|
+
data.tar.gz: 6693f3634541bc8308ed5b4329d69851d971df1cec2b50e842fa9332c62425b6
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: ec2a9df9254cf3623f4fffb0516e99ec30a46ec6496ad0d5e555d4a3ce8324fa81d1880036f42aeb4bbbee136957479f283ccab4b8fb3f2847122bd597483889
|
7
|
+
data.tar.gz: 25a5040c6dc5b91b0b54020657e436aa775cf2211b56fecf1b277e36c11041b55f5a29861000503d5388a9a3d0280a8ccca99c052a2dc82a45abb8f5c98bb50e
|
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,10 @@ Layout/LineLength:
|
|
8
9
|
Metrics/ClassLength:
|
9
10
|
Exclude:
|
10
11
|
- 'test/**/*.rb'
|
12
|
+
|
13
|
+
Metrics/BlockLength:
|
14
|
+
Exclude:
|
15
|
+
- 'test/**/*.rb'
|
16
|
+
|
17
|
+
Style/DocumentDynamicEvalDefinition:
|
18
|
+
Enabled: false
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,27 @@
|
|
1
1
|
## [Unreleased]
|
2
2
|
|
3
|
+
## [0.6.0] - 2022-09-28
|
4
|
+
|
5
|
+
- **Breaking Change** - Previously, a source model would `has_many :versions` with an inverse
|
6
|
+
relationship of the i18n interpreted name of the source model. Now it simply `has_many :versions,
|
7
|
+
inverse_of :hoardable_source` to not potentially conflict with previously existing relationships.
|
8
|
+
|
9
|
+
## [0.5.0] - 2022-09-25
|
10
|
+
|
11
|
+
- **Breaking Change** - Untrashing a version will now insert a version for the untrash event with
|
12
|
+
it's own temporal timespan. This simplifies the ability to query versions temporarily for when
|
13
|
+
they were trashed or not. This changes, but corrects, temporal query results using `.at`.
|
14
|
+
|
15
|
+
- **Breaking Change** - Because of the above, a new operation enum value of "insert" was added. If
|
16
|
+
you already have the `hoardable_operation` enum in your PostgreSQL schema, you can add it by
|
17
|
+
executing the following SQL in a new migration: `ALTER TYPE hoardable_operation ADD VALUE
|
18
|
+
'insert';`.
|
19
|
+
|
20
|
+
## [0.4.0] - 2022-09-24
|
21
|
+
|
22
|
+
- **Breaking Change** - Trashed versions now pull from the same postgres sequenced used by the
|
23
|
+
source model’s table.
|
24
|
+
|
3
25
|
## [0.1.0] - 2022-07-23
|
4
26
|
|
5
27
|
- 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,7 @@ 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
|
149
|
-
# config/
|
179
|
+
# config/initializers/hoardable.rb
|
150
180
|
Hoardable.whodunit = -> { Current.user&.id }
|
151
181
|
|
152
182
|
# somewhere in your app code
|
@@ -177,7 +207,7 @@ class ApplicationController < ActionController::Base
|
|
177
207
|
Hoardable.with(whodunit: current_user.id, meta: { request_uuid: request.uuid }) do
|
178
208
|
yield
|
179
209
|
end
|
180
|
-
# `Hoardable.whodunit`
|
210
|
+
# `Hoardable.whodunit` and `Hoardable.meta` are back to nil or their previously set values
|
181
211
|
end
|
182
212
|
end
|
183
213
|
```
|
@@ -227,7 +257,7 @@ end
|
|
227
257
|
|
228
258
|
### Configuration
|
229
259
|
|
230
|
-
|
260
|
+
The configurable options are:
|
231
261
|
|
232
262
|
```ruby
|
233
263
|
Hoardable.enabled # => default true
|
@@ -278,18 +308,18 @@ If a model-level option exists, it will use that. Otherwise, it will fall back t
|
|
278
308
|
|
279
309
|
### Relationships
|
280
310
|
|
281
|
-
As in life, sometimes relationships can be hard
|
282
|
-
|
311
|
+
As in life, sometimes relationships can be hard, but here are some pointers on handling associations
|
312
|
+
with `Hoardable` considerations.
|
283
313
|
|
284
|
-
Sometimes you’ll have a record that belongs to a record that you’ll trash. Now the child
|
285
|
-
foreign key will point to the non-existent trashed version of the parent. If you would like
|
286
|
-
`belongs_to`
|
287
|
-
|
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`:
|
288
318
|
|
289
319
|
```ruby
|
290
320
|
class Comment
|
291
|
-
include Hoardable::Model
|
292
|
-
|
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
|
293
323
|
end
|
294
324
|
```
|
295
325
|
|
@@ -317,7 +347,8 @@ end
|
|
317
347
|
|
318
348
|
If there are models that might be related to versions that are trashed or otherwise, and/or might
|
319
349
|
trashed themselves, you can bypass the inherited tables query handling altogether by using the
|
320
|
-
`return_everything` configuration variable in `Hoardable.with
|
350
|
+
`return_everything` configuration variable in `Hoardable.with`. This will ensure that you always see
|
351
|
+
all records, including update and trashed versions.
|
321
352
|
|
322
353
|
```ruby
|
323
354
|
post.destroy!
|
@@ -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,18 +5,34 @@ 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 return_everything].freeze
|
11
|
+
CONFIG_KEYS = %i[enabled version_updates save_trash return_everything warn_on_missing_created_at_column].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
|
21
|
+
|
22
|
+
HOARDABLE_CALLBACKS_ENABLED = proc do |source_model|
|
23
|
+
source_model.class.hoardable_config[:enabled] && !source_model.class.name.end_with?(VERSION_CLASS_SUFFIX)
|
24
|
+
end.freeze
|
25
|
+
private_constant :HOARDABLE_CALLBACKS_ENABLED
|
26
|
+
|
27
|
+
HOARDABLE_SAVE_TRASH = proc do |source_model|
|
28
|
+
source_model.class.hoardable_config[:save_trash]
|
29
|
+
end.freeze
|
30
|
+
private_constant :HOARDABLE_SAVE_TRASH
|
31
|
+
|
32
|
+
HOARDABLE_VERSION_UPDATES = proc do |source_model|
|
33
|
+
source_model.class.hoardable_config[:version_updates]
|
34
|
+
end.freeze
|
35
|
+
private_constant :HOARDABLE_VERSION_UPDATES
|
20
36
|
|
21
37
|
@context = {}
|
22
38
|
@config = CONFIG_KEYS.to_h do |key|
|
@@ -44,7 +60,8 @@ module Hoardable
|
|
44
60
|
end
|
45
61
|
end
|
46
62
|
|
47
|
-
# This is a general use method for setting {
|
63
|
+
# This is a general use method for setting {file:README.md#tracking-contextual-data Contextual
|
64
|
+
# Data} or {file:README.md#configuration Configuration} around a block.
|
48
65
|
#
|
49
66
|
# @param hash [Hash] config and contextual data to set within a block
|
50
67
|
def with(hash)
|
@@ -57,5 +74,10 @@ module Hoardable
|
|
57
74
|
@config = current_config
|
58
75
|
@context = current_context
|
59
76
|
end
|
77
|
+
|
78
|
+
# @!visibility private
|
79
|
+
def logger
|
80
|
+
@logger ||= ActiveSupport::TaggedLogging.new(Logger.new($stdout))
|
81
|
+
end
|
60
82
|
end
|
61
83
|
end
|
data/lib/hoardable/model.rb
CHANGED
@@ -21,10 +21,10 @@ module Hoardable
|
|
21
21
|
# @return [Hash]
|
22
22
|
def hoardable_config(hash = nil)
|
23
23
|
if hash
|
24
|
-
@_hoardable_config = hash.slice(*
|
24
|
+
@_hoardable_config = hash.slice(*CONFIG_KEYS)
|
25
25
|
else
|
26
26
|
@_hoardable_config ||= {}
|
27
|
-
|
27
|
+
CONFIG_KEYS.to_h do |key|
|
28
28
|
[key, @_hoardable_config.key?(key) ? @_hoardable_config[key] : Hoardable.send(key)]
|
29
29
|
end
|
30
30
|
end
|
@@ -37,7 +37,7 @@ module Hoardable
|
|
37
37
|
# {CONFIG_KEYS}
|
38
38
|
def with_hoardable_config(hash)
|
39
39
|
current_config = @_hoardable_config
|
40
|
-
@_hoardable_config = hash.slice(*
|
40
|
+
@_hoardable_config = hash.slice(*CONFIG_KEYS)
|
41
41
|
yield
|
42
42
|
ensure
|
43
43
|
@_hoardable_config = current_config
|
@@ -45,6 +45,7 @@ module Hoardable
|
|
45
45
|
end
|
46
46
|
|
47
47
|
included do
|
48
|
+
include Associations
|
48
49
|
define_model_callbacks :versioned
|
49
50
|
define_model_callbacks :reverted, only: :after
|
50
51
|
define_model_callbacks :untrashed, only: :after
|
@@ -7,6 +7,15 @@ module Hoardable
|
|
7
7
|
module SourceModel
|
8
8
|
extend ActiveSupport::Concern
|
9
9
|
|
10
|
+
# The +Version+ class instance for use within +versioned+, +reverted+, and +untrashed+ callbacks.
|
11
|
+
attr_reader :hoardable_version
|
12
|
+
|
13
|
+
# @!attribute [r] hoardable_event_uuid
|
14
|
+
# @return [String] A postgres UUID that represents the +version+’s +ActiveRecord+ database transaction
|
15
|
+
# @!attribute [r] hoardable_operation
|
16
|
+
# @return [String] The database operation that created the +version+ - either +update+ or +delete+.
|
17
|
+
delegate :hoardable_event_uuid, :hoardable_operation, to: :hoardable_version, allow_nil: true
|
18
|
+
|
10
19
|
class_methods do
|
11
20
|
# The dynamically generated +Version+ class for this model.
|
12
21
|
def version_class
|
@@ -17,35 +26,46 @@ module Hoardable
|
|
17
26
|
included do
|
18
27
|
include Tableoid
|
19
28
|
|
20
|
-
around_update
|
21
|
-
|
22
|
-
|
23
|
-
after_commit :unset_hoardable_version_and_event_uuid
|
29
|
+
around_update(if: [HOARDABLE_CALLBACKS_ENABLED, HOARDABLE_VERSION_UPDATES]) do |_, block|
|
30
|
+
hoardable_source_service.insert_hoardable_version('update', &block)
|
31
|
+
end
|
24
32
|
|
25
|
-
|
26
|
-
|
27
|
-
|
33
|
+
around_destroy(if: [HOARDABLE_CALLBACKS_ENABLED, HOARDABLE_SAVE_TRASH]) do |_, block|
|
34
|
+
hoardable_source_service.insert_hoardable_version('delete', &block)
|
35
|
+
end
|
28
36
|
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
37
|
+
before_destroy(if: HOARDABLE_CALLBACKS_ENABLED, unless: HOARDABLE_SAVE_TRASH) do
|
38
|
+
versions.delete_all(:delete_all)
|
39
|
+
end
|
40
|
+
|
41
|
+
after_commit { hoardable_source_service.unset_hoardable_version_and_event_uuid }
|
34
42
|
|
35
43
|
# Returns all +versions+ in ascending order of their temporal timeframes.
|
36
44
|
has_many(
|
37
|
-
:versions, -> { order(
|
45
|
+
:versions, -> { order('UPPER(_during) ASC') },
|
38
46
|
dependent: nil,
|
39
47
|
class_name: version_class.to_s,
|
40
|
-
inverse_of:
|
48
|
+
inverse_of: :hoardable_source
|
41
49
|
)
|
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
|
+
}
|
42
62
|
end
|
43
63
|
|
44
64
|
# Returns a boolean of whether the record is actually a trashed +version+.
|
45
65
|
#
|
46
66
|
# @return [Boolean]
|
47
67
|
def trashed?
|
48
|
-
versions.trashed.
|
68
|
+
versions.trashed.only_most_recent.first&.hoardable_source_foreign_id == id
|
49
69
|
end
|
50
70
|
|
51
71
|
# Returns the +version+ at the supplied +datetime+ or +time+. It will return +self+ if there is
|
@@ -55,7 +75,7 @@ module Hoardable
|
|
55
75
|
def at(datetime)
|
56
76
|
raise(Error, 'Future state cannot be known') if datetime.future?
|
57
77
|
|
58
|
-
versions.
|
78
|
+
versions.at(datetime).first || self
|
59
79
|
end
|
60
80
|
|
61
81
|
# If a version is found at the supplied datetime, it will +revert!+ to it and return it. This
|
@@ -70,72 +90,63 @@ module Hoardable
|
|
70
90
|
|
71
91
|
private
|
72
92
|
|
73
|
-
def
|
74
|
-
|
75
|
-
end
|
76
|
-
|
77
|
-
def hoardable_save_trash
|
78
|
-
self.class.hoardable_config[:save_trash]
|
93
|
+
def hoardable_source_service
|
94
|
+
@hoardable_source_service ||= Service.new(self)
|
79
95
|
end
|
80
96
|
|
81
|
-
|
82
|
-
|
83
|
-
|
97
|
+
# This is a private service class that manages the insertion of {VersionModel}s for a
|
98
|
+
# {SourceModel} into the PostgreSQL database.
|
99
|
+
class Service
|
100
|
+
attr_reader :source_model
|
84
101
|
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
def insert_hoardable_version_on_destroy(&block)
|
90
|
-
insert_hoardable_version('delete', attributes_before_type_cast, &block)
|
91
|
-
end
|
102
|
+
def initialize(source_model)
|
103
|
+
@source_model = source_model
|
104
|
+
end
|
92
105
|
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
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
|
98
112
|
end
|
99
|
-
end
|
100
113
|
|
101
|
-
|
102
|
-
|
103
|
-
|
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
|
104
117
|
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
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
|
+
)
|
114
128
|
)
|
115
|
-
)
|
116
|
-
end
|
117
|
-
|
118
|
-
def initialize_hoardable_data
|
119
|
-
DATA_KEYS.to_h do |key|
|
120
|
-
[key, assign_hoardable_context(key)]
|
121
129
|
end
|
122
|
-
end
|
123
130
|
|
124
|
-
|
125
|
-
|
131
|
+
def initialize_hoardable_data
|
132
|
+
DATA_KEYS.to_h do |key|
|
133
|
+
[key, assign_hoardable_context(key)]
|
134
|
+
end
|
135
|
+
end
|
126
136
|
|
127
|
-
|
128
|
-
|
137
|
+
def assign_hoardable_context(key)
|
138
|
+
return nil if (value = Hoardable.public_send(key)).nil?
|
129
139
|
|
130
|
-
|
131
|
-
|
132
|
-
end
|
140
|
+
value.is_a?(Proc) ? value.call : value
|
141
|
+
end
|
133
142
|
|
134
|
-
|
135
|
-
|
136
|
-
|
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?
|
137
146
|
|
138
|
-
|
147
|
+
Thread.current[:hoardable_event_uuid] = nil
|
148
|
+
end
|
139
149
|
end
|
150
|
+
private_constant :Service
|
140
151
|
end
|
141
152
|
end
|
data/lib/hoardable/tableoid.rb
CHANGED
@@ -5,13 +5,13 @@ 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
|
@@ -24,7 +24,7 @@ module Hoardable
|
|
24
24
|
if hoardable_config[:return_everything]
|
25
25
|
where(nil)
|
26
26
|
else
|
27
|
-
|
27
|
+
exclude_versions
|
28
28
|
end
|
29
29
|
end
|
30
30
|
|
@@ -43,6 +43,14 @@ module Hoardable
|
|
43
43
|
# Returns only +versions+ of the parent +ActiveRecord+ class, cast as instances of the source
|
44
44
|
# model’s class.
|
45
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)) }
|
46
54
|
end
|
47
55
|
|
48
56
|
private
|
data/lib/hoardable/version.rb
CHANGED
@@ -6,29 +6,38 @@ module Hoardable
|
|
6
6
|
module VersionModel
|
7
7
|
extend ActiveSupport::Concern
|
8
8
|
|
9
|
-
|
10
|
-
|
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
|
11
15
|
|
16
|
+
included do
|
12
17
|
# A +version+ belongs to it’s parent +ActiveRecord+ source.
|
13
|
-
belongs_to
|
14
|
-
|
18
|
+
belongs_to(
|
19
|
+
:hoardable_source,
|
20
|
+
inverse_of: :versions,
|
21
|
+
class_name: superclass.model_name,
|
22
|
+
foreign_key: hoardable_source_foreign_key
|
23
|
+
)
|
15
24
|
|
16
|
-
self.table_name = "#{table_name.singularize}#{
|
25
|
+
self.table_name = "#{table_name.singularize}#{VERSION_TABLE_SUFFIX}"
|
17
26
|
|
18
27
|
alias_method :readonly?, :persisted?
|
19
28
|
alias_attribute :hoardable_operation, :_operation
|
20
29
|
alias_attribute :hoardable_event_uuid, :_event_uuid
|
21
30
|
alias_attribute :hoardable_during, :_during
|
22
31
|
|
23
|
-
before_create
|
32
|
+
before_create { hoardable_version_service.assign_temporal_tsrange }
|
24
33
|
|
25
34
|
# @!scope class
|
26
35
|
# @!method trashed
|
27
36
|
# @return [ActiveRecord<Object>]
|
28
37
|
#
|
29
|
-
# Returns only trashed +versions+ that are orphans.
|
38
|
+
# Returns only trashed +versions+ that are currently orphans.
|
30
39
|
scope :trashed, lambda {
|
31
|
-
left_outer_joins(
|
40
|
+
left_outer_joins(:hoardable_source)
|
32
41
|
.where(superclass.table_name => { id: nil })
|
33
42
|
.where(_operation: 'delete')
|
34
43
|
}
|
@@ -38,7 +47,14 @@ module Hoardable
|
|
38
47
|
# @return [ActiveRecord<Object>]
|
39
48
|
#
|
40
49
|
# Returns +versions+ that were valid at the supplied +datetime+ or +time+.
|
41
|
-
scope :at, ->(datetime) { where(DURING_QUERY, datetime) }
|
50
|
+
scope :at, ->(datetime) { where(_operation: %w[delete update]).where(DURING_QUERY, datetime) }
|
51
|
+
|
52
|
+
# @!scope class
|
53
|
+
# @!method trashed_at
|
54
|
+
# @return [ActiveRecord<Object>]
|
55
|
+
#
|
56
|
+
# Returns +versions+ that were trashed at the supplied +datetime+ or +time+.
|
57
|
+
scope :trashed_at, ->(datetime) { where(_operation: 'insert').where(DURING_QUERY, datetime) }
|
42
58
|
|
43
59
|
# @!scope class
|
44
60
|
# @!method with_hoardable_event_uuid
|
@@ -47,6 +63,13 @@ module Hoardable
|
|
47
63
|
# Returns all +versions+ that were created as part of the same +ActiveRecord+ database
|
48
64
|
# transaction of the supplied +event_uuid+. Useful in +reverted+ and +untrashed+ callbacks.
|
49
65
|
scope :with_hoardable_event_uuid, ->(event_uuid) { where(_event_uuid: event_uuid) }
|
66
|
+
|
67
|
+
# @!scope class
|
68
|
+
# @!method only_most_recent
|
69
|
+
# @return [ActiveRecord<Object>]
|
70
|
+
#
|
71
|
+
# Returns a limited +ActiveRecord+ scope of only the most recent version.
|
72
|
+
scope :only_most_recent, -> { limit(1).reorder('UPPER(_during) DESC') }
|
50
73
|
end
|
51
74
|
|
52
75
|
# Reverts the parent +ActiveRecord+ instance to the saved attributes of this +version+. Raises
|
@@ -56,7 +79,7 @@ module Hoardable
|
|
56
79
|
|
57
80
|
transaction do
|
58
81
|
hoardable_source.tap do |reverted|
|
59
|
-
reverted.update!(hoardable_source_attributes.without('id'))
|
82
|
+
reverted.update!(hoardable_version_service.hoardable_source_attributes.without('id'))
|
60
83
|
reverted.instance_variable_set(:@hoardable_version, self)
|
61
84
|
reverted.run_callbacks(:reverted)
|
62
85
|
end
|
@@ -69,9 +92,8 @@ module Hoardable
|
|
69
92
|
raise(Error, 'Version is not trashed, cannot untrash') unless hoardable_operation == 'delete'
|
70
93
|
|
71
94
|
transaction do
|
72
|
-
|
73
|
-
|
74
|
-
superscope.find(hoardable_source_foreign_id).tap do |untrashed|
|
95
|
+
hoardable_version_service.insert_untrashed_source.tap do |untrashed|
|
96
|
+
untrashed.send('hoardable_source_service').insert_hoardable_version('insert')
|
75
97
|
untrashed.instance_variable_set(:@hoardable_version, self)
|
76
98
|
untrashed.run_callbacks(:untrashed)
|
77
99
|
end
|
@@ -91,43 +113,72 @@ module Hoardable
|
|
91
113
|
_data&.dig('changes')
|
92
114
|
end
|
93
115
|
|
94
|
-
|
95
|
-
|
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
|
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)
|
100
119
|
end
|
101
120
|
|
102
|
-
|
103
|
-
@hoardable_source_attributes ||=
|
104
|
-
attributes_before_type_cast
|
105
|
-
.without(hoardable_source_foreign_key)
|
106
|
-
.reject { |k, _v| k.start_with?('_') }
|
107
|
-
end
|
121
|
+
delegate :hoardable_source_foreign_key, to: :class
|
108
122
|
|
109
|
-
def
|
110
|
-
@
|
123
|
+
def hoardable_version_service
|
124
|
+
@hoardable_version_service ||= Service.new(self)
|
111
125
|
end
|
112
126
|
|
113
|
-
|
114
|
-
|
115
|
-
|
127
|
+
# This is a private service class that manages the construction of {VersionModel} attributes and
|
128
|
+
# untrashing / re-insertion into the {SourceModel} table.
|
129
|
+
class Service
|
130
|
+
attr_reader :version_model
|
116
131
|
|
117
|
-
|
118
|
-
|
119
|
-
|
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
|
120
155
|
|
121
|
-
|
122
|
-
range_start = (
|
123
|
-
previous_temporal_tsrange_end ||
|
156
|
+
def hoardable_source_epoch
|
124
157
|
if hoardable_source.class.column_names.include?('created_at')
|
125
158
|
hoardable_source.created_at
|
126
159
|
else
|
127
|
-
|
160
|
+
maybe_warn_about_missing_created_at_column
|
161
|
+
Time.at(0).utc
|
128
162
|
end
|
129
|
-
|
130
|
-
|
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
|
131
181
|
end
|
182
|
+
private_constant :Service
|
132
183
|
end
|
133
184
|
end
|
data/lib/hoardable.rb
CHANGED
data/sig/hoardable.rbs
CHANGED
@@ -1,14 +1,19 @@
|
|
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, :return_everything]
|
4
|
+
CONFIG_KEYS: [:enabled, :version_updates, :save_trash, :return_everything, :warn_on_missing_created_at_column]
|
5
5
|
VERSION_CLASS_SUFFIX: String
|
6
6
|
VERSION_TABLE_SUFFIX: String
|
7
7
|
DURING_QUERY: String
|
8
|
+
HOARDABLE_CALLBACKS_ENABLED: ^(untyped) -> untyped
|
9
|
+
HOARDABLE_SAVE_TRASH: ^(untyped) -> untyped
|
10
|
+
HOARDABLE_VERSION_UPDATES: ^(untyped) -> untyped
|
8
11
|
self.@context: Hash[untyped, untyped]
|
9
12
|
self.@config: untyped
|
13
|
+
self.@logger: untyped
|
10
14
|
|
11
15
|
def self.with: (untyped hash) -> untyped
|
16
|
+
def self.logger: -> untyped
|
12
17
|
|
13
18
|
module Tableoid
|
14
19
|
TABLEOID_AREL_CONDITIONS: Proc
|
@@ -25,56 +30,71 @@ module Hoardable
|
|
25
30
|
|
26
31
|
module SourceModel
|
27
32
|
include Tableoid
|
33
|
+
@hoardable_source_service: Service
|
28
34
|
|
35
|
+
attr_reader hoardable_version: nil
|
29
36
|
def trashed?: -> untyped
|
30
37
|
def at: (untyped datetime) -> SourceModel
|
31
38
|
def revert_to!: (untyped datetime) -> SourceModel?
|
32
39
|
|
33
40
|
private
|
34
|
-
def
|
35
|
-
def hoardable_save_trash: -> untyped
|
36
|
-
def hoardable_version_updates: -> untyped
|
37
|
-
def insert_hoardable_version_on_update: -> untyped
|
38
|
-
def insert_hoardable_version_on_destroy: -> untyped
|
39
|
-
def insert_hoardable_version: (String operation, untyped attrs) -> untyped
|
40
|
-
def find_or_initialize_hoardable_event_uuid: -> untyped
|
41
|
-
def initialize_hoardable_version: (String operation, untyped attrs) -> untyped
|
42
|
-
def initialize_hoardable_data: -> untyped
|
43
|
-
def assign_hoardable_context: (:event_uuid | :meta | :note | :whodunit key) -> nil
|
44
|
-
def delete_hoardable_versions: -> untyped
|
45
|
-
def unset_hoardable_version_and_event_uuid: -> nil
|
41
|
+
def hoardable_source_service: -> Service
|
46
42
|
|
47
43
|
public
|
48
44
|
def version_class: -> untyped
|
49
|
-
|
45
|
+
|
46
|
+
class Service
|
47
|
+
attr_reader source_model: SourceModel
|
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
|
55
|
+
end
|
50
56
|
end
|
51
57
|
|
52
58
|
module VersionModel
|
53
|
-
@hoardable_source_attributes: untyped
|
54
|
-
@hoardable_source_foreign_key: String
|
55
59
|
@hoardable_source_foreign_id: untyped
|
60
|
+
@hoardable_source_foreign_key: String
|
61
|
+
@hoardable_version_service: Service
|
56
62
|
|
57
63
|
def revert!: -> untyped
|
58
64
|
def untrash!: -> untyped
|
59
65
|
def changes: -> untyped
|
60
|
-
|
61
|
-
private
|
62
|
-
def untrashable_hoardable_source_attributes: -> untyped
|
63
|
-
def hoardable_source_attributes: -> untyped
|
64
|
-
def hoardable_source_foreign_key: -> String
|
65
66
|
def hoardable_source_foreign_id: -> untyped
|
66
|
-
def
|
67
|
-
def
|
67
|
+
def hoardable_version_service: -> Service
|
68
|
+
def hoardable_source_foreign_key: -> String
|
69
|
+
|
70
|
+
class Service
|
71
|
+
@hoardable_source_attributes: untyped
|
72
|
+
|
73
|
+
attr_reader version_model: VersionModel
|
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
|
68
82
|
end
|
69
83
|
|
70
84
|
module Model
|
71
85
|
include VersionModel
|
72
86
|
include SourceModel
|
87
|
+
include Associations
|
73
88
|
|
89
|
+
attr_reader _hoardable_config: Hash[untyped, untyped]
|
74
90
|
def hoardable_config: (?nil hash) -> untyped
|
75
91
|
def with_hoardable_config: (untyped hash) -> untyped
|
76
92
|
end
|
77
93
|
|
94
|
+
module Associations
|
95
|
+
def belongs_to_trashable: (untyped name, ?nil scope, **untyped) -> untyped
|
96
|
+
end
|
97
|
+
|
78
98
|
class MigrationGenerator
|
79
99
|
@singularized_table_name: untyped
|
80
100
|
|
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.6.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-28 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
|