hoardable 0.1.0 → 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Gemfile +2 -2
- data/README.md +68 -39
- data/lib/generators/hoardable/migration_generator.rb +9 -1
- data/lib/generators/hoardable/templates/migration.rb.erb +8 -1
- data/lib/generators/hoardable/templates/migration_6.rb.erb +34 -0
- data/lib/hoardable/error.rb +6 -0
- data/lib/hoardable/hoardable.rb +6 -2
- data/lib/hoardable/model.rb +8 -58
- data/lib/hoardable/source_model.rb +104 -0
- data/lib/hoardable/tableoid.rb +27 -0
- data/lib/hoardable/version.rb +5 -0
- data/lib/hoardable/version_model.rb +42 -13
- 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: 7380fb64f7dd3132bec65fd1cfc0c8317a52e6cb6f26895d0b83d7b78391c193
|
4
|
+
data.tar.gz: 71241a1edc81c57d1bb0549685da47642036fc65ff8d659284c31a9784b0f571
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 4f473b92097e223dd535512ec5850a6c00d9c22faba482787ffbf2ae7b0f6130d37b0a3ed34a46701f53ec4958e7293a69626bdc9693f1f7a51c0e35ff62bb2e
|
7
|
+
data.tar.gz: 530e609fd4535b4d37f82fc4a39ce3322209451bdd0fa67079e74ed2ef658bd252d9a87ca7189a9332e0303b20c8cb073765bc1c99fcdb647c346f893a133ecd
|
data/Gemfile
CHANGED
data/README.md
CHANGED
@@ -1,23 +1,25 @@
|
|
1
1
|
# Hoardable
|
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
|
+
#### nice... huh?
|
7
7
|
|
8
8
|
[Temporal tables](https://en.wikipedia.org/wiki/Temporal_database) are a database design pattern
|
9
|
-
where each row contains data
|
10
|
-
|
9
|
+
where each row of a table contains data along with one or more time ranges. In the case of this gem,
|
10
|
+
each database row has a time range that represents the row’s valid time range - hence
|
11
11
|
"uni-temporal".
|
12
12
|
|
13
13
|
[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
|
-
|
14
|
+
that allows a table to inherit all columns of a parent table. The descendant table’s schema will
|
15
|
+
stay in sync with its parent. If a new column is added to or removed from the parent, the schema
|
16
|
+
change is reflected on its descendants.
|
17
17
|
|
18
|
-
With these
|
19
|
-
|
20
|
-
other versioning
|
18
|
+
With these concepts combined, `hoardable` offers a simple and effective model versioning system for
|
19
|
+
Rails. Versions of records are stored in separate, inherited tables along with their valid time
|
20
|
+
ranges and contextual data. Compared to other Rails-oriented versioning systems, this gem strives to
|
21
|
+
be more explicit and obvious on the lower RDBS level while still familiar and convenient within Ruby
|
22
|
+
on Rails.
|
21
23
|
|
22
24
|
## Installation
|
23
25
|
|
@@ -31,12 +33,15 @@ And then execute `bundle install`.
|
|
31
33
|
|
32
34
|
### Model Installation
|
33
35
|
|
34
|
-
|
36
|
+
You must include `Hoardable::Model` into an ActiveRecord model that you would like to hoard versions
|
37
|
+
of:
|
35
38
|
|
36
39
|
```ruby
|
37
40
|
class Post < ActiveRecord::Base
|
38
41
|
include Hoardable::Model
|
39
42
|
belongs_to :user
|
43
|
+
has_many :comments, dependent: :destroy
|
44
|
+
...
|
40
45
|
end
|
41
46
|
```
|
42
47
|
|
@@ -47,52 +52,60 @@ bin/rails g hoardable:migration posts
|
|
47
52
|
bin/rails db:migrate
|
48
53
|
```
|
49
54
|
|
55
|
+
_Note:_ If you are on Rails 6.1, you might want to set `config.active_record.schema_format = :sql`
|
56
|
+
in `application.rb`, so that the enum type is captured in your schema dump. This is not required in
|
57
|
+
Rails 7.
|
58
|
+
|
50
59
|
## Usage
|
51
60
|
|
52
|
-
###
|
61
|
+
### Overview
|
53
62
|
|
54
63
|
Once you include `Hoardable::Model` into a model, it will dynamically generate a "Version" subclass
|
55
|
-
of that model.
|
64
|
+
of that model. As we continue our example above, :
|
56
65
|
|
57
66
|
```
|
67
|
+
$ irb
|
58
68
|
>> Post
|
59
69
|
=> Post(id: integer, body: text, user_id: integer, created_at: datetime)
|
60
70
|
>> PostVersion
|
61
71
|
=> PostVersion(id: integer, body: text, user_id: integer, created_at: datetime, _data: jsonb, _during: tsrange, post_id: integer)
|
62
72
|
```
|
63
73
|
|
64
|
-
A `Post` now `has_many :versions
|
65
|
-
default):
|
74
|
+
A `Post` now `has_many :versions`. Whenever an update and deletion of a `Post` occurs, a version is
|
75
|
+
created (by default):
|
66
76
|
|
67
77
|
```ruby
|
68
|
-
|
78
|
+
post = Post.create!(attributes)
|
69
79
|
post.versions.size # => 0
|
70
80
|
post.update!(title: "Title")
|
71
81
|
post.versions.size # => 1
|
72
82
|
post.destroy!
|
73
|
-
post.
|
74
|
-
|
83
|
+
post.trashed? # true
|
84
|
+
post.versions.size # => 2
|
85
|
+
Post.find(post.id) # raises ActiveRecord::RecordNotFound
|
75
86
|
```
|
76
87
|
|
77
88
|
Each `PostVersion` has access to the same attributes, relationships, and other model behavior that
|
78
|
-
`Post` has, but
|
89
|
+
`Post` has, but as a read-only record.
|
79
90
|
|
80
|
-
If you ever need to revert to a specific version, you can call `version.revert!` on it. If
|
81
|
-
|
91
|
+
If you ever need to revert to a specific version, you can call `version.revert!` on it. If you would
|
92
|
+
like to untrash a specific version, you can call `version.untrash!` on it. This will re-insert the
|
93
|
+
model in the parent class’ table with it’s original primary key.
|
82
94
|
|
83
95
|
### Querying and Temporal Lookup
|
84
96
|
|
85
|
-
Since a `PostVersion` is
|
86
|
-
resource, i.e:
|
97
|
+
Since a `PostVersion` is an `ActiveRecord` class, you can query them like another model resource:
|
87
98
|
|
88
99
|
```ruby
|
89
|
-
post.versions.where(user_id: Current.user.id, body:
|
100
|
+
post.versions.where(user_id: Current.user.id, body: "Cool!")
|
90
101
|
```
|
91
102
|
|
92
|
-
If you want to look-up the version of a
|
103
|
+
If you want to look-up the version of a record at a specific time, you can use the `.at` method:
|
93
104
|
|
94
105
|
```ruby
|
95
106
|
post.at(1.day.ago) # => #<PostVersion:0x000000010d44fa30>
|
107
|
+
# or
|
108
|
+
PostVersion.at(1.day.ago).find_by(post_id: post.id) # => #<PostVersion:0x000000010d44fa30>
|
96
109
|
```
|
97
110
|
|
98
111
|
By default, `hoardable` will keep copies of records you have destroyed. You can query for them as
|
@@ -103,14 +116,17 @@ PostVersion.trashed
|
|
103
116
|
```
|
104
117
|
|
105
118
|
_Note:_ Creating an inherited table does not copy over the indexes from the parent table. If you
|
106
|
-
need to query versions often, you
|
119
|
+
need to query versions often, you should add appropriate indexes to the `_versions` tables.
|
107
120
|
|
108
121
|
### Tracking contextual data
|
109
122
|
|
110
|
-
You’ll often want to track contextual data about a version. `hoardable` will
|
111
|
-
|
123
|
+
You’ll often want to track contextual data about the creation of a version. `hoardable` will
|
124
|
+
automatically capture the ActiveRecord
|
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`.
|
112
128
|
|
113
|
-
There
|
129
|
+
There 3 other optional keys that are provided for tracking contextual information:
|
114
130
|
|
115
131
|
- `whodunit` - an identifier for who is responsible for creating the version
|
116
132
|
- `note` - a string containing a description regarding the versioning
|
@@ -119,10 +135,13 @@ There are also 3 other optional keys that are provided for tracking contextual i
|
|
119
135
|
This information is stored in a `jsonb` column. Each key’s value can be in the format of your
|
120
136
|
choosing.
|
121
137
|
|
122
|
-
One convenient way to assign
|
138
|
+
One convenient way to assign contextual data to these is by defining a proc in an initializer, i.e.:
|
123
139
|
|
124
140
|
```ruby
|
125
141
|
Hoardable.whodunit = -> { Current.user&.id }
|
142
|
+
Current.user = User.find(123)
|
143
|
+
post.update!(status: 'live')
|
144
|
+
post.versions.last.hoardable_whodunit # => 123
|
126
145
|
```
|
127
146
|
|
128
147
|
You can also set this context manually as well, just remember to clear them afterwards.
|
@@ -134,7 +153,7 @@ Hoardable.note = nil
|
|
134
153
|
post.versions.last.hoardable_note # => "reverting due to accidental deletion"
|
135
154
|
```
|
136
155
|
|
137
|
-
|
156
|
+
A more useful pattern is to use `Hoardable.with` to set the context around a block. A good example
|
138
157
|
of this would be in `ApplicationController`:
|
139
158
|
|
140
159
|
```ruby
|
@@ -147,38 +166,48 @@ class ApplicationController < ActionController::Base
|
|
147
166
|
Hoardable.with(whodunit: current_user.id, meta: { request_uuid: request.uuid }) do
|
148
167
|
yield
|
149
168
|
end
|
169
|
+
# `Hoardable.whodunit` is back to nil or the previously set value
|
150
170
|
end
|
151
171
|
end
|
152
172
|
```
|
153
173
|
|
154
174
|
### Model Callbacks
|
155
175
|
|
156
|
-
Sometimes you might want to do something with a version before it gets
|
157
|
-
|
158
|
-
|
176
|
+
Sometimes you might want to do something with a version before or after it gets inserted to the
|
177
|
+
database. You can access it in `before/after/around_versioned` callbacks on the source record as
|
178
|
+
`hoardable_version`. These happen around `.save`, which is enclosed in an ActiveRecord transaction.
|
179
|
+
|
180
|
+
There are also `after_reverted` and `after_untrashed` callbacks available as well, which are called
|
181
|
+
on the source record after a version is reverted or untrashed.
|
159
182
|
|
160
|
-
```
|
183
|
+
```ruby
|
161
184
|
class User
|
162
|
-
|
185
|
+
include Hoardable::Model
|
186
|
+
before_versioned :sanitize_version
|
163
187
|
after_reverted :track_reverted_event
|
188
|
+
after_untrashed :track_untrashed_event
|
164
189
|
|
165
190
|
private
|
166
191
|
|
167
192
|
def sanitize_version
|
168
193
|
hoardable_version.sanitize_password
|
169
|
-
end
|
194
|
+
end
|
170
195
|
|
171
196
|
def track_reverted_event
|
172
197
|
track_event(:user_reverted, self)
|
173
198
|
end
|
199
|
+
|
200
|
+
def track_untrashed_event
|
201
|
+
track_event(:user_untrashed, self)
|
202
|
+
end
|
174
203
|
end
|
175
204
|
```
|
176
205
|
|
177
206
|
### Configuration
|
178
207
|
|
179
|
-
There are two
|
208
|
+
There are two configurable options currently:
|
180
209
|
|
181
|
-
```
|
210
|
+
```ruby
|
182
211
|
Hoardable.enabled # => default true
|
183
212
|
Hoardable.save_trash # => default true
|
184
213
|
```
|
@@ -10,10 +10,18 @@ module Hoardable
|
|
10
10
|
include Rails::Generators::Migration
|
11
11
|
|
12
12
|
def create_versions_table
|
13
|
-
migration_template
|
13
|
+
migration_template migration_template_name, "db/migrate/create_#{singularized_table_name}_versions.rb"
|
14
14
|
end
|
15
15
|
|
16
16
|
no_tasks do
|
17
|
+
def migration_template_name
|
18
|
+
if Gem::Version.new(ActiveRecord::Migration.current_version.to_s) < Gem::Version.new('7')
|
19
|
+
'migration_6.rb.erb'
|
20
|
+
else
|
21
|
+
'migration.rb.erb'
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
17
25
|
def singularized_table_name
|
18
26
|
@singularized_table_name ||= table_name.singularize
|
19
27
|
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
|
9
|
+
t.enum :_operation, enum_type: 'hoardable_operation', null: false
|
8
10
|
t.bigint :<%= singularized_table_name %>_id, null: false, index: true
|
9
11
|
end
|
10
|
-
add_index
|
12
|
+
add_index(
|
13
|
+
:<%= singularized_table_name %>_versions,
|
14
|
+
%i[_during <%= singularized_table_name %>_id],
|
15
|
+
name: 'idx_<%= singularized_table_name %>_versions_temporally'
|
16
|
+
)
|
17
|
+
add_index :<%= singularized_table_name %>_versions, :_operation
|
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.column :_operation, :hoardable_operation, null: false
|
25
|
+
t.bigint :<%= singularized_table_name %>_id, null: false, index: true
|
26
|
+
end
|
27
|
+
add_index(
|
28
|
+
:<%= singularized_table_name %>_versions,
|
29
|
+
%i[_during <%= singularized_table_name %>_id],
|
30
|
+
name: 'idx_<%= singularized_table_name %>_versions_temporally'
|
31
|
+
)
|
32
|
+
add_index :<%= singularized_table_name %>_versions, :_operation
|
33
|
+
end
|
34
|
+
end
|
data/lib/hoardable/hoardable.rb
CHANGED
@@ -2,10 +2,14 @@
|
|
2
2
|
|
3
3
|
# An ActiveRecord extension for keeping versions of records in temporal inherited tables
|
4
4
|
module Hoardable
|
5
|
-
|
6
|
-
DATA_KEYS = %i[changes meta whodunit note operation].freeze
|
5
|
+
DATA_KEYS = %i[meta whodunit note event_id].freeze
|
7
6
|
CONFIG_KEYS = %i[enabled save_trash].freeze
|
8
7
|
|
8
|
+
VERSION_CLASS_SUFFIX = 'Version'
|
9
|
+
VERSION_TABLE_SUFFIX = "_#{VERSION_CLASS_SUFFIX.tableize}"
|
10
|
+
SAVE_TRASH_ENABLED = -> { Hoardable.save_trash }.freeze
|
11
|
+
DURING_QUERY = '_during @> ?::timestamp'
|
12
|
+
|
9
13
|
@context = {}
|
10
14
|
@config = CONFIG_KEYS.to_h do |key|
|
11
15
|
[key, true]
|
data/lib/hoardable/model.rb
CHANGED
@@ -1,78 +1,28 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Hoardable
|
4
|
-
# This concern
|
5
|
-
#
|
4
|
+
# This concern dynamically generates the Version variant of the class module Model and includes
|
5
|
+
# the API methods and relationships on the source model
|
6
6
|
module Model
|
7
7
|
extend ActiveSupport::Concern
|
8
8
|
|
9
9
|
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
|
-
|
10
|
+
define_model_callbacks :versioned
|
20
11
|
define_model_callbacks :reverted, only: :after
|
12
|
+
define_model_callbacks :untrashed, only: :after
|
21
13
|
|
22
14
|
TracePoint.new(:end) do |trace|
|
23
15
|
next unless self == trace.self
|
24
16
|
|
25
|
-
version_class_name = "#{name}
|
17
|
+
version_class_name = "#{name}#{VERSION_CLASS_SUFFIX}"
|
26
18
|
next if Object.const_defined?(version_class_name)
|
27
19
|
|
28
20
|
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
|
-
trace.disable
|
36
|
-
end.enable
|
37
|
-
end
|
38
|
-
|
39
|
-
def at(datetime)
|
40
|
-
versions.find_by('_during @> ?::timestamp', datetime) || self
|
41
|
-
end
|
42
21
|
|
43
|
-
|
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
|
22
|
+
include SourceModel
|
55
23
|
|
56
|
-
|
57
|
-
|
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)
|
24
|
+
trace.disable
|
25
|
+
end.enable
|
76
26
|
end
|
77
27
|
end
|
78
28
|
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Hoardable
|
4
|
+
# This concern contains the relationships, callbacks, and API methods for a Source model
|
5
|
+
module SourceModel
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
class_methods do
|
9
|
+
def version_class
|
10
|
+
"#{name}#{VERSION_CLASS_SUFFIX}".constantize
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
included do
|
15
|
+
include Tableoid
|
16
|
+
|
17
|
+
around_update :insert_hoardable_version_on_update, if: :hoardable_callbacks_enabled
|
18
|
+
around_destroy :insert_hoardable_version_on_destroy, if: [:hoardable_callbacks_enabled, SAVE_TRASH_ENABLED]
|
19
|
+
before_destroy :delete_hoardable_versions, if: :hoardable_callbacks_enabled, unless: SAVE_TRASH_ENABLED
|
20
|
+
after_commit :unset_hoardable_version_and_event_id
|
21
|
+
|
22
|
+
attr_reader :hoardable_version
|
23
|
+
|
24
|
+
has_many(
|
25
|
+
:versions, -> { order(:_during) },
|
26
|
+
dependent: nil,
|
27
|
+
class_name: version_class.to_s,
|
28
|
+
inverse_of: model_name.i18n_key
|
29
|
+
)
|
30
|
+
end
|
31
|
+
|
32
|
+
def trashed?
|
33
|
+
versions.trashed.limit(1).order(_during: :desc).first&.send(:hoardable_source_attributes) == attributes
|
34
|
+
end
|
35
|
+
|
36
|
+
def at(datetime)
|
37
|
+
versions.find_by(DURING_QUERY, datetime) || self
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def hoardable_callbacks_enabled
|
43
|
+
Hoardable.enabled && !self.class.name.end_with?(VERSION_CLASS_SUFFIX)
|
44
|
+
end
|
45
|
+
|
46
|
+
def insert_hoardable_version_on_update(&block)
|
47
|
+
insert_hoardable_version('update', attributes_before_type_cast.without('id'), &block)
|
48
|
+
end
|
49
|
+
|
50
|
+
def insert_hoardable_version_on_destroy(&block)
|
51
|
+
insert_hoardable_version('delete', attributes_before_type_cast, &block)
|
52
|
+
end
|
53
|
+
|
54
|
+
def insert_hoardable_version(operation, attrs)
|
55
|
+
event_id = find_or_initialize_hoardable_event_id
|
56
|
+
Hoardable.with(event_id: event_id) do
|
57
|
+
@hoardable_version = initialize_hoardable_version(operation, attrs)
|
58
|
+
run_callbacks(:versioned) do
|
59
|
+
yield
|
60
|
+
hoardable_version.save(validate: false, touch: false)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def find_or_initialize_hoardable_event_id
|
66
|
+
Thread.current[:hoardable_event_id] ||= SecureRandom.hex
|
67
|
+
end
|
68
|
+
|
69
|
+
def initialize_hoardable_version(operation, attrs)
|
70
|
+
versions.new(
|
71
|
+
attrs.merge(
|
72
|
+
changes.transform_values { |h| h[0] },
|
73
|
+
{
|
74
|
+
_operation: operation,
|
75
|
+
_data: initialize_hoardable_data.merge(changes: changes)
|
76
|
+
}
|
77
|
+
)
|
78
|
+
)
|
79
|
+
end
|
80
|
+
|
81
|
+
def initialize_hoardable_data
|
82
|
+
DATA_KEYS.to_h do |key|
|
83
|
+
[key, assign_hoardable_context(key)]
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def assign_hoardable_context(key)
|
88
|
+
return nil if (value = Hoardable.public_send(key)).nil?
|
89
|
+
|
90
|
+
value.is_a?(Proc) ? value.call : value
|
91
|
+
end
|
92
|
+
|
93
|
+
def delete_hoardable_versions
|
94
|
+
versions.delete_all(:delete_all)
|
95
|
+
end
|
96
|
+
|
97
|
+
def unset_hoardable_version_and_event_id
|
98
|
+
@hoardable_version = nil
|
99
|
+
return if ActiveRecord::Base.connection.transaction_open?
|
100
|
+
|
101
|
+
Thread.current[:hoardable_event_id] = nil
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Hoardable
|
4
|
+
# This concern provides support for PostgreSQL's tableoid system column
|
5
|
+
module Tableoid
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
TABLEOID_AREL_CONDITIONS = lambda do |arel_table, condition|
|
9
|
+
arel_table[:tableoid].send(
|
10
|
+
condition,
|
11
|
+
Arel::Nodes::NamedFunction.new('CAST', [Arel::Nodes::Quoted.new(arel_table.name).as('regclass')])
|
12
|
+
)
|
13
|
+
end.freeze
|
14
|
+
|
15
|
+
included do
|
16
|
+
attr_writer :tableoid
|
17
|
+
|
18
|
+
default_scope { where(TABLEOID_AREL_CONDITIONS.call(arel_table, :eq)) }
|
19
|
+
scope :include_versions, -> { unscope(where: [:tableoid]) }
|
20
|
+
scope :versions, -> { include_versions.where(TABLEOID_AREL_CONDITIONS.call(arel_table, :not_eq)) }
|
21
|
+
end
|
22
|
+
|
23
|
+
def tableoid
|
24
|
+
connection.execute("SELECT oid FROM pg_class WHERE relname = '#{table_name}'")[0]['oid']
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -10,7 +10,7 @@ module Hoardable
|
|
10
10
|
belongs_to hoardable_source_key, inverse_of: :versions
|
11
11
|
alias_method :hoardable_source, hoardable_source_key
|
12
12
|
|
13
|
-
self.table_name = "#{table_name.singularize}
|
13
|
+
self.table_name = "#{table_name.singularize}#{Hoardable::VERSION_TABLE_SUFFIX}"
|
14
14
|
|
15
15
|
alias_method :readonly?, :persisted?
|
16
16
|
|
@@ -19,17 +19,32 @@ module Hoardable
|
|
19
19
|
scope :trashed, lambda {
|
20
20
|
left_outer_joins(hoardable_source_key)
|
21
21
|
.where(superclass.table_name => { id: nil })
|
22
|
-
.where(
|
22
|
+
.where(_operation: 'delete')
|
23
23
|
}
|
24
|
+
scope :at, ->(datetime) { where(DURING_QUERY, datetime) }
|
24
25
|
end
|
25
26
|
|
26
27
|
def revert!
|
28
|
+
raise(Error, 'Version is trashed, cannot revert') unless _operation == 'update'
|
29
|
+
|
30
|
+
transaction do
|
31
|
+
hoardable_source.tap do |reverted|
|
32
|
+
reverted.update!(hoardable_source_attributes.without('id'))
|
33
|
+
reverted.instance_variable_set(:@hoardable_version, self)
|
34
|
+
reverted.run_callbacks(:reverted)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def untrash!
|
40
|
+
raise(Error, 'Version is not trashed, cannot untrash') unless _operation == 'delete'
|
41
|
+
|
27
42
|
transaction do
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
43
|
+
superscope = self.class.superclass.unscoped
|
44
|
+
superscope.insert(untrashable_hoardable_source_attributes)
|
45
|
+
superscope.find(hoardable_source_foreign_id).tap do |untrashed|
|
46
|
+
untrashed.instance_variable_set(:@hoardable_version, self)
|
47
|
+
untrashed.run_callbacks(:untrashed)
|
33
48
|
end
|
34
49
|
end
|
35
50
|
end
|
@@ -40,14 +55,16 @@ module Hoardable
|
|
40
55
|
end
|
41
56
|
end
|
42
57
|
|
43
|
-
|
58
|
+
def changes
|
59
|
+
_data&.dig('changes')
|
60
|
+
end
|
44
61
|
|
45
62
|
private
|
46
63
|
|
47
|
-
def
|
48
|
-
|
49
|
-
|
50
|
-
|
64
|
+
def untrashable_hoardable_source_attributes
|
65
|
+
hoardable_source_attributes.merge('id' => hoardable_source_foreign_id).tap do |hash|
|
66
|
+
hash['updated_at'] = Time.now if self.class.column_names.include?('updated_at')
|
67
|
+
end
|
51
68
|
end
|
52
69
|
|
53
70
|
def hoardable_source_attributes
|
@@ -61,12 +78,24 @@ module Hoardable
|
|
61
78
|
@hoardable_source_foreign_key ||= "#{self.class.superclass.model_name.i18n_key}_id"
|
62
79
|
end
|
63
80
|
|
81
|
+
def hoardable_source_foreign_id
|
82
|
+
@hoardable_source_foreign_id ||= public_send(hoardable_source_foreign_key)
|
83
|
+
end
|
84
|
+
|
64
85
|
def previous_temporal_tsrange_end
|
65
86
|
hoardable_source.versions.limit(1).order(_during: :desc).pluck('_during').first&.end
|
66
87
|
end
|
67
88
|
|
68
89
|
def assign_temporal_tsrange
|
69
|
-
|
90
|
+
range_start = (
|
91
|
+
previous_temporal_tsrange_end ||
|
92
|
+
if hoardable_source.class.column_names.include?('created_at')
|
93
|
+
hoardable_source.created_at
|
94
|
+
else
|
95
|
+
Time.at(0)
|
96
|
+
end
|
97
|
+
)
|
98
|
+
self._during = (range_start..Time.now)
|
70
99
|
end
|
71
100
|
end
|
72
101
|
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.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-07-
|
11
|
+
date: 2022-07-28 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
|