hoardable 0.1.0 → 0.1.3
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Gemfile +3 -2
- data/README.md +130 -46
- data/lib/generators/hoardable/migration_generator.rb +19 -2
- data/lib/generators/hoardable/templates/migration.rb.erb +9 -2
- data/lib/generators/hoardable/templates/migration_6.rb.erb +34 -0
- data/lib/hoardable/error.rb +6 -0
- data/lib/hoardable/hoardable.rb +21 -3
- data/lib/hoardable/model.rb +12 -60
- data/lib/hoardable/source_model.rb +133 -0
- data/lib/hoardable/tableoid.rb +47 -0
- data/lib/hoardable/version.rb +5 -0
- data/lib/hoardable/version_model.rb +75 -14
- data/lib/hoardable.rb +4 -0
- metadata +17 -12
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 96952d8928266fc5ae03199a91e85428565ff11385a74851fe61fd3a8eafc881
|
4
|
+
data.tar.gz: 1b9117cbf4ea08325d29212c16580e368a857b12f8c2d20bc346d9e8f5268d59
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e533edf9412339fcc90691fa5d10395265914f71f7f7493c9afc226902cf831db87f69661d565630602e2db815549e4209550420abc44f6a7d179ccb23ba7509
|
7
|
+
data.tar.gz: a8c690b0a3399f3853ed6c65ca7d514af9c82d10569483d7d4bed86c6e7a63d1881ecac61e13d6646facdce3370614dc6c86f88599bc6eb955feba38589c398c
|
data/Gemfile
CHANGED
@@ -2,11 +2,12 @@
|
|
2
2
|
|
3
3
|
source 'https://rubygems.org'
|
4
4
|
|
5
|
-
gemspec
|
6
|
-
|
7
5
|
gem 'debug', '~> 1.6'
|
8
6
|
gem 'minitest', '~> 5.0'
|
9
7
|
gem 'rake', '~> 13.0'
|
10
8
|
gem 'rubocop', '~> 1.21'
|
11
9
|
gem 'rubocop-minitest', '~> 0.20'
|
12
10
|
gem 'rubocop-rake', '~> 0.6'
|
11
|
+
gem 'yard', '~> 0.9'
|
12
|
+
|
13
|
+
gemspec
|
data/README.md
CHANGED
@@ -1,23 +1,27 @@
|
|
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
|
-
versioning and soft-deletion of records through the use of
|
4
|
+
versioning and soft-deletion of records through the use of _uni-temporal inherited tables_.
|
5
5
|
|
6
|
-
|
6
|
+
[👉 Documentation](https://www.rubydoc.info/gems/hoardable)
|
7
|
+
|
8
|
+
### huh?
|
7
9
|
|
8
10
|
[Temporal tables](https://en.wikipedia.org/wiki/Temporal_database) are a database design pattern
|
9
|
-
where each row contains data
|
10
|
-
|
11
|
+
where each row of a table contains data along with one or more time ranges. In the case of this gem,
|
12
|
+
each database row has a time range that represents the row’s valid time range - hence
|
11
13
|
"uni-temporal".
|
12
14
|
|
13
15
|
[Table inheritance](https://www.postgresql.org/docs/14/ddl-inherit.html) is a feature of PostgreSQL
|
14
|
-
that allows a table to inherit all columns of
|
15
|
-
in sync with
|
16
|
-
|
16
|
+
that allows a table to inherit all columns of a parent table. The descendant table’s schema will
|
17
|
+
stay in sync with its parent. If a new column is added to or removed from the parent, the schema
|
18
|
+
change is reflected on its descendants.
|
17
19
|
|
18
|
-
With these
|
19
|
-
|
20
|
-
other versioning
|
20
|
+
With these concepts combined, `hoardable` offers a simple and effective model versioning system for
|
21
|
+
Rails. Versions of records are stored in separate, inherited tables along with their valid time
|
22
|
+
ranges and contextual data. Compared to other Rails-oriented versioning systems, this gem strives to
|
23
|
+
be more explicit and obvious on the lower RDBS level while still familiar and convenient within Ruby
|
24
|
+
on Rails.
|
21
25
|
|
22
26
|
## Installation
|
23
27
|
|
@@ -31,68 +35,88 @@ And then execute `bundle install`.
|
|
31
35
|
|
32
36
|
### Model Installation
|
33
37
|
|
34
|
-
|
38
|
+
You must include `Hoardable::Model` into an ActiveRecord model that you would like to hoard versions
|
39
|
+
of:
|
35
40
|
|
36
41
|
```ruby
|
37
42
|
class Post < ActiveRecord::Base
|
38
43
|
include Hoardable::Model
|
39
44
|
belongs_to :user
|
45
|
+
has_many :comments, dependent: :destroy
|
46
|
+
...
|
40
47
|
end
|
41
48
|
```
|
42
49
|
|
43
50
|
Then, run the generator command to create a database migration and migrate it:
|
44
51
|
|
45
52
|
```
|
46
|
-
bin/rails g hoardable:migration
|
53
|
+
bin/rails g hoardable:migration Post
|
47
54
|
bin/rails db:migrate
|
48
55
|
```
|
49
56
|
|
57
|
+
By default, it will try to guess the foreign key type for the `_versions` table based on the primary
|
58
|
+
key of the model specified in the migration generator above. If you want/need to specify this
|
59
|
+
explicitly, you can do so:
|
60
|
+
|
61
|
+
```
|
62
|
+
bin/rails g hoardable:migration Post --foreign-key-type uuid
|
63
|
+
```
|
64
|
+
|
65
|
+
_Note:_ If you are on Rails 6.1, you might want to set `config.active_record.schema_format = :sql`
|
66
|
+
in `application.rb`, so that the enum type is captured in your schema dump. This is not required in
|
67
|
+
Rails 7.
|
68
|
+
|
50
69
|
## Usage
|
51
70
|
|
52
|
-
###
|
71
|
+
### Overview
|
53
72
|
|
54
73
|
Once you include `Hoardable::Model` into a model, it will dynamically generate a "Version" subclass
|
55
|
-
of that model.
|
74
|
+
of that model. As we continue our example above, :
|
56
75
|
|
57
76
|
```
|
77
|
+
$ irb
|
58
78
|
>> Post
|
59
79
|
=> Post(id: integer, body: text, user_id: integer, created_at: datetime)
|
60
80
|
>> PostVersion
|
61
81
|
=> PostVersion(id: integer, body: text, user_id: integer, created_at: datetime, _data: jsonb, _during: tsrange, post_id: integer)
|
62
82
|
```
|
63
83
|
|
64
|
-
A `Post` now `has_many :versions
|
65
|
-
default):
|
84
|
+
A `Post` now `has_many :versions`. Whenever an update and deletion of a `Post` occurs, a version is
|
85
|
+
created (by default):
|
66
86
|
|
67
87
|
```ruby
|
68
|
-
|
88
|
+
post = Post.create!(title: "Title")
|
69
89
|
post.versions.size # => 0
|
70
|
-
post.update!(title: "Title")
|
90
|
+
post.update!(title: "Revised Title")
|
71
91
|
post.versions.size # => 1
|
92
|
+
post.versions.first.title # => "Title"
|
72
93
|
post.destroy!
|
73
|
-
post.
|
74
|
-
|
94
|
+
post.trashed? # true
|
95
|
+
post.versions.size # => 2
|
96
|
+
Post.find(post.id) # raises ActiveRecord::RecordNotFound
|
75
97
|
```
|
76
98
|
|
77
99
|
Each `PostVersion` has access to the same attributes, relationships, and other model behavior that
|
78
|
-
`Post` has, but
|
100
|
+
`Post` has, but as a read-only record.
|
79
101
|
|
80
|
-
If you ever need to revert to a specific version, you can call `version.revert!` on it. If
|
81
|
-
|
102
|
+
If you ever need to revert to a specific version, you can call `version.revert!` on it. If you would
|
103
|
+
like to untrash a specific version, you can call `version.untrash!` on it. This will re-insert the
|
104
|
+
model in the parent class’ table with it’s original primary key.
|
82
105
|
|
83
106
|
### Querying and Temporal Lookup
|
84
107
|
|
85
|
-
Since a `PostVersion` is
|
86
|
-
resource, i.e:
|
108
|
+
Since a `PostVersion` is an `ActiveRecord` class, you can query them like another model resource:
|
87
109
|
|
88
110
|
```ruby
|
89
|
-
post.versions.where(user_id: Current.user.id, body:
|
111
|
+
post.versions.where(user_id: Current.user.id, body: "Cool!")
|
90
112
|
```
|
91
113
|
|
92
|
-
If you want to look-up the version of a
|
114
|
+
If you want to look-up the version of a record at a specific time, you can use the `.at` method:
|
93
115
|
|
94
116
|
```ruby
|
95
117
|
post.at(1.day.ago) # => #<PostVersion:0x000000010d44fa30>
|
118
|
+
# or
|
119
|
+
PostVersion.at(1.day.ago).find_by(post_id: post.id) # => #<PostVersion:0x000000010d44fa30>
|
96
120
|
```
|
97
121
|
|
98
122
|
By default, `hoardable` will keep copies of records you have destroyed. You can query for them as
|
@@ -103,26 +127,27 @@ PostVersion.trashed
|
|
103
127
|
```
|
104
128
|
|
105
129
|
_Note:_ Creating an inherited table does not copy over the indexes from the parent table. If you
|
106
|
-
need to query versions often, you
|
130
|
+
need to query versions often, you should add appropriate indexes to the `_versions` tables.
|
107
131
|
|
108
132
|
### Tracking contextual data
|
109
133
|
|
110
|
-
You’ll often want to track contextual data about a version.
|
111
|
-
|
112
|
-
|
113
|
-
There are also 3 other optional keys that are provided for tracking contextual information:
|
134
|
+
You’ll often want to track contextual data about the creation of a version. There are 3 optional
|
135
|
+
symbol keys that are provided for tracking contextual information:
|
114
136
|
|
115
|
-
-
|
116
|
-
-
|
117
|
-
-
|
137
|
+
- `:whodunit` - an identifier for who is responsible for creating the version
|
138
|
+
- `:note` - a description regarding the versioning
|
139
|
+
- `:meta` - any other contextual information you’d like to store along with the version
|
118
140
|
|
119
141
|
This information is stored in a `jsonb` column. Each key’s value can be in the format of your
|
120
142
|
choosing.
|
121
143
|
|
122
|
-
One convenient way to assign
|
144
|
+
One convenient way to assign contextual data to these is by defining a proc in an initializer, i.e.:
|
123
145
|
|
124
146
|
```ruby
|
125
147
|
Hoardable.whodunit = -> { Current.user&.id }
|
148
|
+
Current.user = User.find(123)
|
149
|
+
post.update!(status: 'live')
|
150
|
+
post.versions.last.hoardable_whodunit # => 123
|
126
151
|
```
|
127
152
|
|
128
153
|
You can also set this context manually as well, just remember to clear them afterwards.
|
@@ -134,7 +159,7 @@ Hoardable.note = nil
|
|
134
159
|
post.versions.last.hoardable_note # => "reverting due to accidental deletion"
|
135
160
|
```
|
136
161
|
|
137
|
-
|
162
|
+
A more useful pattern is to use `Hoardable.with` to set the context around a block. A good example
|
138
163
|
of this would be in `ApplicationController`:
|
139
164
|
|
140
165
|
```ruby
|
@@ -147,38 +172,59 @@ class ApplicationController < ActionController::Base
|
|
147
172
|
Hoardable.with(whodunit: current_user.id, meta: { request_uuid: request.uuid }) do
|
148
173
|
yield
|
149
174
|
end
|
175
|
+
# `Hoardable.whodunit` is back to nil or the previously set value
|
150
176
|
end
|
151
177
|
end
|
152
178
|
```
|
153
179
|
|
180
|
+
`hoardable` will also automatically capture the ActiveRecord
|
181
|
+
[changes](https://api.rubyonrails.org/classes/ActiveModel/Dirty.html#method-i-changes) hash, the
|
182
|
+
`operation` that cause the version (`update` or `delete`), and it will also tag all versions created
|
183
|
+
in the same database transaction with a shared and unique `event_uuid`. These are available as:
|
184
|
+
|
185
|
+
```ruby
|
186
|
+
version.changes
|
187
|
+
version.hoardable_operation
|
188
|
+
version.hoardable_event_uuid
|
189
|
+
```
|
190
|
+
|
154
191
|
### Model Callbacks
|
155
192
|
|
156
|
-
Sometimes you might want to do something with a version before it gets
|
157
|
-
|
158
|
-
|
193
|
+
Sometimes you might want to do something with a version before or after it gets inserted to the
|
194
|
+
database. You can access it in `before/after/around_versioned` callbacks on the source record as
|
195
|
+
`hoardable_version`. These happen around `.save`, which is enclosed in an ActiveRecord transaction.
|
159
196
|
|
160
|
-
|
197
|
+
There are also `after_reverted` and `after_untrashed` callbacks available as well, which are called
|
198
|
+
on the source record after a version is reverted or untrashed.
|
199
|
+
|
200
|
+
```ruby
|
161
201
|
class User
|
162
|
-
|
202
|
+
include Hoardable::Model
|
203
|
+
before_versioned :sanitize_version
|
163
204
|
after_reverted :track_reverted_event
|
205
|
+
after_untrashed :track_untrashed_event
|
164
206
|
|
165
207
|
private
|
166
208
|
|
167
209
|
def sanitize_version
|
168
210
|
hoardable_version.sanitize_password
|
169
|
-
end
|
211
|
+
end
|
170
212
|
|
171
213
|
def track_reverted_event
|
172
214
|
track_event(:user_reverted, self)
|
173
215
|
end
|
216
|
+
|
217
|
+
def track_untrashed_event
|
218
|
+
track_event(:user_untrashed, self)
|
219
|
+
end
|
174
220
|
end
|
175
221
|
```
|
176
222
|
|
177
223
|
### Configuration
|
178
224
|
|
179
|
-
There are two
|
225
|
+
There are two configurable options currently:
|
180
226
|
|
181
|
-
```
|
227
|
+
```ruby
|
182
228
|
Hoardable.enabled # => default true
|
183
229
|
Hoardable.save_trash # => default true
|
184
230
|
```
|
@@ -196,8 +242,46 @@ Hoardable.with(enabled: false) do
|
|
196
242
|
end
|
197
243
|
```
|
198
244
|
|
245
|
+
### Relationships
|
246
|
+
|
247
|
+
As in life, sometimes relationships can be hard. `hoardable` is still working out best practices and
|
248
|
+
features in this area, but here are a couple pointers.
|
249
|
+
|
250
|
+
Sometimes you’ll have a record that belongs to a record that you’ll trash. Now the child record’s
|
251
|
+
foreign key will point to the non-existent trashed version of the parent. If you would like this
|
252
|
+
`belongs_to` relationship to always resolve to the parent as if it was not trashed, you can include
|
253
|
+
the scope on the relationship definition:
|
254
|
+
|
255
|
+
```ruby
|
256
|
+
belongs_to :parent, -> { include_versions }
|
257
|
+
```
|
258
|
+
|
259
|
+
Sometimes you’ll trash something that `has_many :children, dependent: :destroy` and both the parent
|
260
|
+
and child model classes include `Hoardable::Model`. Whenever a hoardable version is created in a
|
261
|
+
database transaction, it will create or re-use a unique event UUID for that transaction and tag all
|
262
|
+
versions created with it. That way, when you `untrash!` a parent object, you can find and `untrash!`
|
263
|
+
the children like so:
|
264
|
+
|
265
|
+
```ruby
|
266
|
+
class Post < ActiveRecord::Base
|
267
|
+
include Hoardable::Model
|
268
|
+
has_many :comments, dependent: :destroy # `Comment` also includes `Hoardable::Model`
|
269
|
+
|
270
|
+
after_untrashed do
|
271
|
+
Comment
|
272
|
+
.version_class
|
273
|
+
.trashed
|
274
|
+
.where(post_id: id)
|
275
|
+
.with_hoardable_event_uuid(hoardable_event_uuid)
|
276
|
+
.find_each(&:untrash!)
|
277
|
+
end
|
278
|
+
end
|
279
|
+
```
|
280
|
+
|
199
281
|
## Contributing
|
200
282
|
|
283
|
+
This gem is currently considered alpha and very open to feedback.
|
284
|
+
|
201
285
|
Bug reports and pull requests are welcome on GitHub at https://github.com/waymondo/hoardable.
|
202
286
|
|
203
287
|
## License
|
@@ -4,16 +4,33 @@ require 'rails/generators'
|
|
4
4
|
require 'rails/generators/active_record/migration/migration_generator'
|
5
5
|
|
6
6
|
module Hoardable
|
7
|
-
# Generates a migration
|
7
|
+
# Generates a migration to create an inherited uni-temporal table of a model including
|
8
|
+
# {Hoardable::Model}, for the storage of +versions+.
|
8
9
|
class MigrationGenerator < ActiveRecord::Generators::Base
|
9
10
|
source_root File.expand_path('templates', __dir__)
|
10
11
|
include Rails::Generators::Migration
|
12
|
+
class_option :foreign_key_type, type: :string
|
11
13
|
|
12
14
|
def create_versions_table
|
13
|
-
migration_template
|
15
|
+
migration_template migration_template_name, "db/migrate/create_#{singularized_table_name}_versions.rb"
|
14
16
|
end
|
15
17
|
|
16
18
|
no_tasks do
|
19
|
+
def foreign_key_type
|
20
|
+
options[:foreign_key_type] ||
|
21
|
+
class_name.singularize.constantize.columns.find { |col| col.name == 'id' }.sql_type
|
22
|
+
rescue StandardError
|
23
|
+
'bigint'
|
24
|
+
end
|
25
|
+
|
26
|
+
def migration_template_name
|
27
|
+
if Gem::Version.new(ActiveRecord::Migration.current_version.to_s) < Gem::Version.new('7')
|
28
|
+
'migration_6.rb.erb'
|
29
|
+
else
|
30
|
+
'migration.rb.erb'
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
17
34
|
def singularized_table_name
|
18
35
|
@singularized_table_name ||= table_name.singularize
|
19
36
|
end
|
@@ -2,11 +2,18 @@
|
|
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
6
|
create_table :<%= singularized_table_name %>_versions, id: false, options: 'INHERITS (<%= table_name %>)' do |t|
|
6
7
|
t.jsonb :_data
|
7
8
|
t.tsrange :_during, null: false
|
8
|
-
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
|
9
12
|
end
|
10
|
-
add_index
|
13
|
+
add_index(
|
14
|
+
:<%= singularized_table_name %>_versions,
|
15
|
+
%i[_during <%= singularized_table_name %>_id],
|
16
|
+
name: 'idx_<%= singularized_table_name %>_versions_temporally'
|
17
|
+
)
|
11
18
|
end
|
12
19
|
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Create<%= class_name.singularize %>Versions < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
4
|
+
def change
|
5
|
+
reversible do |dir|
|
6
|
+
dir.up do
|
7
|
+
execute <<~SQL
|
8
|
+
DO $$
|
9
|
+
BEGIN
|
10
|
+
IF NOT EXISTS (
|
11
|
+
SELECT 1 FROM pg_type t
|
12
|
+
WHERE t.typname = 'hoardable_operation'
|
13
|
+
) THEN
|
14
|
+
CREATE TYPE hoardable_operation AS ENUM ('update', 'delete');
|
15
|
+
END IF;
|
16
|
+
END
|
17
|
+
$$;
|
18
|
+
SQL
|
19
|
+
end
|
20
|
+
end
|
21
|
+
create_table :<%= singularized_table_name %>_versions, id: false, options: 'INHERITS (<%= table_name %>)' do |t|
|
22
|
+
t.jsonb :_data
|
23
|
+
t.tsrange :_during, null: false
|
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
|
27
|
+
end
|
28
|
+
add_index(
|
29
|
+
:<%= singularized_table_name %>_versions,
|
30
|
+
%i[_during <%= singularized_table_name %>_id],
|
31
|
+
name: 'idx_<%= singularized_table_name %>_versions_temporally'
|
32
|
+
)
|
33
|
+
end
|
34
|
+
end
|
data/lib/hoardable/hoardable.rb
CHANGED
@@ -1,11 +1,26 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
# An ActiveRecord extension for keeping versions of records in temporal inherited tables
|
3
|
+
# An +ActiveRecord+ extension for keeping versions of records in uni-temporal inherited tables.
|
4
4
|
module Hoardable
|
5
|
-
|
6
|
-
|
5
|
+
# Symbols for use with setting contextual data, when creating versions. See
|
6
|
+
# {file:README.md#tracking-contextual-data README} for more.
|
7
|
+
DATA_KEYS = %i[meta whodunit note event_uuid].freeze
|
8
|
+
# Symbols for use with setting {Hoardable} configuration. See {file:README.md#configuration
|
9
|
+
# README} for more.
|
7
10
|
CONFIG_KEYS = %i[enabled save_trash].freeze
|
8
11
|
|
12
|
+
# @!visibility private
|
13
|
+
VERSION_CLASS_SUFFIX = 'Version'
|
14
|
+
|
15
|
+
# @!visibility private
|
16
|
+
VERSION_TABLE_SUFFIX = "_#{VERSION_CLASS_SUFFIX.tableize}"
|
17
|
+
|
18
|
+
# @!visibility private
|
19
|
+
SAVE_TRASH_ENABLED = -> { Hoardable.save_trash }.freeze
|
20
|
+
|
21
|
+
# @!visibility private
|
22
|
+
DURING_QUERY = '_during @> ?::timestamp'
|
23
|
+
|
9
24
|
@context = {}
|
10
25
|
@config = CONFIG_KEYS.to_h do |key|
|
11
26
|
[key, true]
|
@@ -32,6 +47,9 @@ module Hoardable
|
|
32
47
|
end
|
33
48
|
end
|
34
49
|
|
50
|
+
# This is a general use method for setting {DATA_KEYS} or {CONFIG_KEYS} around a scoped block.
|
51
|
+
#
|
52
|
+
# @param hash [Hash] Options and contextual data to set within a block
|
35
53
|
def with(hash)
|
36
54
|
current_config = @config
|
37
55
|
current_context = @context
|
data/lib/hoardable/model.rb
CHANGED
@@ -1,78 +1,30 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Hoardable
|
4
|
-
# This concern
|
5
|
-
# generates the Version variant of
|
4
|
+
# This concern is the main entrypoint for using {Hoardable}. When included into an +ActiveRecord+
|
5
|
+
# class, it dynamically generates the +Version+ variant of that class (with {VersionModel}) and
|
6
|
+
# includes the {Hoardable} API methods and relationships on the source model class (through
|
7
|
+
# {SourceModel}).
|
6
8
|
module Model
|
7
9
|
extend ActiveSupport::Concern
|
8
10
|
|
9
11
|
included do
|
10
|
-
|
11
|
-
|
12
|
-
before_update :initialize_hoardable_version, if: -> { Hoardable.enabled }
|
13
|
-
before_destroy :initialize_hoardable_version, if: -> { Hoardable.enabled && Hoardable.save_trash }
|
14
|
-
after_update :save_hoardable_version, if: -> { Hoardable.enabled }
|
15
|
-
before_destroy :delete_hoardable_versions, if: -> { Hoardable.enabled && !Hoardable.save_trash }
|
16
|
-
after_destroy :save_hoardable_version, if: -> { Hoardable.enabled && Hoardable.save_trash }
|
17
|
-
|
18
|
-
attr_reader :hoardable_version
|
19
|
-
|
12
|
+
define_model_callbacks :versioned
|
20
13
|
define_model_callbacks :reverted, only: :after
|
14
|
+
define_model_callbacks :untrashed, only: :after
|
21
15
|
|
22
16
|
TracePoint.new(:end) do |trace|
|
23
17
|
next unless self == trace.self
|
24
18
|
|
25
|
-
version_class_name = "#{name}
|
26
|
-
|
19
|
+
version_class_name = "#{name}#{VERSION_CLASS_SUFFIX}"
|
20
|
+
unless Object.const_defined?(version_class_name)
|
21
|
+
Object.const_set(version_class_name, Class.new(self) { include VersionModel })
|
22
|
+
end
|
23
|
+
|
24
|
+
include SourceModel
|
27
25
|
|
28
|
-
Object.const_set(version_class_name, Class.new(self) { include VersionModel })
|
29
|
-
has_many(
|
30
|
-
:versions, -> { order(:_during) },
|
31
|
-
dependent: nil,
|
32
|
-
class_name: version_class_name,
|
33
|
-
inverse_of: model_name.i18n_key
|
34
|
-
)
|
35
26
|
trace.disable
|
36
27
|
end.enable
|
37
28
|
end
|
38
|
-
|
39
|
-
def at(datetime)
|
40
|
-
versions.find_by('_during @> ?::timestamp', datetime) || self
|
41
|
-
end
|
42
|
-
|
43
|
-
private
|
44
|
-
|
45
|
-
def initialize_hoardable_version
|
46
|
-
Hoardable.with(changes: changes) do
|
47
|
-
@hoardable_version = versions.new(
|
48
|
-
attributes_before_type_cast
|
49
|
-
.without('id')
|
50
|
-
.merge(changes.transform_values { |h| h[0] })
|
51
|
-
.merge(_data: initialize_hoardable_data)
|
52
|
-
)
|
53
|
-
end
|
54
|
-
end
|
55
|
-
|
56
|
-
def initialize_hoardable_data
|
57
|
-
DATA_KEYS.to_h do |key|
|
58
|
-
[key, assign_hoardable_context(key)]
|
59
|
-
end
|
60
|
-
end
|
61
|
-
|
62
|
-
def assign_hoardable_context(key)
|
63
|
-
return nil if (value = Hoardable.public_send(key)).nil?
|
64
|
-
|
65
|
-
value.is_a?(Proc) ? value.call : value
|
66
|
-
end
|
67
|
-
|
68
|
-
def save_hoardable_version
|
69
|
-
hoardable_version._data['operation'] = persisted? ? 'update' : 'delete'
|
70
|
-
hoardable_version.save!(validate: false, touch: false)
|
71
|
-
@hoardable_version = nil
|
72
|
-
end
|
73
|
-
|
74
|
-
def delete_hoardable_versions
|
75
|
-
versions.delete_all(:delete_all)
|
76
|
-
end
|
77
29
|
end
|
78
30
|
end
|
@@ -0,0 +1,133 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Hoardable
|
4
|
+
# This concern contains the {Hoardable} relationships, callbacks, and API methods for an
|
5
|
+
# +ActiveRecord+. It is included by {Hoardable::Model} after the dynamic generation of the
|
6
|
+
# +Version+ class variant.
|
7
|
+
module SourceModel
|
8
|
+
extend ActiveSupport::Concern
|
9
|
+
|
10
|
+
class_methods do
|
11
|
+
# The dynamically generated +Version+ class for this model.
|
12
|
+
def version_class
|
13
|
+
"#{name}#{VERSION_CLASS_SUFFIX}".constantize
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
included do
|
18
|
+
include Tableoid
|
19
|
+
|
20
|
+
around_update :insert_hoardable_version_on_update, if: :hoardable_callbacks_enabled
|
21
|
+
around_destroy :insert_hoardable_version_on_destroy, if: [:hoardable_callbacks_enabled, SAVE_TRASH_ENABLED]
|
22
|
+
before_destroy :delete_hoardable_versions, if: :hoardable_callbacks_enabled, unless: SAVE_TRASH_ENABLED
|
23
|
+
after_commit :unset_hoardable_version_and_event_uuid
|
24
|
+
|
25
|
+
# This will contain the +Version+ class instance for use within +versioned+, +reverted+, and
|
26
|
+
# +untrashed+ callbacks.
|
27
|
+
attr_reader :hoardable_version
|
28
|
+
|
29
|
+
# @!attribute [r] hoardable_event_uuid
|
30
|
+
# @return [String] A postgres UUID that represents the +version+’s +ActiveRecord+ database transaction
|
31
|
+
# @!attribute [r] hoardable_operation
|
32
|
+
# @return [String] The database operation that created the +version+ - either +update+ or +delete+.
|
33
|
+
delegate :hoardable_event_uuid, :hoardable_operation, to: :hoardable_version, allow_nil: true
|
34
|
+
|
35
|
+
# Returns all +versions+ in ascending order of their temporal timeframes.
|
36
|
+
has_many(
|
37
|
+
:versions, -> { order(:_during) },
|
38
|
+
dependent: nil,
|
39
|
+
class_name: version_class.to_s,
|
40
|
+
inverse_of: model_name.i18n_key
|
41
|
+
)
|
42
|
+
end
|
43
|
+
|
44
|
+
# Returns a boolean of whether the record is actually a trashed +version+.
|
45
|
+
#
|
46
|
+
# @return [Boolean]
|
47
|
+
def trashed?
|
48
|
+
versions.trashed.limit(1).order(_during: :desc).first&.send(:hoardable_source_attributes) == attributes
|
49
|
+
end
|
50
|
+
|
51
|
+
# Returns the +version+ at the supplied +datetime+ or +time+. It will return +self+ if there is
|
52
|
+
# none. This will raise an error if you try to find a version in the future.
|
53
|
+
#
|
54
|
+
# @param datetime [DateTime, Time]
|
55
|
+
def at(datetime)
|
56
|
+
raise(Error, 'Future state cannot be known') if datetime.future?
|
57
|
+
|
58
|
+
versions.find_by(DURING_QUERY, datetime) || self
|
59
|
+
end
|
60
|
+
|
61
|
+
# If a version is found at the supplied datetime, it will +revert!+ to it and return it. This
|
62
|
+
# will raise an error if you try to revert to a version in the future.
|
63
|
+
#
|
64
|
+
# @param datetime [DateTime, Time]
|
65
|
+
def revert_to!(datetime)
|
66
|
+
return unless (version = at(datetime))
|
67
|
+
|
68
|
+
version.is_a?(self.class.version_class) ? version.revert! : self
|
69
|
+
end
|
70
|
+
|
71
|
+
private
|
72
|
+
|
73
|
+
def hoardable_callbacks_enabled
|
74
|
+
Hoardable.enabled && !self.class.name.end_with?(VERSION_CLASS_SUFFIX)
|
75
|
+
end
|
76
|
+
|
77
|
+
def insert_hoardable_version_on_update(&block)
|
78
|
+
insert_hoardable_version('update', attributes_before_type_cast.without('id'), &block)
|
79
|
+
end
|
80
|
+
|
81
|
+
def insert_hoardable_version_on_destroy(&block)
|
82
|
+
insert_hoardable_version('delete', attributes_before_type_cast, &block)
|
83
|
+
end
|
84
|
+
|
85
|
+
def insert_hoardable_version(operation, attrs)
|
86
|
+
@hoardable_version = initialize_hoardable_version(operation, attrs)
|
87
|
+
run_callbacks(:versioned) do
|
88
|
+
yield
|
89
|
+
hoardable_version.save(validate: false, touch: false)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def find_or_initialize_hoardable_event_uuid
|
94
|
+
Thread.current[:hoardable_event_uuid] ||= ActiveRecord::Base.connection.query('SELECT gen_random_uuid();')[0][0]
|
95
|
+
end
|
96
|
+
|
97
|
+
def initialize_hoardable_version(operation, attrs)
|
98
|
+
versions.new(
|
99
|
+
attrs.merge(
|
100
|
+
changes.transform_values { |h| h[0] },
|
101
|
+
{
|
102
|
+
_event_uuid: find_or_initialize_hoardable_event_uuid,
|
103
|
+
_operation: operation,
|
104
|
+
_data: initialize_hoardable_data.merge(changes: changes)
|
105
|
+
}
|
106
|
+
)
|
107
|
+
)
|
108
|
+
end
|
109
|
+
|
110
|
+
def initialize_hoardable_data
|
111
|
+
DATA_KEYS.to_h do |key|
|
112
|
+
[key, assign_hoardable_context(key)]
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
def assign_hoardable_context(key)
|
117
|
+
return nil if (value = Hoardable.public_send(key)).nil?
|
118
|
+
|
119
|
+
value.is_a?(Proc) ? value.call : value
|
120
|
+
end
|
121
|
+
|
122
|
+
def delete_hoardable_versions
|
123
|
+
versions.delete_all(:delete_all)
|
124
|
+
end
|
125
|
+
|
126
|
+
def unset_hoardable_version_and_event_uuid
|
127
|
+
@hoardable_version = nil
|
128
|
+
return if ActiveRecord::Base.connection.transaction_open?
|
129
|
+
|
130
|
+
Thread.current[:hoardable_event_uuid] = nil
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Hoardable
|
4
|
+
# This concern provides support for PostgreSQL’s tableoid system column to {SourceModel}.
|
5
|
+
module Tableoid
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
# @!visibility private
|
9
|
+
TABLEOID_AREL_CONDITIONS = lambda do |arel_table, condition|
|
10
|
+
arel_table[:tableoid].send(
|
11
|
+
condition,
|
12
|
+
Arel::Nodes::NamedFunction.new('CAST', [Arel::Nodes::Quoted.new(arel_table.name).as('regclass')])
|
13
|
+
)
|
14
|
+
end.freeze
|
15
|
+
|
16
|
+
included do
|
17
|
+
# @!visibility private
|
18
|
+
attr_writer :tableoid
|
19
|
+
|
20
|
+
# By default, {Hoardable} only returns instances of the parent table, and not the +versions+
|
21
|
+
# in the inherited table.
|
22
|
+
default_scope { where(TABLEOID_AREL_CONDITIONS.call(arel_table, :eq)) }
|
23
|
+
|
24
|
+
# @!scope class
|
25
|
+
# @!method include_versions
|
26
|
+
# @return [ActiveRecord<Object>]
|
27
|
+
#
|
28
|
+
# Returns +versions+ along with instances of the source models, all cast as instances of the
|
29
|
+
# source model’s class.
|
30
|
+
scope :include_versions, -> { unscope(where: [:tableoid]) }
|
31
|
+
|
32
|
+
# @!scope class
|
33
|
+
# @!method versions
|
34
|
+
# @return [ActiveRecord<Object>]
|
35
|
+
#
|
36
|
+
# Returns only +versions+ of the parent +ActiveRecord+ class, cast as instances of the source
|
37
|
+
# model’s class.
|
38
|
+
scope :versions, -> { include_versions.where(TABLEOID_AREL_CONDITIONS.call(arel_table, :not_eq)) }
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def tableoid
|
44
|
+
connection.execute("SELECT oid FROM pg_class WHERE relname = '#{table_name}'")[0]['oid']
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -1,35 +1,79 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Hoardable
|
4
|
-
# This concern is included into the dynamically generated Version
|
4
|
+
# This concern is included into the dynamically generated +Version+ kind of the parent
|
5
|
+
# +ActiveRecord+ class.
|
5
6
|
module VersionModel
|
6
7
|
extend ActiveSupport::Concern
|
7
8
|
|
8
9
|
included do
|
9
10
|
hoardable_source_key = superclass.model_name.i18n_key
|
11
|
+
|
12
|
+
# A +version+ belongs to it’s parent +ActiveRecord+ source.
|
10
13
|
belongs_to hoardable_source_key, inverse_of: :versions
|
11
14
|
alias_method :hoardable_source, hoardable_source_key
|
12
15
|
|
13
|
-
self.table_name = "#{table_name.singularize}
|
16
|
+
self.table_name = "#{table_name.singularize}#{Hoardable::VERSION_TABLE_SUFFIX}"
|
14
17
|
|
15
18
|
alias_method :readonly?, :persisted?
|
19
|
+
alias_attribute :hoardable_operation, :_operation
|
20
|
+
alias_attribute :hoardable_event_uuid, :_event_uuid
|
21
|
+
alias_attribute :hoardable_during, :_during
|
16
22
|
|
17
23
|
before_create :assign_temporal_tsrange
|
18
24
|
|
25
|
+
# @!scope class
|
26
|
+
# @!method trashed
|
27
|
+
# @return [ActiveRecord<Object>]
|
28
|
+
#
|
29
|
+
# Returns only trashed +versions+ that are orphans.
|
19
30
|
scope :trashed, lambda {
|
20
31
|
left_outer_joins(hoardable_source_key)
|
21
32
|
.where(superclass.table_name => { id: nil })
|
22
|
-
.where(
|
33
|
+
.where(_operation: 'delete')
|
23
34
|
}
|
35
|
+
|
36
|
+
# @!scope class
|
37
|
+
# @!method at
|
38
|
+
# @return [ActiveRecord<Object>]
|
39
|
+
#
|
40
|
+
# Returns +versions+ that were valid at the supplied +datetime+ or +time+.
|
41
|
+
scope :at, ->(datetime) { where(DURING_QUERY, datetime) }
|
42
|
+
|
43
|
+
# @!scope class
|
44
|
+
# @!method with_hoardable_event_uuid
|
45
|
+
# @return [ActiveRecord<Object>]
|
46
|
+
#
|
47
|
+
# Returns all +versions+ that were created as part of the same +ActiveRecord+ database
|
48
|
+
# transaction of the supplied +event_uuid+. Useful in +reverted+ and +untrashed+ callbacks.
|
49
|
+
scope :with_hoardable_event_uuid, ->(event_uuid) { where(_event_uuid: event_uuid) }
|
24
50
|
end
|
25
51
|
|
52
|
+
# Reverts the parent +ActiveRecord+ instance to the saved attributes of this +version+. Raises
|
53
|
+
# an error if the version is trashed.
|
26
54
|
def revert!
|
55
|
+
raise(Error, 'Version is trashed, cannot revert') unless hoardable_operation == 'update'
|
56
|
+
|
27
57
|
transaction do
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
58
|
+
hoardable_source.tap do |reverted|
|
59
|
+
reverted.update!(hoardable_source_attributes.without('id'))
|
60
|
+
reverted.instance_variable_set(:@hoardable_version, self)
|
61
|
+
reverted.run_callbacks(:reverted)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# Inserts a trashed +version+ back into its parent +ActiveRecord+ table with its original
|
67
|
+
# primary key. Raises an error if the version is not trashed.
|
68
|
+
def untrash!
|
69
|
+
raise(Error, 'Version is not trashed, cannot untrash') unless hoardable_operation == 'delete'
|
70
|
+
|
71
|
+
transaction do
|
72
|
+
superscope = self.class.superclass.unscoped
|
73
|
+
superscope.insert(untrashable_hoardable_source_attributes)
|
74
|
+
superscope.find(hoardable_source_foreign_id).tap do |untrashed|
|
75
|
+
untrashed.instance_variable_set(:@hoardable_version, self)
|
76
|
+
untrashed.run_callbacks(:untrashed)
|
33
77
|
end
|
34
78
|
end
|
35
79
|
end
|
@@ -40,14 +84,19 @@ module Hoardable
|
|
40
84
|
end
|
41
85
|
end
|
42
86
|
|
43
|
-
|
87
|
+
# Returns the +ActiveRecord+
|
88
|
+
# {https://api.rubyonrails.org/classes/ActiveModel/Dirty.html#method-i-changes changes} that
|
89
|
+
# were present during version creation.
|
90
|
+
def changes
|
91
|
+
_data&.dig('changes')
|
92
|
+
end
|
44
93
|
|
45
94
|
private
|
46
95
|
|
47
|
-
def
|
48
|
-
|
49
|
-
|
50
|
-
|
96
|
+
def untrashable_hoardable_source_attributes
|
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
|
51
100
|
end
|
52
101
|
|
53
102
|
def hoardable_source_attributes
|
@@ -61,12 +110,24 @@ module Hoardable
|
|
61
110
|
@hoardable_source_foreign_key ||= "#{self.class.superclass.model_name.i18n_key}_id"
|
62
111
|
end
|
63
112
|
|
113
|
+
def hoardable_source_foreign_id
|
114
|
+
@hoardable_source_foreign_id ||= public_send(hoardable_source_foreign_key)
|
115
|
+
end
|
116
|
+
|
64
117
|
def previous_temporal_tsrange_end
|
65
118
|
hoardable_source.versions.limit(1).order(_during: :desc).pluck('_during').first&.end
|
66
119
|
end
|
67
120
|
|
68
121
|
def assign_temporal_tsrange
|
69
|
-
|
122
|
+
range_start = (
|
123
|
+
previous_temporal_tsrange_end ||
|
124
|
+
if hoardable_source.class.column_names.include?('created_at')
|
125
|
+
hoardable_source.created_at
|
126
|
+
else
|
127
|
+
Time.at(0)
|
128
|
+
end
|
129
|
+
)
|
130
|
+
self._during = (range_start..Time.now)
|
70
131
|
end
|
71
132
|
end
|
72
133
|
end
|
data/lib/hoardable.rb
CHANGED
@@ -1,6 +1,10 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require_relative 'hoardable/version'
|
3
4
|
require_relative 'hoardable/hoardable'
|
5
|
+
require_relative 'hoardable/tableoid'
|
6
|
+
require_relative 'hoardable/error'
|
7
|
+
require_relative 'hoardable/source_model'
|
4
8
|
require_relative 'hoardable/version_model'
|
5
9
|
require_relative 'hoardable/model'
|
6
10
|
require_relative 'generators/hoardable/migration_generator'
|
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.3
|
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-02 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|
@@ -51,45 +51,45 @@ dependencies:
|
|
51
51
|
- !ruby/object:Gem::Version
|
52
52
|
version: '8'
|
53
53
|
- !ruby/object:Gem::Dependency
|
54
|
-
name:
|
54
|
+
name: railties
|
55
55
|
requirement: !ruby/object:Gem::Requirement
|
56
56
|
requirements:
|
57
57
|
- - ">="
|
58
58
|
- !ruby/object:Gem::Version
|
59
|
-
version: '1
|
59
|
+
version: '6.1'
|
60
60
|
- - "<"
|
61
61
|
- !ruby/object:Gem::Version
|
62
|
-
version: '
|
62
|
+
version: '8'
|
63
63
|
type: :runtime
|
64
64
|
prerelease: false
|
65
65
|
version_requirements: !ruby/object:Gem::Requirement
|
66
66
|
requirements:
|
67
67
|
- - ">="
|
68
68
|
- !ruby/object:Gem::Version
|
69
|
-
version: '1
|
69
|
+
version: '6.1'
|
70
70
|
- - "<"
|
71
71
|
- !ruby/object:Gem::Version
|
72
|
-
version: '
|
72
|
+
version: '8'
|
73
73
|
- !ruby/object:Gem::Dependency
|
74
|
-
name:
|
74
|
+
name: pg
|
75
75
|
requirement: !ruby/object:Gem::Requirement
|
76
76
|
requirements:
|
77
77
|
- - ">="
|
78
78
|
- !ruby/object:Gem::Version
|
79
|
-
version: '
|
79
|
+
version: '1'
|
80
80
|
- - "<"
|
81
81
|
- !ruby/object:Gem::Version
|
82
|
-
version: '
|
82
|
+
version: '2'
|
83
83
|
type: :runtime
|
84
84
|
prerelease: false
|
85
85
|
version_requirements: !ruby/object:Gem::Requirement
|
86
86
|
requirements:
|
87
87
|
- - ">="
|
88
88
|
- !ruby/object:Gem::Version
|
89
|
-
version: '
|
89
|
+
version: '1'
|
90
90
|
- - "<"
|
91
91
|
- !ruby/object:Gem::Version
|
92
|
-
version: '
|
92
|
+
version: '2'
|
93
93
|
description: Rails model versioning with the power of uni-temporal inherited tables
|
94
94
|
email:
|
95
95
|
- justin@waymondo.com
|
@@ -106,9 +106,14 @@ files:
|
|
106
106
|
- Rakefile
|
107
107
|
- lib/generators/hoardable/migration_generator.rb
|
108
108
|
- lib/generators/hoardable/templates/migration.rb.erb
|
109
|
+
- lib/generators/hoardable/templates/migration_6.rb.erb
|
109
110
|
- lib/hoardable.rb
|
111
|
+
- lib/hoardable/error.rb
|
110
112
|
- lib/hoardable/hoardable.rb
|
111
113
|
- lib/hoardable/model.rb
|
114
|
+
- lib/hoardable/source_model.rb
|
115
|
+
- lib/hoardable/tableoid.rb
|
116
|
+
- lib/hoardable/version.rb
|
112
117
|
- lib/hoardable/version_model.rb
|
113
118
|
- sig/hoardable.rbs
|
114
119
|
homepage: https://github.com/waymondo/hoardable
|