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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 24eee9ce1b76bd2d5a40a6b527ef4e048c25850ea454f75b2838f45b21339c25
4
- data.tar.gz: e464f7418ec2527ed6fe598b4575481a01d50fea236631749d0722bdd328c66f
3
+ metadata.gz: 7380fb64f7dd3132bec65fd1cfc0c8317a52e6cb6f26895d0b83d7b78391c193
4
+ data.tar.gz: 71241a1edc81c57d1bb0549685da47642036fc65ff8d659284c31a9784b0f571
5
5
  SHA512:
6
- metadata.gz: a617926045371fa040ae329241fe37a5ea58ea4fdcd43c2c66f0c3fe2b89cc0b5c2723371c88c9946b4bda4cf20d477a936494a32f6e8e39b9a1b5e727c7e9b4
7
- data.tar.gz: 283ee7c613a1d07b227e32dc823914ea2cdc9b144a9b9d03c0155adf21cdeeacecd92ba8d173d2c0c4bf17febbd1343571898db7c5e24ea828e0f261ae82cbb7
6
+ metadata.gz: 4f473b92097e223dd535512ec5850a6c00d9c22faba482787ffbf2ae7b0f6130d37b0a3ed34a46701f53ec4958e7293a69626bdc9693f1f7a51c0e35ff62bb2e
7
+ data.tar.gz: 530e609fd4535b4d37f82fc4a39ce3322209451bdd0fa67079e74ed2ef658bd252d9a87ca7189a9332e0303b20c8cb073765bc1c99fcdb647c346f893a133ecd
data/Gemfile CHANGED
@@ -2,11 +2,11 @@
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
+
12
+ gemspec
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 **uni-temporal inherited tables**.
4
+ versioning and soft-deletion of records through the use of _uni-temporal inherited tables_.
5
5
 
6
- ### Huh?
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 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
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 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.
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 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.
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
- First, include `Hoardable::Model` into a model you would like to hoard versions of:
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
- ### Basics
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. Continuing our example above:
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` which are created on every update and deletion of a `Post` (by
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
- post_id = post.id
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.reload # => ActiveRecord::RecordNotFound
74
- PostVersion.where(post_id: post_id).size # => 2
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 is a read-only record.
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 the
81
- source post had been deleted, this will untrash it with it’s original primary key.
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 just a normal `ActiveRecord`, you can query them like another model
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: nil)
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 `Post` at a specific time, you can use the `.at` method:
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 will need to add those indexes to the `_versions` tables manually.
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 automatically capture
111
- the ActiveRecord `changes` hash and `operation` that cause the version (`update` or `delete`).
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 are also 3 other optional keys that are provided for tracking contextual information:
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 this contextual data is with a proc in an initializer, i.e.:
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
- Another useful pattern is to use `Hoardable.with` to set the context around a block. A good example
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 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.
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
- ``` ruby
183
+ ```ruby
161
184
  class User
162
- before_save :sanitize_version
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 available options:
208
+ There are two configurable options currently:
180
209
 
181
- ``` ruby
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 'migration.rb.erb', "db/migrate/create_#{singularized_table_name}_versions.rb"
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 :<%= singularized_table_name %>_versions, %i[_during <%= singularized_table_name %>_id]
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
@@ -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
@@ -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
- VERSION = '0.1.0'
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]
@@ -1,78 +1,28 @@
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 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
- 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
-
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}Version"
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
- 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
22
+ include SourceModel
55
23
 
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)
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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hoardable
4
+ VERSION = '0.1.1'
5
+ 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}_versions"
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("_data ->> 'operation' = 'delete'")
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
- hoardable_source&.tap { |tapped| tapped.update!(hoardable_source_attributes.without('id')) } ||
30
- untrash
31
- ).tap do |tapped|
32
- tapped.run_callbacks(:reverted)
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
- alias changes hoardable_changes
58
+ def changes
59
+ _data&.dig('changes')
60
+ end
44
61
 
45
62
  private
46
63
 
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)
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
- self._during = ((previous_temporal_tsrange_end || hoardable_source.created_at)..Time.now)
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.0
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-26 00:00:00.000000000 Z
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: 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