hoardable 0.1.1 → 0.1.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +67 -15
- data/lib/generators/hoardable/migration_generator.rb +8 -0
- data/lib/generators/hoardable/templates/migration.rb.erb +3 -3
- data/lib/generators/hoardable/templates/migration_6.rb.erb +3 -3
- data/lib/hoardable/hoardable.rb +1 -1
- data/lib/hoardable/model.rb +3 -3
- data/lib/hoardable/source_model.rb +20 -12
- data/lib/hoardable/version.rb +1 -1
- data/lib/hoardable/version_model.rb +6 -2
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 3c73d162f69d7ff2984571b7e0aefa256357a835c2ecb22d1288a362dce38d73
|
4
|
+
data.tar.gz: 4b250ebb2bf536f94d3cd8e65f0bd884579bd6fc5d8fd89f5c9846554c4d0e7a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 4503897e596e1694a49009aa3e964a86a8e317169c590a1079b075345b9116fb4af50abb68b17a4ea73c7bf1570f56906c11f5e57dbaf84044ab8ac11ca942f2
|
7
|
+
data.tar.gz: a8e2780685b3d0a00757c44ca6a38f58571ef617b1543b7ee8b8f3dcef18c9fed5f7c3e10f7d31677afe0fccafeffca4348d70b5afe3706ead0438488bf57166
|
data/README.md
CHANGED
@@ -1,9 +1,9 @@
|
|
1
|
-
# Hoardable
|
1
|
+
# Hoardable ![gem version](https://img.shields.io/gem/v/hoardable?style=flat-square)
|
2
2
|
|
3
3
|
Hoardable is an ActiveRecord extension for Ruby 2.6+, Rails 6.1+, and PostgreSQL that allows for
|
4
4
|
versioning and soft-deletion of records through the use of _uni-temporal inherited tables_.
|
5
5
|
|
6
|
-
####
|
6
|
+
#### huh?
|
7
7
|
|
8
8
|
[Temporal tables](https://en.wikipedia.org/wiki/Temporal_database) are a database design pattern
|
9
9
|
where each row of a table contains data along with one or more time ranges. In the case of this gem,
|
@@ -48,10 +48,18 @@ end
|
|
48
48
|
Then, run the generator command to create a database migration and migrate it:
|
49
49
|
|
50
50
|
```
|
51
|
-
bin/rails g hoardable:migration
|
51
|
+
bin/rails g hoardable:migration Post
|
52
52
|
bin/rails db:migrate
|
53
53
|
```
|
54
54
|
|
55
|
+
By default, it will try to guess the foreign key type for the `_versions` table based on the primary
|
56
|
+
key of the model specified in the migration generator above. If you want/need to specify this
|
57
|
+
explicitly, you can do so:
|
58
|
+
|
59
|
+
```
|
60
|
+
bin/rails g hoardable:migration Post --foreign-key-type uuid
|
61
|
+
```
|
62
|
+
|
55
63
|
_Note:_ If you are on Rails 6.1, you might want to set `config.active_record.schema_format = :sql`
|
56
64
|
in `application.rb`, so that the enum type is captured in your schema dump. This is not required in
|
57
65
|
Rails 7.
|
@@ -75,10 +83,11 @@ A `Post` now `has_many :versions`. Whenever an update and deletion of a `Post` o
|
|
75
83
|
created (by default):
|
76
84
|
|
77
85
|
```ruby
|
78
|
-
post = Post.create!(
|
86
|
+
post = Post.create!(title: "Title")
|
79
87
|
post.versions.size # => 0
|
80
|
-
post.update!(title: "Title")
|
88
|
+
post.update!(title: "Revised Title")
|
81
89
|
post.versions.size # => 1
|
90
|
+
post.versions.first.title # => "Title"
|
82
91
|
post.destroy!
|
83
92
|
post.trashed? # true
|
84
93
|
post.versions.size # => 2
|
@@ -120,17 +129,12 @@ need to query versions often, you should add appropriate indexes to the `_versio
|
|
120
129
|
|
121
130
|
### Tracking contextual data
|
122
131
|
|
123
|
-
You’ll often want to track contextual data about the creation of a version.
|
124
|
-
|
125
|
-
[changes](https://api.rubyonrails.org/classes/ActiveModel/Dirty.html#method-i-changes) hash and the
|
126
|
-
`operation` that cause the version (`update` or `delete`). It will also tag all versions created in
|
127
|
-
the same database transaction with a shared and unique `event_id`.
|
128
|
-
|
129
|
-
There 3 other optional keys that are provided for tracking contextual information:
|
132
|
+
You’ll often want to track contextual data about the creation of a version. There are 3 optional
|
133
|
+
symbol keys that are provided for tracking contextual information:
|
130
134
|
|
131
|
-
-
|
132
|
-
-
|
133
|
-
-
|
135
|
+
- `:whodunit` - an identifier for who is responsible for creating the version
|
136
|
+
- `:note` - a description regarding the versioning
|
137
|
+
- `:meta` - any other contextual information you’d like to store along with the version
|
134
138
|
|
135
139
|
This information is stored in a `jsonb` column. Each key’s value can be in the format of your
|
136
140
|
choosing.
|
@@ -171,6 +175,17 @@ class ApplicationController < ActionController::Base
|
|
171
175
|
end
|
172
176
|
```
|
173
177
|
|
178
|
+
`hoardable` will also automatically capture the ActiveRecord
|
179
|
+
[changes](https://api.rubyonrails.org/classes/ActiveModel/Dirty.html#method-i-changes) hash, the
|
180
|
+
`operation` that cause the version (`update` or `delete`), and it will also tag all versions created
|
181
|
+
in the same database transaction with a shared and unique `event_uuid`. These are available as:
|
182
|
+
|
183
|
+
```ruby
|
184
|
+
version.changes
|
185
|
+
version.hoardable_operation
|
186
|
+
version.hoardable_event_uuid
|
187
|
+
```
|
188
|
+
|
174
189
|
### Model Callbacks
|
175
190
|
|
176
191
|
Sometimes you might want to do something with a version before or after it gets inserted to the
|
@@ -225,8 +240,45 @@ Hoardable.with(enabled: false) do
|
|
225
240
|
end
|
226
241
|
```
|
227
242
|
|
243
|
+
### Relationships
|
244
|
+
|
245
|
+
As in life, sometimes relationships can be hard. `hoardable` is still working out best practices and
|
246
|
+
features in this area, but here are a couple pointers.
|
247
|
+
|
248
|
+
Sometimes you’ll have a record that belongs to a record that you’ll trash. Now the child record’s
|
249
|
+
foreign key will point to the non-existent trashed version of the parent. If you would like this
|
250
|
+
`belongs_to` relationship to always resolve to the parent as if it was not trashed, you can include
|
251
|
+
the scope on the relationship definition:
|
252
|
+
|
253
|
+
```ruby
|
254
|
+
belongs_to :parent, -> { include_versions }
|
255
|
+
```
|
256
|
+
|
257
|
+
Sometimes you’ll trash something that `has_many :children, dependent: :destroy` and both the parent
|
258
|
+
and child model classes include `Hoardable::Model`. Whenever a hoardable version is created in a
|
259
|
+
database transaction, it will create or re-use a unique event UUID for that transaction and tag all
|
260
|
+
versions created with it. That way, when you `untrash!` a parent object, you can find and `untrash!`
|
261
|
+
the children like so:
|
262
|
+
|
263
|
+
```ruby
|
264
|
+
class Post < ActiveRecord::Base
|
265
|
+
include Hoardable::Model
|
266
|
+
has_many :comments, dependent: :destroy # `Comment` also includes `Hoardable::Model`
|
267
|
+
|
268
|
+
after_untrashed do
|
269
|
+
Comment
|
270
|
+
.version_class
|
271
|
+
.trashed
|
272
|
+
.with_hoardable_event_uuid(hoardable_event_uuid)
|
273
|
+
.find_each(&:untrash!)
|
274
|
+
end
|
275
|
+
end
|
276
|
+
```
|
277
|
+
|
228
278
|
## Contributing
|
229
279
|
|
280
|
+
This gem is currently considered alpha and very open to feedback.
|
281
|
+
|
230
282
|
Bug reports and pull requests are welcome on GitHub at https://github.com/waymondo/hoardable.
|
231
283
|
|
232
284
|
## License
|
@@ -8,12 +8,20 @@ module Hoardable
|
|
8
8
|
class MigrationGenerator < ActiveRecord::Generators::Base
|
9
9
|
source_root File.expand_path('templates', __dir__)
|
10
10
|
include Rails::Generators::Migration
|
11
|
+
class_option :foreign_key_type, type: :string
|
11
12
|
|
12
13
|
def create_versions_table
|
13
14
|
migration_template migration_template_name, "db/migrate/create_#{singularized_table_name}_versions.rb"
|
14
15
|
end
|
15
16
|
|
16
17
|
no_tasks do
|
18
|
+
def foreign_key_type
|
19
|
+
options[:foreign_key_type] ||
|
20
|
+
class_name.singularize.constantize.columns.find { |col| col.name == 'id' }.sql_type
|
21
|
+
rescue StandardError
|
22
|
+
'bigint'
|
23
|
+
end
|
24
|
+
|
17
25
|
def migration_template_name
|
18
26
|
if Gem::Version.new(ActiveRecord::Migration.current_version.to_s) < Gem::Version.new('7')
|
19
27
|
'migration_6.rb.erb'
|
@@ -6,14 +6,14 @@ class Create<%= class_name.singularize %>Versions < ActiveRecord::Migration[<%=
|
|
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
|
9
|
-
t.
|
10
|
-
t.
|
9
|
+
t.uuid :_event_uuid, null: false, index: true
|
10
|
+
t.enum :_operation, enum_type: 'hoardable_operation', null: false, index: true
|
11
|
+
t.<%= foreign_key_type %> :<%= singularized_table_name %>_id, null: false, index: true
|
11
12
|
end
|
12
13
|
add_index(
|
13
14
|
:<%= singularized_table_name %>_versions,
|
14
15
|
%i[_during <%= singularized_table_name %>_id],
|
15
16
|
name: 'idx_<%= singularized_table_name %>_versions_temporally'
|
16
17
|
)
|
17
|
-
add_index :<%= singularized_table_name %>_versions, :_operation
|
18
18
|
end
|
19
19
|
end
|
@@ -21,14 +21,14 @@ class Create<%= class_name.singularize %>Versions < ActiveRecord::Migration[<%=
|
|
21
21
|
create_table :<%= singularized_table_name %>_versions, id: false, options: 'INHERITS (<%= table_name %>)' do |t|
|
22
22
|
t.jsonb :_data
|
23
23
|
t.tsrange :_during, null: false
|
24
|
-
t.
|
25
|
-
t.
|
24
|
+
t.uuid :_event_uuid, null: false, index: true
|
25
|
+
t.column :_operation, :hoardable_operation, null: false, index: true
|
26
|
+
t.<%= foreign_key_type %> :<%= singularized_table_name %>_id, null: false, index: true
|
26
27
|
end
|
27
28
|
add_index(
|
28
29
|
:<%= singularized_table_name %>_versions,
|
29
30
|
%i[_during <%= singularized_table_name %>_id],
|
30
31
|
name: 'idx_<%= singularized_table_name %>_versions_temporally'
|
31
32
|
)
|
32
|
-
add_index :<%= singularized_table_name %>_versions, :_operation
|
33
33
|
end
|
34
34
|
end
|
data/lib/hoardable/hoardable.rb
CHANGED
@@ -2,7 +2,7 @@
|
|
2
2
|
|
3
3
|
# An ActiveRecord extension for keeping versions of records in temporal inherited tables
|
4
4
|
module Hoardable
|
5
|
-
DATA_KEYS = %i[meta whodunit note
|
5
|
+
DATA_KEYS = %i[meta whodunit note event_uuid].freeze
|
6
6
|
CONFIG_KEYS = %i[enabled save_trash].freeze
|
7
7
|
|
8
8
|
VERSION_CLASS_SUFFIX = 'Version'
|
data/lib/hoardable/model.rb
CHANGED
@@ -15,9 +15,9 @@ module Hoardable
|
|
15
15
|
next unless self == trace.self
|
16
16
|
|
17
17
|
version_class_name = "#{name}#{VERSION_CLASS_SUFFIX}"
|
18
|
-
|
19
|
-
|
20
|
-
|
18
|
+
unless Object.const_defined?(version_class_name)
|
19
|
+
Object.const_set(version_class_name, Class.new(self) { include VersionModel })
|
20
|
+
end
|
21
21
|
|
22
22
|
include SourceModel
|
23
23
|
|
@@ -17,10 +17,12 @@ module Hoardable
|
|
17
17
|
around_update :insert_hoardable_version_on_update, if: :hoardable_callbacks_enabled
|
18
18
|
around_destroy :insert_hoardable_version_on_destroy, if: [:hoardable_callbacks_enabled, SAVE_TRASH_ENABLED]
|
19
19
|
before_destroy :delete_hoardable_versions, if: :hoardable_callbacks_enabled, unless: SAVE_TRASH_ENABLED
|
20
|
-
after_commit :
|
20
|
+
after_commit :unset_hoardable_version_and_event_uuid
|
21
21
|
|
22
22
|
attr_reader :hoardable_version
|
23
23
|
|
24
|
+
delegate :hoardable_event_uuid, :hoardable_operation, to: :hoardable_version, allow_nil: true
|
25
|
+
|
24
26
|
has_many(
|
25
27
|
:versions, -> { order(:_during) },
|
26
28
|
dependent: nil,
|
@@ -34,9 +36,17 @@ module Hoardable
|
|
34
36
|
end
|
35
37
|
|
36
38
|
def at(datetime)
|
39
|
+
raise(Error, 'Future state cannot be known') if datetime.future?
|
40
|
+
|
37
41
|
versions.find_by(DURING_QUERY, datetime) || self
|
38
42
|
end
|
39
43
|
|
44
|
+
def revert_to!(datetime)
|
45
|
+
return unless (version = at(datetime))
|
46
|
+
|
47
|
+
version.is_a?(self.class.version_class) ? version.revert! : self
|
48
|
+
end
|
49
|
+
|
40
50
|
private
|
41
51
|
|
42
52
|
def hoardable_callbacks_enabled
|
@@ -52,18 +62,15 @@ module Hoardable
|
|
52
62
|
end
|
53
63
|
|
54
64
|
def insert_hoardable_version(operation, attrs)
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
yield
|
60
|
-
hoardable_version.save(validate: false, touch: false)
|
61
|
-
end
|
65
|
+
@hoardable_version = initialize_hoardable_version(operation, attrs)
|
66
|
+
run_callbacks(:versioned) do
|
67
|
+
yield
|
68
|
+
hoardable_version.save(validate: false, touch: false)
|
62
69
|
end
|
63
70
|
end
|
64
71
|
|
65
|
-
def
|
66
|
-
Thread.current[:
|
72
|
+
def find_or_initialize_hoardable_event_uuid
|
73
|
+
Thread.current[:hoardable_event_uuid] ||= ActiveRecord::Base.connection.query('SELECT gen_random_uuid();')[0][0]
|
67
74
|
end
|
68
75
|
|
69
76
|
def initialize_hoardable_version(operation, attrs)
|
@@ -71,6 +78,7 @@ module Hoardable
|
|
71
78
|
attrs.merge(
|
72
79
|
changes.transform_values { |h| h[0] },
|
73
80
|
{
|
81
|
+
_event_uuid: find_or_initialize_hoardable_event_uuid,
|
74
82
|
_operation: operation,
|
75
83
|
_data: initialize_hoardable_data.merge(changes: changes)
|
76
84
|
}
|
@@ -94,11 +102,11 @@ module Hoardable
|
|
94
102
|
versions.delete_all(:delete_all)
|
95
103
|
end
|
96
104
|
|
97
|
-
def
|
105
|
+
def unset_hoardable_version_and_event_uuid
|
98
106
|
@hoardable_version = nil
|
99
107
|
return if ActiveRecord::Base.connection.transaction_open?
|
100
108
|
|
101
|
-
Thread.current[:
|
109
|
+
Thread.current[:hoardable_event_uuid] = nil
|
102
110
|
end
|
103
111
|
end
|
104
112
|
end
|
data/lib/hoardable/version.rb
CHANGED
@@ -13,6 +13,9 @@ module Hoardable
|
|
13
13
|
self.table_name = "#{table_name.singularize}#{Hoardable::VERSION_TABLE_SUFFIX}"
|
14
14
|
|
15
15
|
alias_method :readonly?, :persisted?
|
16
|
+
alias_attribute :hoardable_operation, :_operation
|
17
|
+
alias_attribute :hoardable_event_uuid, :_event_uuid
|
18
|
+
alias_attribute :hoardable_during, :_during
|
16
19
|
|
17
20
|
before_create :assign_temporal_tsrange
|
18
21
|
|
@@ -22,10 +25,11 @@ module Hoardable
|
|
22
25
|
.where(_operation: 'delete')
|
23
26
|
}
|
24
27
|
scope :at, ->(datetime) { where(DURING_QUERY, datetime) }
|
28
|
+
scope :with_hoardable_event_uuid, ->(event_uuid) { where(_event_uuid: event_uuid) }
|
25
29
|
end
|
26
30
|
|
27
31
|
def revert!
|
28
|
-
raise(Error, 'Version is trashed, cannot revert') unless
|
32
|
+
raise(Error, 'Version is trashed, cannot revert') unless hoardable_operation == 'update'
|
29
33
|
|
30
34
|
transaction do
|
31
35
|
hoardable_source.tap do |reverted|
|
@@ -37,7 +41,7 @@ module Hoardable
|
|
37
41
|
end
|
38
42
|
|
39
43
|
def untrash!
|
40
|
-
raise(Error, 'Version is not trashed, cannot untrash') unless
|
44
|
+
raise(Error, 'Version is not trashed, cannot untrash') unless hoardable_operation == 'delete'
|
41
45
|
|
42
46
|
transaction do
|
43
47
|
superscope = self.class.superclass.unscoped
|
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.1.
|
4
|
+
version: 0.1.2
|
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-08-01 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|