hoardable 0.1.1 → 0.1.2

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