hoardable 0.1.0 → 0.1.3

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml 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