hoardable 0.1.0 → 0.1.3

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: 24eee9ce1b76bd2d5a40a6b527ef4e048c25850ea454f75b2838f45b21339c25
4
- data.tar.gz: e464f7418ec2527ed6fe598b4575481a01d50fea236631749d0722bdd328c66f
3
+ metadata.gz: 96952d8928266fc5ae03199a91e85428565ff11385a74851fe61fd3a8eafc881
4
+ data.tar.gz: 1b9117cbf4ea08325d29212c16580e368a857b12f8c2d20bc346d9e8f5268d59
5
5
  SHA512:
6
- metadata.gz: a617926045371fa040ae329241fe37a5ea58ea4fdcd43c2c66f0c3fe2b89cc0b5c2723371c88c9946b4bda4cf20d477a936494a32f6e8e39b9a1b5e727c7e9b4
7
- data.tar.gz: 283ee7c613a1d07b227e32dc823914ea2cdc9b144a9b9d03c0155adf21cdeeacecd92ba8d173d2c0c4bf17febbd1343571898db7c5e24ea828e0f261ae82cbb7
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 **uni-temporal inherited tables**.
4
+ versioning and soft-deletion of records through the use of _uni-temporal inherited tables_.
5
5
 
6
- ### Huh?
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 as well as one or more time ranges. In the case of a temporal table
10
- representing versions, each row has one time range representing the row’s valid time range, hence
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 another table. The descendant table’s schema will stay
15
- in sync with all columns that it inherits from it’s parent. If a new column or removed from the
16
- parent, the schema change is reflected on its descendants.
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 principles combined, `hoardable` offers a simple and effective model versioning system,
19
- where versions of records are stored in a separate, inherited table with the validity time range and
20
- other versioning data.
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
- First, include `Hoardable::Model` into a model you would like to hoard versions of:
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 posts
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
- ### Basics
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. Continuing our example above:
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` which are created on every update and deletion of a `Post` (by
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
- post_id = post.id
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.reload # => ActiveRecord::RecordNotFound
74
- PostVersion.where(post_id: post_id).size # => 2
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 is a read-only record.
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 the
81
- source post had been deleted, this will untrash it with it’s original primary key.
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 just a normal `ActiveRecord`, you can query them like another model
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: nil)
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 `Post` at a specific time, you can use the `.at` method:
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 will need to add those indexes to the `_versions` tables manually.
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. `hoardable` will automatically capture
111
- the ActiveRecord `changes` hash and `operation` that cause the version (`update` or `delete`).
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
- - `whodunit` - an identifier for who is responsible for creating the version
116
- - `note` - a string containing a description regarding the versioning
117
- - `meta` - any other contextual information you’d like to store along with the version
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 this contextual data is with a proc in an initializer, i.e.:
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
- Another useful pattern is to use `Hoardable.with` to set the context around a block. A good example
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 saved. You can access it in a
157
- `before_save` callback as `hoardable_version`. There is also an `after_reverted` callback available
158
- as well.
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
- ``` ruby
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
- before_save :sanitize_version
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 available options:
225
+ There are two configurable options currently:
180
226
 
181
- ``` ruby
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 for an inherited temporal table of a model including {Hoardable::Model}
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 'migration.rb.erb', "db/migrate/create_#{singularized_table_name}_versions.rb"
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.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
9
12
  end
10
- add_index :<%= singularized_table_name %>_versions, %i[_during <%= singularized_table_name %>_id]
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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hoardable
4
+ # A subclass of +StandardError+ for general use within {Hoardable}.
5
+ class Error < StandardError; end
6
+ end
@@ -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
- VERSION = '0.1.0'
6
- DATA_KEYS = %i[changes meta whodunit note operation].freeze
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
@@ -1,78 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hoardable
4
- # This concern includes the Hoardable API methods on ActiveRecord instances and dynamically
5
- # generates the Version variant of the class
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
- default_scope { where("#{table_name}.tableoid = '#{table_name}'::regclass") }
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}Version"
26
- next if Object.const_defined?(version_class_name)
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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hoardable
4
+ VERSION = '0.1.3'
5
+ 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 models.
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}_versions"
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("_data ->> 'operation' = 'delete'")
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
- hoardable_source&.tap { |tapped| tapped.update!(hoardable_source_attributes.without('id')) } ||
30
- untrash
31
- ).tap do |tapped|
32
- tapped.run_callbacks(:reverted)
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
- alias changes hoardable_changes
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 untrash
48
- foreign_id = public_send(hoardable_source_foreign_key)
49
- self.class.superclass.insert(hoardable_source_attributes.merge('id' => foreign_id, 'updated_at' => Time.now))
50
- self.class.superclass.find(foreign_id)
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
- self._during = ((previous_temporal_tsrange_end || hoardable_source.created_at)..Time.now)
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.0
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-07-26 00:00:00.000000000 Z
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: pg
54
+ name: railties
55
55
  requirement: !ruby/object:Gem::Requirement
56
56
  requirements:
57
57
  - - ">="
58
58
  - !ruby/object:Gem::Version
59
- version: '1.0'
59
+ version: '6.1'
60
60
  - - "<"
61
61
  - !ruby/object:Gem::Version
62
- version: '2'
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.0'
69
+ version: '6.1'
70
70
  - - "<"
71
71
  - !ruby/object:Gem::Version
72
- version: '2'
72
+ version: '8'
73
73
  - !ruby/object:Gem::Dependency
74
- name: railties
74
+ name: pg
75
75
  requirement: !ruby/object:Gem::Requirement
76
76
  requirements:
77
77
  - - ">="
78
78
  - !ruby/object:Gem::Version
79
- version: '6.1'
79
+ version: '1'
80
80
  - - "<"
81
81
  - !ruby/object:Gem::Version
82
- version: '8'
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: '6.1'
89
+ version: '1'
90
90
  - - "<"
91
91
  - !ruby/object:Gem::Version
92
- version: '8'
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