hoardable 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/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
|